Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@ func runGateway() {

// Register all RPC methods
server.SetLogTee(logTee)
pairingMethods, heartbeatMethods, chatMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants, pgStores.SkillTenantCfgs)
pairingMethods, heartbeatMethods, chatMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants, pgStores.SkillTenantCfgs, sandboxMgr)

// Wire post-turn processor for team task dispatch (WS chat.send + HTTP API paths).
if postTurn != nil {
Expand Down
4 changes: 3 additions & 1 deletion cmd/gateway_methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/gateway/methods"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)

func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore, skillTenantCfgStore store.SkillTenantConfigStore) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods) {
func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore, skillTenantCfgStore store.SkillTenantConfigStore, sandboxMgr sandbox.Manager) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods) {
router := server.Router()

// Phase 1: Core methods
Expand All @@ -22,6 +23,7 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor, msgBus).Register(router)
methods.NewSessionsMethods(sessStore, msgBus, cfg).Register(router)
configMethods := methods.NewConfigMethods(cfg, cfgPath, configSecretsStore, msgBus)
configMethods.SetSandboxManager(sandboxMgr)
if sysConfigStore != nil {
configMethods.SetSystemConfigSync(func(ctx context.Context, c *config.Config) {
// Only sync config for the current tenant (from request context)
Expand Down
47 changes: 40 additions & 7 deletions cmd/gateway_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,50 @@ func setupToolRegistry(
toolsReg = tools.NewRegistry()
agentCfg = cfg.ResolveAgent("default")

// Sandbox manager (optional — routes tools through Docker containers)
// Sandbox manager (optional — Docker and/or bubblewrap; Router picks backend per effective config)
if sbCfg := cfg.Agents.Defaults.Sandbox; sbCfg != nil && sbCfg.Mode != "" && sbCfg.Mode != "off" {
if err := sandbox.CheckDockerAvailable(context.Background()); err != nil {
slog.Warn("sandbox disabled: Docker not available",
resolved := sbCfg.ToSandboxConfig()
bg := context.Background()
var dm *sandbox.DockerManager
if err := sandbox.CheckDockerAvailable(bg); err != nil {
slog.Debug("docker sandbox not available", "error", err)
} else {
dm = sandbox.NewDockerManager(resolved)
}
var bm *sandbox.BwrapManager
if err := sandbox.CheckBwrapAvailable(bg); err != nil {
slog.Debug("bwrap sandbox not available", "error", err)
} else {
bm = sandbox.NewBwrapManager(resolved)
}
primaryOK := false
switch resolved.Backend {
case sandbox.BackendBwrap:
primaryOK = bm != nil
default:
primaryOK = dm != nil
}
if !primaryOK {
slog.Warn("sandbox disabled: primary backend unavailable",
"configured_mode", sbCfg.Mode,
"error", err,
"backend", string(resolved.Backend),
"docker_ok", dm != nil,
"bwrap_ok", bm != nil,
)
} else {
resolved := sbCfg.ToSandboxConfig()
sandboxMgr = sandbox.NewDockerManager(resolved)
slog.Info("sandbox enabled", "mode", string(resolved.Mode), "image", resolved.Image, "scope", string(resolved.Scope))
sandboxMgr = sandbox.NewSandboxRouter(resolved, dm, bm)
infoArgs := []any{
"default_backend", string(resolved.Backend),
"docker", dm != nil,
"bwrap", bm != nil,
"mode", string(resolved.Mode),
"scope", string(resolved.Scope),
"image", resolved.Image,
}
if bm != nil {
infoArgs = append(infoArgs, "bwrap_resource_limits", bm.CgroupLimitsActive())
}
slog.Info("sandbox enabled", infoArgs...)
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/agent/loop_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
// Apply user isolation layer via pipeline.
shared := l.shouldShareWorkspace(req.UserID, req.PeerKind)
effectiveWorkspace := tools.ResolveWorkspace(ws,
tools.UserChatLayer(tools.SanitizePathSegment(req.UserID), shared),
tools.UserChatLayer(tools.WorkspaceUserSegment(req.UserID), shared),
)
if l.shouldShareMemory() {
ctx = store.WithSharedMemory(ctx)
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/loop_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
}
}
promptWorkspace = tools.ResolveWorkspace(baseWs,
tools.UserChatLayer(tools.SanitizePathSegment(userID), shared),
tools.UserChatLayer(tools.WorkspaceUserSegment(userID), shared),
)
}

Expand Down
5 changes: 4 additions & 1 deletion internal/agent/loop_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/tools"
)


// scanWebToolResult checks web_fetch/web_search tool results for prompt injection patterns.
// If detected, prepends a warning (doesn't block — may be false positive).
func (l *Loop) scanWebToolResult(toolName string, result *tools.Result) {
Expand All @@ -36,6 +35,10 @@ func (l *Loop) scanWebToolResult(toolName string, result *tools.Result) {
// shouldShareWorkspace checks if the given user should share the base workspace
// directory (skip per-user subfolder isolation) based on workspace_sharing config.
func (l *Loop) shouldShareWorkspace(userID, peerKind string) bool {
// group:{channel}:{chatId} must never skip UserChatLayer — would expose the channel root.
if strings.HasPrefix(userID, "group:") {
return false
}
ws := l.workspaceSharing
if ws == nil {
return false
Expand Down
29 changes: 7 additions & 22 deletions internal/agent/workspace_sharing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@ func TestShouldShareWorkspace_SharedDM(t *testing.T) {
func TestShouldShareWorkspace_SharedGroup(t *testing.T) {
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{SharedGroup: true}}

if !l.shouldShareWorkspace("group:telegram:-100", "group") {
t.Error("shared_group=true should share for group peer")
if l.shouldShareWorkspace("group:telegram:-100", "group") {
t.Error("group:-scoped chat IDs must not skip UserChatLayer (would expose whole channel workspace)")
}
if l.shouldShareWorkspace("user1", "direct") {
t.Error("shared_group=true should NOT share for direct peer")
}
if !l.shouldShareWorkspace("guild:9:user:1", "group") {
t.Error("shared_group=true should share for non-group:-scoped group peer")
}
}

func TestShouldShareWorkspace_SharedUsers(t *testing.T) {
Expand All @@ -43,8 +46,8 @@ func TestShouldShareWorkspace_SharedUsers(t *testing.T) {
if !l.shouldShareWorkspace("telegram:386246614", "direct") {
t.Error("user in shared_users should share regardless of peerKind")
}
if !l.shouldShareWorkspace("group:telegram:-100", "group") {
t.Error("group in shared_users should share")
if l.shouldShareWorkspace("group:telegram:-100", "group") {
t.Error("group:-scoped IDs must not widen workspace via shared_users")
}
if l.shouldShareWorkspace("unknown-user", "direct") {
t.Error("user NOT in shared_users should not share")
Expand Down Expand Up @@ -120,21 +123,3 @@ func TestShouldShareMemory_IndependentOfWorkspace(t *testing.T) {
t.Error("workspace should be shared when SharedDM=true")
}
}

func TestShouldShareWorkspace_BothEnabled(t *testing.T) {
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{
SharedDM: true,
SharedGroup: true,
SharedUsers: []string{"extra-user"},
}}

if !l.shouldShareWorkspace("user1", "direct") {
t.Error("should share DM")
}
if !l.shouldShareWorkspace("group:tg:-100", "group") {
t.Error("should share group")
}
if !l.shouldShareWorkspace("extra-user", "direct") {
t.Error("should share listed user")
}
}
11 changes: 10 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"encoding/json"
"fmt"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -188,9 +189,10 @@ type MemoryConfig struct {
MinScore float64 `json:"min_score,omitempty"` // minimum relevance score (default 0.35)
}

// SandboxConfig configures Docker-based sandbox execution.
// SandboxConfig configures sandbox execution (Docker or bubblewrap).
// Matching TS agents.defaults.sandbox.
type SandboxConfig struct {
Backend string `json:"backend,omitempty"` // "docker" (default), "bwrap"
Mode string `json:"mode,omitempty"` // "off" (default), "non-main", "all"
Image string `json:"image,omitempty"` // Docker image (default: "goclaw-sandbox:bookworm-slim")
WorkspaceAccess string `json:"workspace_access,omitempty"` // "none", "ro", "rw" (default)
Expand Down Expand Up @@ -222,6 +224,13 @@ func (sc *SandboxConfig) ToSandboxConfig() sandbox.Config {
return cfg
}

switch strings.ToLower(strings.TrimSpace(sc.Backend)) {
case "bwrap":
cfg.Backend = sandbox.BackendBwrap
default:
cfg.Backend = sandbox.BackendDocker
}

switch sc.Mode {
case "all":
cfg.Mode = sandbox.ModeAll
Expand Down
25 changes: 18 additions & 7 deletions internal/gateway/methods/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,31 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)

// ConfigMethods handles config.get, config.apply, config.patch, config.schema.
// Matching TS src/gateway/server-methods/config.ts.
type ConfigMethods struct {
cfg *config.Config
cfgPath string
secretsStore store.ConfigSecretsStore
syncFn func(ctx context.Context, cfg *config.Config) // nil-safe; syncs non-secret settings to system_configs
eventBus bus.EventPublisher // nil-safe; broadcasts config change events
cfg *config.Config
cfgPath string
secretsStore store.ConfigSecretsStore
syncFn func(ctx context.Context, cfg *config.Config) // nil-safe; syncs non-secret settings to system_configs
eventBus bus.EventPublisher // nil-safe; broadcasts config change events
sandboxMgr sandbox.Manager // optional; for config UI runtime flags
}

func NewConfigMethods(cfg *config.Config, cfgPath string, secretsStore store.ConfigSecretsStore, eventBus bus.EventPublisher) *ConfigMethods {
return &ConfigMethods{cfg: cfg, cfgPath: cfgPath, secretsStore: secretsStore, eventBus: eventBus}
}

// SetSandboxManager wires the live sandbox manager so config.get can expose bwrap/systemd capability flags.
func (m *ConfigMethods) SetSandboxManager(mgr sandbox.Manager) {
m.sandboxMgr = mgr
}

// SetSystemConfigSync sets a callback to sync config to system_configs after save.
// The callback receives the final resolved config (with secrets + env applied).
func (m *ConfigMethods) SetSystemConfigSync(fn func(ctx context.Context, cfg *config.Config)) {
Expand Down Expand Up @@ -58,11 +65,15 @@ func (m *ConfigMethods) requireOwner(next gateway.MethodHandler) gateway.MethodH
}

func (m *ConfigMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
payload := map[string]any{
"config": m.cfg.MaskedCopy(),
"hash": m.cfg.Hash(),
"path": m.cfgPath,
}))
}
if snap := sandbox.RuntimeUISnapshot(m.sandboxMgr); len(snap) > 0 {
payload["runtime"] = snap
}
client.SendResponse(protocol.NewOKResponse(req.ID, payload))
}

// handleApply replaces the entire config with the provided JSON5 raw content.
Expand Down
Loading
Loading