diff --git a/cmd/gateway.go b/cmd/gateway.go index f05b2a033..84034615a 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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 { diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go index 5334b959a..af7040d2b 100644 --- a/cmd/gateway_methods.go +++ b/cmd/gateway_methods.go @@ -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 @@ -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) diff --git a/cmd/gateway_setup.go b/cmd/gateway_setup.go index 49b3e4460..f0139d5e1 100644 --- a/cmd/gateway_setup.go +++ b/cmd/gateway_setup.go @@ -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...) } } diff --git a/internal/agent/loop_context.go b/internal/agent/loop_context.go index 6706b6306..4c65ddf1c 100644 --- a/internal/agent/loop_context.go +++ b/internal/agent/loop_context.go @@ -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) diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index 260ea925a..2cc2244ab 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -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), ) } diff --git a/internal/agent/loop_utils.go b/internal/agent/loop_utils.go index 19c6233f2..5af8674d9 100644 --- a/internal/agent/loop_utils.go +++ b/internal/agent/loop_utils.go @@ -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) { @@ -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 diff --git a/internal/agent/workspace_sharing_test.go b/internal/agent/workspace_sharing_test.go index f67bc82b1..2534e7fd7 100644 --- a/internal/agent/workspace_sharing_test.go +++ b/internal/agent/workspace_sharing_test.go @@ -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) { @@ -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") @@ -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") - } -} diff --git a/internal/config/config.go b/internal/config/config.go index e995915de..02b3008b2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" "fmt" + "strings" "sync" "time" @@ -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) @@ -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 diff --git a/internal/gateway/methods/config.go b/internal/gateway/methods/config.go index 91fdbae38..6f5df2b38 100644 --- a/internal/gateway/methods/config.go +++ b/internal/gateway/methods/config.go @@ -11,6 +11,7 @@ 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" ) @@ -18,17 +19,23 @@ import ( // 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)) { @@ -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. diff --git a/internal/sandbox/bwrap.go b/internal/sandbox/bwrap.go new file mode 100644 index 000000000..35d003ec6 --- /dev/null +++ b/internal/sandbox/bwrap.go @@ -0,0 +1,329 @@ +package sandbox + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "math" + "os" + "os/exec" + "slices" + "strings" + "sync" + "time" +) + +const ( + bwrapBin = "/usr/bin/bwrap" + systemdRunBin = "/usr/bin/systemd-run" +) + +// CheckBwrapAvailable verifies only that the bubblewrap binary exists and is executable. +// Resource limits (memory/CPU/pids) require systemd-run --scope when available; see probeSystemdRunScope. +func CheckBwrapAvailable(context.Context) error { + fi, err := os.Stat(bwrapBin) + if err != nil { + return fmt.Errorf("%s: %w", bwrapBin, err) + } + if fi.Mode()&0o111 == 0 { + return fmt.Errorf("%s is not executable", bwrapBin) + } + return nil +} + +// probeSystemdRunScope returns whether systemd-run can create a transient scope (for cgroup limits). +// On failure (e.g. non-root, no user session delegate), logs at DEBUG only so log subscribers at INFO do not see it on the UI stream. +func probeSystemdRunScope(ctx context.Context) bool { + fi, err := os.Stat(systemdRunBin) + if err != nil || fi.Mode()&0o111 == 0 { + slog.Debug("sandbox.bwrap.systemd_run_unavailable", + "reason", "binary_missing_or_not_executable", + "hint", "memory_mb/cpus/pids_limit will not be enforced; bubblewrap isolation still works") + return false + } + runCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + out, err := exec.CommandContext(runCtx, systemdRunBin, "--scope", "/usr/bin/true").CombinedOutput() + if err != nil { + slog.Debug("sandbox.bwrap.systemd_scope_unavailable", + "hint", "memory_mb/cpus/pids_limit will not be enforced; bubblewrap isolation still works", + "error", err, + "output", strings.TrimSpace(string(out))) + return false + } + return true +} + +// BwrapSandbox runs each Exec in a fresh bubblewrap namespace (stateless). +type BwrapSandbox struct { + key string + config Config + resolvedWorkspace string // host path mounted at ContainerWorkdir(); empty if no workspace bind + cgroupViaSystemd bool // false → run bwrap without systemd-run (no MemoryMax/CPUQuota/TasksMax) + mu sync.Mutex + lastUsed time.Time +} + +// Exec runs command inside bwrap (+ optional systemd-run cgroup wrapper). +func (s *BwrapSandbox) Exec(ctx context.Context, command []string, workDir string, opts ...ExecOption) (*ExecResult, error) { + s.mu.Lock() + s.lastUsed = time.Now() + s.mu.Unlock() + + timeout := time.Duration(s.config.TimeoutSec) * time.Second + if timeout <= 0 { + timeout = 5 * time.Minute + } + execCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + o := ApplyExecOpts(opts) + + bwrapArgs := buildBwrapArgs(s.config, s.resolvedWorkspace) + chdir := bwrapEffectiveChdir(s.config, s.resolvedWorkspace, workDir) + bwrapArgs = append(bwrapArgs, "--chdir", chdir, "--") + bwrapArgs = append(bwrapArgs, command...) + + wantLimits := s.config.MemoryMB > 0 || s.config.CPUs > 0 || s.config.PidsLimit > 0 + useSystemdWrap := wantLimits && s.cgroupViaSystemd + var cmd *exec.Cmd + if useSystemdWrap { + sysArgs := []string{"--scope"} + if s.config.MemoryMB > 0 { + sysArgs = append(sysArgs, "-p", fmt.Sprintf("MemoryMax=%dM", s.config.MemoryMB)) + } + if s.config.CPUs > 0 { + pct := int(math.Round(s.config.CPUs * 100)) + if pct < 1 { + pct = 1 + } + sysArgs = append(sysArgs, "-p", fmt.Sprintf("CPUQuota=%d%%", pct)) + } + if s.config.PidsLimit > 0 { + sysArgs = append(sysArgs, "-p", fmt.Sprintf("TasksMax=%d", s.config.PidsLimit)) + } + sysArgs = append(sysArgs, "--", bwrapBin) + sysArgs = append(sysArgs, bwrapArgs...) + cmd = exec.CommandContext(execCtx, systemdRunBin, sysArgs...) + } else { + cmd = exec.CommandContext(execCtx, bwrapBin, bwrapArgs...) + } + + cmd.Env = mergeSandboxEnv(os.Environ(), o.Env) + if len(o.Stdin) > 0 { + cmd.Stdin = bytes.NewReader(o.Stdin) + } + + maxOut := s.config.MaxOutputBytes + if maxOut <= 0 { + maxOut = 1 << 20 + } + stdout := &limitedBuffer{max: maxOut} + stderr := &limitedBuffer{max: maxOut} + cmd.Stdout = stdout + cmd.Stderr = stderr + + slog.Debug("sandbox.bwrap.exec", + "sandbox_key", s.key, + "path", cmd.Path, + "args", cmd.Args, + "work_dir", workDir, + "effective_chdir", chdir, + "resolved_workspace", s.resolvedWorkspace, + "stdin_bytes", len(o.Stdin), + "cgroup_limits", useSystemdWrap, + ) + + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return nil, fmt.Errorf("bwrap exec: %w", err) + } + } + + res := &ExecResult{ExitCode: exitCode, Stdout: stdout.String(), Stderr: stderr.String()} + if stdout.truncated { + res.Stdout += "\n...[output truncated]" + } + if stderr.truncated { + res.Stderr += "\n...[output truncated]" + } + return res, nil +} + +// Destroy is a no-op; bwrap has no persistent process per slot. +func (s *BwrapSandbox) Destroy(context.Context) error { return nil } + +// ID returns the scope key (not a container id). +func (s *BwrapSandbox) ID() string { return s.key } + +func buildBwrapArgs(cfg Config, resolvedHostWS string) []string { + args := []string{ + "--ro-bind", "/usr", "/usr", + "--ro-bind", "/bin", "/bin", + "--ro-bind", "/lib", "/lib", + } + if fi, err := os.Stat("/lib64"); err == nil && fi.IsDir() { + args = append(args, "--ro-bind", "/lib64", "/lib64") + } + args = append(args, + "--ro-bind", "/var", "/var", + "--tmpfs", "/tmp", + "--proc", "/proc", + "--dev", "/dev", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + ) + if !cfg.NetworkEnabled { + args = append(args, "--unshare-net") + } + args = append(args, "--die-with-parent") + + cw := cfg.ContainerWorkdir() + switch cfg.WorkspaceAccess { + case AccessRW: + if resolvedHostWS != "" { + args = append(args, "--bind", resolvedHostWS, cw) + } + case AccessRO: + if resolvedHostWS != "" { + args = append(args, "--ro-bind", resolvedHostWS, cw) + } + } + return args +} + +func bwrapEffectiveChdir(cfg Config, resolvedHostWS, workDir string) string { + if workDir != "" { + return workDir + } + if cfg.WorkspaceAccess != AccessNone && resolvedHostWS != "" { + return cfg.ContainerWorkdir() + } + return "/tmp" +} + +func mergeSandboxEnv(base []string, extra map[string]string) []string { + if len(extra) == 0 { + return base + } + out := slices.Clone(base) + for k, v := range extra { + pair := k + "=" + v + prefix := k + "=" + replaced := false + for i, e := range out { + if strings.HasPrefix(e, prefix) { + out[i] = pair + replaced = true + break + } + } + if !replaced { + out = append(out, pair) + } + } + return out +} + +// BwrapManager tracks scope keys for API parity with DockerManager (no persistent bwrap processes). +type BwrapManager struct { + config Config + sandboxes map[string]*BwrapSandbox + cgroupViaSystemd bool // true if systemd-run --scope works (enforces MemoryMB/CPUs/PidsLimit) + mu sync.RWMutex +} + +// NewBwrapManager creates a bwrap-backed sandbox manager. +func NewBwrapManager(cfg Config) *BwrapManager { + useCgroup := probeSystemdRunScope(context.Background()) + return &BwrapManager{ + config: cfg, + sandboxes: make(map[string]*BwrapSandbox), + cgroupViaSystemd: useCgroup, + } +} + +// CgroupLimitsActive reports whether memory/CPU/pids limits are applied via systemd-run. +func (m *BwrapManager) CgroupLimitsActive() bool { + if m == nil { + return false + } + return m.cgroupViaSystemd +} + +// Get returns or creates a BwrapSandbox for the scope key. +func (m *BwrapManager) Get(ctx context.Context, key string, workspace string, cfgOverride *Config) (Sandbox, error) { + cfg := m.config + if cfgOverride != nil { + cfg = *cfgOverride + } + if cfg.Mode == ModeOff { + return nil, ErrSandboxDisabled + } + + var resolved string + if cfg.WorkspaceAccess != AccessNone && strings.TrimSpace(workspace) != "" { + resolved = resolveHostWorkspacePath(ctx, workspace) + } + + m.mu.Lock() + defer m.mu.Unlock() + if sb, ok := m.sandboxes[key]; ok { + if sb.resolvedWorkspace == resolved { + return sb, nil + } + delete(m.sandboxes, key) + } + + sb := &BwrapSandbox{ + key: key, + config: cfg, + resolvedWorkspace: resolved, + cgroupViaSystemd: m.cgroupViaSystemd, + } + m.sandboxes[key] = sb + slog.Debug("bwrap sandbox slot created", "key", key, "workspace_bind", resolved != "") + return sb, nil +} + +// Release removes a slot (no host resources to free). +func (m *BwrapManager) Release(_ context.Context, key string) error { + m.mu.Lock() + delete(m.sandboxes, key) + m.mu.Unlock() + return nil +} + +// ReleaseAll clears all slots. +func (m *BwrapManager) ReleaseAll(context.Context) error { + m.mu.Lock() + m.sandboxes = make(map[string]*BwrapSandbox) + m.mu.Unlock() + return nil +} + +// Stop is a no-op (no background pruning for bwrap). +func (m *BwrapManager) Stop() {} + +// Stats returns active slot counts. +func (m *BwrapManager) Stats() map[string]any { + m.mu.RLock() + defer m.mu.RUnlock() + keys := make([]string, 0, len(m.sandboxes)) + for k := range m.sandboxes { + keys = append(keys, k) + } + return map[string]any{ + "backend": string(BackendBwrap), + "mode": m.config.Mode, + "active": len(m.sandboxes), + "keys": keys, + "cgroup_limits_via_systemd": m.cgroupViaSystemd, + } +} diff --git a/internal/sandbox/docker.go b/internal/sandbox/docker.go index 4347053d8..9a3ce6694 100644 --- a/internal/sandbox/docker.go +++ b/internal/sandbox/docker.go @@ -164,6 +164,9 @@ func (s *DockerSandbox) Exec(ctx context.Context, command []string, workDir stri o := ApplyExecOpts(opts) args := []string{"exec"} + if len(o.Stdin) > 0 { + args = append(args, "-i") + } // Inject env vars as -e flags before containerID (credentialed exec) for k, v := range o.Env { args = append(args, "-e", k+"="+v) @@ -175,6 +178,9 @@ func (s *DockerSandbox) Exec(ctx context.Context, command []string, workDir stri args = append(args, command...) cmd := exec.CommandContext(execCtx, "docker", args...) + if len(o.Stdin) > 0 { + cmd.Stdin = bytes.NewReader(o.Stdin) + } // Limit output capture to prevent OOM from large command output maxOut := s.config.MaxOutputBytes @@ -244,6 +250,13 @@ func NewDockerManager(cfg Config) *DockerManager { return m } +func dockerResolvedWorkspaceBind(ctx context.Context, cfg Config, workspace string) string { + if cfg.WorkspaceAccess == AccessNone || strings.TrimSpace(workspace) == "" { + return "" + } + return resolveHostWorkspacePath(ctx, workspace) +} + // Get returns an existing sandbox or creates a new one for the given key. // If cfgOverride is non-nil, it is used for new containers instead of the global config. func (m *DockerManager) Get(ctx context.Context, key string, workspace string, cfgOverride *Config) (Sandbox, error) { @@ -255,19 +268,20 @@ func (m *DockerManager) Get(ctx context.Context, key string, workspace string, c return nil, ErrSandboxDisabled } - m.mu.RLock() - if sb, ok := m.sandboxes[key]; ok { - m.mu.RUnlock() - return sb, nil - } - m.mu.RUnlock() + resolved := dockerResolvedWorkspaceBind(ctx, cfg, workspace) m.mu.Lock() defer m.mu.Unlock() - // Double-check if sb, ok := m.sandboxes[key]; ok { - return sb, nil + oldResolved := dockerResolvedWorkspaceBind(ctx, sb.config, sb.workspace) + if oldResolved == resolved { + return sb, nil + } + delete(m.sandboxes, key) + if err := sb.Destroy(ctx); err != nil { + slog.Debug("sandbox.docker.replace_destroy", "key", key, "error", err) + } } prefix := cfg.ContainerPrefix diff --git a/internal/sandbox/fsbridge.go b/internal/sandbox/fsbridge.go index 6188f78be..4cc76cd85 100644 --- a/internal/sandbox/fsbridge.go +++ b/internal/sandbox/fsbridge.go @@ -1,112 +1,102 @@ -// Package sandbox — fsbridge.go provides sandboxed file operations via Docker exec. +// Package sandbox — fsbridge.go provides sandboxed file operations via Sandbox.Exec. // Matching TS src/agents/sandbox/fs-bridge.ts. // // When sandbox is enabled, file tools (read_file, write_file, list_files) // route through FsBridge instead of direct host filesystem access. -// All operations execute inside the Docker container via "docker exec". package sandbox import ( - "bytes" "context" "fmt" - "os/exec" "path/filepath" "strings" ) -// FsBridge provides sandboxed file operations via Docker exec. -// Matching TS SandboxFsBridge in fs-bridge.ts. +// FsBridge provides sandboxed file operations inside a Sandbox (Docker or bwrap). type FsBridge struct { - containerID string - workdir string // container-side working directory (e.g. "/workspace") + sb Sandbox + workdir string // container-side working directory (e.g. "/workspace") } -// NewFsBridge creates a bridge to a running sandbox container. -func NewFsBridge(containerID, workdir string) *FsBridge { +// NewFsBridge creates a bridge that runs file ops through sb.Exec. +func NewFsBridge(sb Sandbox, workdir string) *FsBridge { if workdir == "" { workdir = "/workspace" } return &FsBridge{ - containerID: containerID, - workdir: workdir, + sb: sb, + workdir: workdir, } } -// ReadFile reads file contents from inside the container. -// Matching TS FsBridge.readFile(). +// ReadFile reads file contents from inside the sandbox. func (b *FsBridge) ReadFile(ctx context.Context, path string) (string, error) { resolved := b.resolvePath(path) - stdout, stderr, exitCode, err := b.dockerExec(ctx, nil, "cat", "--", resolved) + res, err := b.sb.Exec(ctx, []string{"cat", "--", resolved}, b.workdir) if err != nil { return "", fmt.Errorf("fsbridge read: %w", err) } - if exitCode != 0 { - return "", fmt.Errorf("read failed: %s", strings.TrimSpace(stderr)) + if res.ExitCode != 0 { + return "", fmt.Errorf("read failed: %s", strings.TrimSpace(res.Stderr)) } - return stdout, nil + return res.Stdout, nil } -// WriteFile writes content to a file inside the container, creating directories as needed. +// WriteFile writes content to a file inside the sandbox, creating directories as needed. // When append is true, content is appended (shell >>); otherwise the file is overwritten (shell >). -// Matching TS FsBridge.writeFile(). func (b *FsBridge) WriteFile(ctx context.Context, path, content string, appendMode bool) error { resolved := b.resolvePath(path) - // Create parent directory dir := resolved[:strings.LastIndex(resolved, "/")] if dir != "" && dir != "/" { - _, _, _, _ = b.dockerExec(ctx, nil, "mkdir", "-p", dir) + _, _ = b.sb.Exec(ctx, []string{"mkdir", "-p", dir}, b.workdir) } redir := ">" if appendMode { redir = ">>" } - // Write content via stdin pipe - _, stderr, exitCode, err := b.dockerExec(ctx, []byte(content), "sh", "-c", fmt.Sprintf("cat %s %q", redir, resolved)) + res, err := b.sb.Exec(ctx, []string{"sh", "-c", fmt.Sprintf("cat %s %q", redir, resolved)}, b.workdir, WithStdin([]byte(content))) if err != nil { return fmt.Errorf("fsbridge write: %w", err) } - if exitCode != 0 { - return fmt.Errorf("write failed: %s", strings.TrimSpace(stderr)) + if res.ExitCode != 0 { + return fmt.Errorf("write failed: %s", strings.TrimSpace(res.Stderr)) } return nil } -// ListDir lists files and directories inside the container. -// Matching TS FsBridge.readdir(). +// ListDir lists files and directories inside the sandbox. func (b *FsBridge) ListDir(ctx context.Context, path string) (string, error) { resolved := b.resolvePath(path) - // Use ls -la for detailed listing - stdout, stderr, exitCode, err := b.dockerExec(ctx, nil, "ls", "-la", "--", resolved) + res, err := b.sb.Exec(ctx, []string{"ls", "-la", "--", resolved}, b.workdir) if err != nil { return "", fmt.Errorf("fsbridge list: %w", err) } - if exitCode != 0 { - return "", fmt.Errorf("list failed: %s", strings.TrimSpace(stderr)) + if res.ExitCode != 0 { + return "", fmt.Errorf("list failed: %s", strings.TrimSpace(res.Stderr)) } - return stdout, nil + return res.Stdout, nil } // Stat checks if a path exists and returns basic info. func (b *FsBridge) Stat(ctx context.Context, path string) (string, error) { resolved := b.resolvePath(path) - stdout, stderr, exitCode, err := b.dockerExec(ctx, nil, "stat", "--", resolved) + res, err := b.sb.Exec(ctx, []string{"stat", "--", resolved}, b.workdir) if err != nil { return "", fmt.Errorf("fsbridge stat: %w", err) } - if exitCode != 0 { - return "", fmt.Errorf("stat failed: %s", strings.TrimSpace(stderr)) + if res.ExitCode != 0 { + return "", fmt.Errorf("stat failed: %s", strings.TrimSpace(res.Stderr)) } - return stdout, nil + return res.Stdout, nil } // resolvePath resolves a path relative to the container workdir. @@ -116,46 +106,11 @@ func (b *FsBridge) resolvePath(path string) string { return b.workdir } if strings.HasPrefix(path, "/") { - // Validate absolute paths stay within workdir (defense in depth, - // container is already sandboxed with read-only FS + cap-drop ALL). cleaned := filepath.Clean(path) if cleaned == b.workdir || strings.HasPrefix(cleaned, b.workdir+"/") { return cleaned } - return b.workdir // fallback to workdir for escapes + return b.workdir } - // Relative paths: use filepath.Join for proper normalization return filepath.Clean(filepath.Join(b.workdir, path)) } - -// dockerExec runs a command inside the container and returns stdout, stderr, exit code. -func (b *FsBridge) dockerExec(ctx context.Context, stdin []byte, args ...string) (string, string, int, error) { - dockerArgs := []string{"exec"} - if stdin != nil { - dockerArgs = append(dockerArgs, "-i") - } - dockerArgs = append(dockerArgs, b.containerID) - dockerArgs = append(dockerArgs, args...) - - cmd := exec.CommandContext(ctx, "docker", dockerArgs...) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if stdin != nil { - cmd.Stdin = bytes.NewReader(stdin) - } - - err := cmd.Run() - exitCode := 0 - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - err = nil // non-zero exit is not an execution error - } else { - return "", "", -1, err - } - } - - return stdout.String(), stderr.String(), exitCode, nil -} diff --git a/internal/sandbox/router.go b/internal/sandbox/router.go new file mode 100644 index 000000000..44c433c21 --- /dev/null +++ b/internal/sandbox/router.go @@ -0,0 +1,95 @@ +package sandbox + +import ( + "context" + "fmt" + "log/slog" +) + +// SandboxRouter dispatches Get to DockerManager or BwrapManager based on effective config Backend. +type SandboxRouter struct { + base Config + docker *DockerManager + bwrap *BwrapManager +} + +// NewSandboxRouter returns a Manager that routes per-request backend. Either sub-manager may be nil +// if that backend was unavailable at startup; Get returns an error if the requested backend is nil. +func NewSandboxRouter(base Config, docker *DockerManager, bwrap *BwrapManager) Manager { + return &SandboxRouter{base: base, docker: docker, bwrap: bwrap} +} + +// Get implements Manager. +func (r *SandboxRouter) Get(ctx context.Context, key, workspace string, cfgOverride *Config) (Sandbox, error) { + cfg := r.base + if cfgOverride != nil { + cfg = *cfgOverride + } + if cfg.Mode == ModeOff { + return nil, ErrSandboxDisabled + } + switch cfg.Backend { + case BackendBwrap: + if r.bwrap == nil { + return nil, fmt.Errorf("bwrap sandbox unavailable: need %s installed and executable", bwrapBin) + } + return r.bwrap.Get(ctx, key, workspace, cfgOverride) + default: + if r.docker == nil { + return nil, fmt.Errorf("docker sandbox unavailable") + } + return r.docker.Get(ctx, key, workspace, cfgOverride) + } +} + +// Release destroys a sandbox key on both backends (no-op for missing side). +func (r *SandboxRouter) Release(ctx context.Context, key string) error { + if r.docker != nil { + if err := r.docker.Release(ctx, key); err != nil { + slog.Debug("sandbox.router.docker_release", "key", key, "error", err) + } + } + if r.bwrap != nil { + if err := r.bwrap.Release(ctx, key); err != nil { + slog.Debug("sandbox.router.bwrap_release", "key", key, "error", err) + } + } + return nil +} + +// ReleaseAll implements Manager. +func (r *SandboxRouter) ReleaseAll(ctx context.Context) error { + if r.docker != nil { + _ = r.docker.ReleaseAll(ctx) + } + if r.bwrap != nil { + _ = r.bwrap.ReleaseAll(ctx) + } + return nil +} + +// Stop implements Manager. +func (r *SandboxRouter) Stop() { + if r.docker != nil { + r.docker.Stop() + } + if r.bwrap != nil { + r.bwrap.Stop() + } +} + +// Stats implements Manager. +func (r *SandboxRouter) Stats() map[string]any { + out := map[string]any{ + "default_backend": string(r.base.Backend), + "docker_ready": r.docker != nil, + "bwrap_ready": r.bwrap != nil, + } + if r.docker != nil { + out["docker"] = r.docker.Stats() + } + if r.bwrap != nil { + out["bwrap"] = r.bwrap.Stats() + } + return out +} diff --git a/internal/sandbox/runtime_ui.go b/internal/sandbox/runtime_ui.go new file mode 100644 index 000000000..323b933c3 --- /dev/null +++ b/internal/sandbox/runtime_ui.go @@ -0,0 +1,27 @@ +package sandbox + +import "context" + +// RuntimeUISnapshot returns server-side flags for the settings UI (config.get). +// When mgr is nil (sandbox disabled), bwrap_cgroup_limits_active is still probed so the UI can +// preview whether limits would apply after enabling bubblewrap. +func RuntimeUISnapshot(mgr Manager) map[string]any { + ctx := context.Background() + bwrapBinOK := CheckBwrapAvailable(ctx) == nil + snap := map[string]any{ + "bwrap_binary_ok": bwrapBinOK, + } + cgroupActive := false + if mgr != nil { + st := mgr.Stats() + if b, ok := st["bwrap"].(map[string]any); ok { + if v, ok := b["cgroup_limits_via_systemd"].(bool); ok { + cgroupActive = v + } + } + } else if bwrapBinOK { + cgroupActive = probeSystemdRunScope(ctx) + } + snap["bwrap_cgroup_limits_active"] = cgroupActive + return snap +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 523f5f51b..6f91338b5 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -1,7 +1,6 @@ -// Package sandbox provides Docker-based code execution isolation. +// Package sandbox provides isolated code execution (Docker containers or bubblewrap on the host). // -// Agents can run tool commands (exec, shell) inside Docker containers -// instead of the host system. Sandbox modes: +// Agents can run tool commands (exec, shell) inside a sandbox instead of the host system. Sandbox modes: // - off: no sandboxing, execute directly on host // - non-main: all agents except "main" run in sandbox // - all: every agent runs in sandbox @@ -50,9 +49,18 @@ const ( ScopeShared Scope = "shared" // one container for all ) +// BackendType selects the sandbox implementation. +type BackendType string + +const ( + BackendDocker BackendType = "docker" // Docker containers (default) + BackendBwrap BackendType = "bwrap" // bubblewrap on host; optional systemd-run for cgroup limits +) + // Config configures the sandbox system. // Matches TS SandboxDockerSettings + SandboxConfig. type Config struct { + Backend BackendType `json:"backend"` Mode Mode `json:"mode"` Image string `json:"image"` WorkspaceAccess Access `json:"workspace_access"` @@ -85,6 +93,7 @@ type Config struct { // DefaultConfig returns sensible defaults matching TS sandbox defaults. func DefaultConfig() Config { return Config{ + Backend: BackendDocker, Mode: ModeOff, Image: "goclaw-sandbox:bookworm-slim", WorkspaceAccess: AccessRW, @@ -163,7 +172,8 @@ type ExecOption func(*ExecOpts) // ExecOpts holds optional settings applied via ExecOption. type ExecOpts struct { - Env map[string]string // extra env vars injected into the container exec + Env map[string]string // extra env vars injected into the container exec + Stdin []byte // optional stdin (e.g. fsbridge write via shell) } // WithEnv injects additional environment variables into the sandbox exec call. @@ -172,6 +182,11 @@ func WithEnv(env map[string]string) ExecOption { return func(o *ExecOpts) { o.Env = env } } +// WithStdin supplies stdin bytes for the executed command (e.g. piped content). +func WithStdin(b []byte) ExecOption { + return func(o *ExecOpts) { o.Stdin = b } +} + // ApplyExecOpts resolves variadic ExecOption into ExecOpts. func ApplyExecOpts(opts []ExecOption) ExecOpts { var o ExecOpts diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index 8c5e9942f..31114d134 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -310,10 +310,10 @@ type WorkspaceSharingConfig struct { } const ( - ReasoningSourceUnset = "unset" - ReasoningSourceLegacy = "thinking_level" - ReasoningSourceAdvanced = "reasoning" - ReasoningSourceProviderDefault = "provider_default" + ReasoningSourceUnset = "unset" + ReasoningSourceLegacy = "thinking_level" + ReasoningSourceAdvanced = "reasoning" + ReasoningSourceProviderDefault = "provider_default" // Reasoning fallback constants — canonical definitions in providers package. ReasoningFallbackDowngrade = providers.ReasoningFallbackDowngrade ReasoningFallbackDisable = providers.ReasoningFallbackDisable diff --git a/internal/tools/credentialed_exec.go b/internal/tools/credentialed_exec.go index a948620f4..9d51b1208 100644 --- a/internal/tools/credentialed_exec.go +++ b/internal/tools/credentialed_exec.go @@ -261,16 +261,22 @@ func (t *ExecTool) executeCredentialedHost(ctx context.Context, absPath string, func (t *ExecTool) executeCredentialedSandbox(ctx context.Context, absPath string, args []string, cwd string, sandboxKey string, envMap map[string]string, timeout time.Duration) *Result { - sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx)) + mount := SandboxHostMountRoot(ctx, t.workspace) + sb, err := t.sandboxMgr.Get(ctx, sandboxKey, mount, SandboxConfigFromCtx(ctx)) if err != nil { slog.Warn("security.credentialed_exec_sandbox_unavailable", "binary", absPath, "error", err) return ErrorResult("credentialed exec requires sandbox but sandbox is unavailable: " + err.Error()) } + containerCwd, cwdErr := SandboxHostPathToContainer(cwd, mount, sandbox.DefaultContainerWorkdir) + if cwdErr != nil { + return ErrorResult("credentialed exec sandbox path mapping: " + cwdErr.Error()) + } + // Direct exec inside sandbox: [absPath, args...] with env injection command := append([]string{absPath}, args...) - result, err := sb.Exec(ctx, command, cwd, sandbox.WithEnv(envMap)) + result, err := sb.Exec(ctx, command, containerCwd, sandbox.WithEnv(envMap)) if err != nil { return ErrorResult(fmt.Sprintf("credentialed sandbox exec: %v", err)) } @@ -287,7 +293,7 @@ func (t *ExecTool) executeCredentialedSandbox(ctx context.Context, absPath strin return credentialedExecFailError(absPath, args, result.ExitCode, scrubbed+MaybeSandboxHint(result.ExitCode, scrubbed)) } if output == "" { - output = "(command completed with no output)" + output = execExitZeroNoStdout } output = ScrubCredentials(output) output = capExecOutput(output, execMaxOutputChars) @@ -336,7 +342,7 @@ func formatCredentialedResult(binary string, args []string, } if output == "" { - output = "(command completed with no output)" + output = execExitZeroNoStdout } output = ScrubCredentials(output) output = capExecOutput(output, execMaxOutputChars) diff --git a/internal/tools/edit.go b/internal/tools/edit.go index 7ad592666..8d2b012cf 100644 --- a/internal/tools/edit.go +++ b/internal/tools/edit.go @@ -193,18 +193,19 @@ func (t *EditTool) Execute(ctx context.Context, args map[string]any) *Result { } func (t *EditTool) executeInSandbox(ctx context.Context, path, oldStr, newStr string, replaceAll bool, sandboxKey string) *Result { - sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx)) + mount := SandboxHostMountRoot(ctx, t.workspace) + sb, err := t.sandboxMgr.Get(ctx, sandboxKey, mount, SandboxConfigFromCtx(ctx)) if err != nil { return ErrorResult(fmt.Sprintf("sandbox error: %v", err)) } - containerCwd, cwdErr := SandboxCwd(ctx, t.workspace, sandbox.DefaultContainerWorkdir) + containerCwd, cwdErr := SandboxCwd(ctx, mount, sandbox.DefaultContainerWorkdir) if cwdErr != nil { return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr)) } containerPath := ResolveSandboxPath(path, containerCwd) - bridge := sandbox.NewFsBridge(sb.ID(), sandbox.DefaultContainerWorkdir) + bridge := sandbox.NewFsBridge(sb, sandbox.DefaultContainerWorkdir) content, err := bridge.ReadFile(ctx, containerPath) if err != nil { return ErrorResult(fmt.Sprintf("failed to read file: %v", err) + MaybeFsBridgeHint(err)) diff --git a/internal/tools/filesystem.go b/internal/tools/filesystem.go index c0e893195..cdf2d978a 100644 --- a/internal/tools/filesystem.go +++ b/internal/tools/filesystem.go @@ -189,7 +189,8 @@ func (t *ReadFileTool) executeInSandbox(ctx context.Context, path, sandboxKey st return ErrorResult(fmt.Sprintf("sandbox error: %v", err)) } - containerCwd, cwdErr := SandboxCwd(ctx, t.workspace, sandbox.DefaultContainerWorkdir) + mount := SandboxHostMountRoot(ctx, t.workspace) + containerCwd, cwdErr := SandboxCwd(ctx, mount, sandbox.DefaultContainerWorkdir) if cwdErr != nil { return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr)) } @@ -204,11 +205,12 @@ func (t *ReadFileTool) executeInSandbox(ctx context.Context, path, sandboxKey st } func (t *ReadFileTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) { - sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx)) + mount := SandboxHostMountRoot(ctx, t.workspace) + sb, err := t.sandboxMgr.Get(ctx, sandboxKey, mount, SandboxConfigFromCtx(ctx)) if err != nil { return nil, err } - return sandbox.NewFsBridge(sb.ID(), sandbox.DefaultContainerWorkdir), nil + return sandbox.NewFsBridge(sb, sandbox.DefaultContainerWorkdir), nil } // readFileMaxChars is the output cap for read_file. Large files require offset/limit pagination. diff --git a/internal/tools/filesystem_list.go b/internal/tools/filesystem_list.go index 409a7239f..05243281a 100644 --- a/internal/tools/filesystem_list.go +++ b/internal/tools/filesystem_list.go @@ -138,7 +138,8 @@ func (t *ListFilesTool) executeInSandbox(ctx context.Context, path, sandboxKey s return ErrorResult(fmt.Sprintf("sandbox error: %v", err)) } - containerCwd, cwdErr := SandboxCwd(ctx, t.workspace, sandbox.DefaultContainerWorkdir) + mount := SandboxHostMountRoot(ctx, t.workspace) + containerCwd, cwdErr := SandboxCwd(ctx, mount, sandbox.DefaultContainerWorkdir) if cwdErr != nil { return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr)) } @@ -153,9 +154,10 @@ func (t *ListFilesTool) executeInSandbox(ctx context.Context, path, sandboxKey s } func (t *ListFilesTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) { - sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx)) + mount := SandboxHostMountRoot(ctx, t.workspace) + sb, err := t.sandboxMgr.Get(ctx, sandboxKey, mount, SandboxConfigFromCtx(ctx)) if err != nil { return nil, err } - return sandbox.NewFsBridge(sb.ID(), sandbox.DefaultContainerWorkdir), nil + return sandbox.NewFsBridge(sb, sandbox.DefaultContainerWorkdir), nil } diff --git a/internal/tools/filesystem_write.go b/internal/tools/filesystem_write.go index c9efba341..91f514756 100644 --- a/internal/tools/filesystem_write.go +++ b/internal/tools/filesystem_write.go @@ -228,7 +228,8 @@ func (t *WriteFileTool) executeInSandbox(ctx context.Context, path, content, san return ErrorResult(fmt.Sprintf("sandbox error: %v", err)) } - containerCwd, cwdErr := SandboxCwd(ctx, t.workspace, sandbox.DefaultContainerWorkdir) + mount := SandboxHostMountRoot(ctx, t.workspace) + containerCwd, cwdErr := SandboxCwd(ctx, mount, sandbox.DefaultContainerWorkdir) if cwdErr != nil { return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr)) } @@ -268,9 +269,10 @@ func (t *WriteFileTool) executeInSandbox(ctx context.Context, path, content, san } func (t *WriteFileTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) { - sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx)) + mount := SandboxHostMountRoot(ctx, t.workspace) + sb, err := t.sandboxMgr.Get(ctx, sandboxKey, mount, SandboxConfigFromCtx(ctx)) if err != nil { return nil, err } - return sandbox.NewFsBridge(sb.ID(), sandbox.DefaultContainerWorkdir), nil + return sandbox.NewFsBridge(sb, sandbox.DefaultContainerWorkdir), nil } diff --git a/internal/tools/sandbox_utils.go b/internal/tools/sandbox_utils.go index e1ccd1a14..b96943d71 100644 --- a/internal/tools/sandbox_utils.go +++ b/internal/tools/sandbox_utils.go @@ -7,26 +7,59 @@ import ( "strings" ) +// SandboxHostMountRoot returns the host path to bind at the sandbox container workdir +// (e.g. /workspace). registryWorkspace is the tool registry root (ExecTool.workspace), +// used only when ToolWorkspaceFromCtx is empty. +// +// When ToolWorkspaceFromCtx is set (effective session workspace from injectContext: +// DM/group/team paths, or shared agent base when workspace sharing applies), that path +// is mounted so the sandbox sees only that session — not sibling agents or channels. +// Inside the container, containerBase (e.g. /workspace) is the root of that directory. +func SandboxHostMountRoot(ctx context.Context, registryWorkspace string) string { + ws := ToolWorkspaceFromCtx(ctx) + if ws == "" { + return registryWorkspace + } + return filepath.Clean(ws) +} + // SandboxCwd maps the current effective workspace (from context) to its -// corresponding path inside the sandbox container. The sandbox mounts the -// global workspace root at containerBase (usually "/workspace"). This function -// computes the relative path from globalWorkspace to the context workspace -// and joins it with containerBase. +// corresponding path inside the sandbox container. hostMountRoot must match the path +// passed to sandbox.Manager.Get(..., workspace, ...) for this request (use SandboxHostMountRoot). // -// Example: globalWorkspace="/app/workspace", ctx workspace="/app/workspace/agent-a/user-123" -// → returns "/workspace/agent-a/user-123" -func SandboxCwd(ctx context.Context, globalWorkspace, containerBase string) (string, error) { +// When hostMountRoot equals the context workspace (typical), this returns containerBase +// (e.g. /workspace). If hostMountRoot is a strict ancestor of the context workspace, +// the result is containerBase plus the relative suffix. +func SandboxCwd(ctx context.Context, hostMountRoot, containerBase string) (string, error) { ws := ToolWorkspaceFromCtx(ctx) if ws == "" { // No per-request workspace — fall back to container root. return containerBase, nil } - rel, err := filepath.Rel(globalWorkspace, ws) + rel, err := filepath.Rel(hostMountRoot, ws) if err != nil || strings.HasPrefix(filepath.Clean(rel), "..") { - return "", fmt.Errorf("workspace %q is outside global mount %q", ws, globalWorkspace) + return "", fmt.Errorf("workspace %q is outside sandbox mount %q", ws, hostMountRoot) + } + + if rel == "." { + return containerBase, nil } + return filepath.Join(containerBase, rel), nil +} +// SandboxHostPathToContainer maps a host working directory under hostMountRoot to the path +// inside the sandbox (same mount root as Manager.Get). Use for exec cmd.Dir / docker -w. +func SandboxHostPathToContainer(hostPath, hostMountRoot, containerBase string) (string, error) { + if hostPath == "" { + return containerBase, nil + } + hostPath = filepath.Clean(hostPath) + root := filepath.Clean(hostMountRoot) + rel, err := filepath.Rel(root, hostPath) + if err != nil || strings.HasPrefix(filepath.Clean(rel), "..") { + return "", fmt.Errorf("path %q is outside sandbox mount %q", hostPath, hostMountRoot) + } if rel == "." { return containerBase, nil } diff --git a/internal/tools/sandbox_utils_test.go b/internal/tools/sandbox_utils_test.go index 0fb9d4f90..b3e1a3269 100644 --- a/internal/tools/sandbox_utils_test.go +++ b/internal/tools/sandbox_utils_test.go @@ -5,11 +5,120 @@ import ( "testing" ) +func TestSandboxHostMountRoot(t *testing.T) { + tests := []struct { + name string + ctxWorkspace string + registryWorkspace string + want string + }{ + { + name: "no context workspace uses registry", + ctxWorkspace: "", + registryWorkspace: "/home/u/.goclaw/workspace", + want: "/home/u/.goclaw/workspace", + }, + { + name: "DM session path is mount root", + ctxWorkspace: "/home/u/.goclaw/workspace/fox-spirit/telegram/52007861", + registryWorkspace: "/home/u/.goclaw/workspace", + want: "/home/u/.goclaw/workspace/fox-spirit/telegram/52007861", + }, + { + name: "team group session path", + ctxWorkspace: "/home/u/.goclaw/workspace/teams/019d6093-eaa9-7a13-a4f1-d7b8925c300c/-1003819627125", + registryWorkspace: "/home/u/.goclaw/workspace", + want: "/home/u/.goclaw/workspace/teams/019d6093-eaa9-7a13-a4f1-d7b8925c300c/-1003819627125", + }, + { + name: "shared agent base when context is agent root", + ctxWorkspace: "/home/u/.goclaw/workspace/fox-spirit", + registryWorkspace: "/home/u/.goclaw/workspace/fox-spirit", + want: "/home/u/.goclaw/workspace/fox-spirit", + }, + { + name: "context outside registry uses context path", + ctxWorkspace: "/home/u/workspace/fox/telegram/52007861", + registryWorkspace: "/home/u/.goclaw/workspace", + want: "/home/u/workspace/fox/telegram/52007861", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + if tt.ctxWorkspace != "" { + ctx = WithToolWorkspace(ctx, tt.ctxWorkspace) + } + got := SandboxHostMountRoot(ctx, tt.registryWorkspace) + if got != tt.want { + t.Errorf("SandboxHostMountRoot() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSandboxHostPathToContainer(t *testing.T) { + tests := []struct { + name string + hostPath string + hostMountRoot string + containerBase string + want string + wantErr bool + }{ + { + name: "empty host path is container base", + hostPath: "", + hostMountRoot: "/app/ws", + containerBase: "/workspace", + want: "/workspace", + }, + { + name: "mount root", + hostPath: "/app/ws", + hostMountRoot: "/app/ws", + containerBase: "/workspace", + want: "/workspace", + }, + { + name: "nested cwd", + hostPath: "/app/ws/agent/u1/sub", + hostMountRoot: "/app/ws", + containerBase: "/workspace", + want: "/workspace/agent/u1/sub", + }, + { + name: "outside mount", + hostPath: "/other/x", + hostMountRoot: "/app/ws", + containerBase: "/workspace", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := SandboxHostPathToContainer(tt.hostPath, tt.hostMountRoot, tt.containerBase) + if tt.wantErr { + if err == nil { + t.Errorf("SandboxHostPathToContainer() = %q, want error", got) + } + return + } + if err != nil { + t.Fatalf("SandboxHostPathToContainer() error = %v", err) + } + if got != tt.want { + t.Errorf("SandboxHostPathToContainer() = %q, want %q", got, tt.want) + } + }) + } +} + func TestSandboxCwd(t *testing.T) { tests := []struct { name string ctxWorkspace string // empty = no workspace in context - globalWorkspace string + globalWorkspace string // host mount root passed to SandboxCwd (must match Manager.Get) containerBase string want string wantErr bool @@ -29,39 +138,53 @@ func TestSandboxCwd(t *testing.T) { want: "/workspace", }, { - name: "per-agent workspace", - ctxWorkspace: "/app/workspace/agent-a-workspace", - globalWorkspace: "/app/workspace", + name: "session leaf mount cwd is container root", + ctxWorkspace: "/app/workspace/fox-spirit/telegram/52007861", + globalWorkspace: "/app/workspace/fox-spirit/telegram/52007861", containerBase: "/workspace", - want: "/workspace/agent-a-workspace", + want: "/workspace", }, { - name: "per-user workspace", - ctxWorkspace: "/app/workspace/agent-a/user-123", - globalWorkspace: "/app/workspace", + name: "team session leaf mount", + ctxWorkspace: "/app/workspace/teams/uuid/-1003819627125", + globalWorkspace: "/app/workspace/teams/uuid/-1003819627125", containerBase: "/workspace", - want: "/workspace/agent-a/user-123", + want: "/workspace", }, { - name: "team workspace", - ctxWorkspace: "/app/workspace/teams/team-uuid/chat-123", - globalWorkspace: "/app/workspace", + name: "shared workspace mount equals agent base", + ctxWorkspace: "/app/workspace/fox-spirit", + globalWorkspace: "/app/workspace/fox-spirit", containerBase: "/workspace", - want: "/workspace/teams/team-uuid/chat-123", + want: "/workspace", }, { - name: "workspace outside global mount — error", + name: "workspace outside host mount — error", ctxWorkspace: "/other/path/agent-a", globalWorkspace: "/app/workspace", containerBase: "/workspace", wantErr: true, }, { - name: "custom container base", - ctxWorkspace: "/app/workspace/agent-a", - globalWorkspace: "/app/workspace", + name: "disjoint tree: mount equals context workspace", + ctxWorkspace: "/home/u/workspace/fox/telegram/1", + globalWorkspace: "/home/u/workspace/fox/telegram/1", + containerBase: "/workspace", + want: "/workspace", + }, + { + name: "disjoint tree: nested under context-as-mount", + ctxWorkspace: "/home/u/workspace/fox/telegram/1", + globalWorkspace: "/home/u/workspace/fox", + containerBase: "/workspace", + want: "/workspace/telegram/1", + }, + { + name: "custom container base session mount", + ctxWorkspace: "/app/workspace/agent-a/sub", + globalWorkspace: "/app/workspace/agent-a/sub", containerBase: "/home/sandbox", - want: "/home/sandbox/agent-a", + want: "/home/sandbox", }, } diff --git a/internal/tools/shell.go b/internal/tools/shell.go index 5d2904e61..ab884a22d 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -16,6 +16,9 @@ import ( "golang.org/x/text/unicode/norm" ) +// execExitZeroNoStdout is used when exit code is 0 but stdout is empty (e.g. ls on an empty directory). +const execExitZeroNoStdout = "(exit 0; no stdout)" + // Dangerous command patterns organized into configurable deny groups. // Defense-in-depth: patterns complement Docker hardening (cap-drop ALL, // no-new-privileges, pids-limit, memory limit). @@ -324,7 +327,7 @@ func (t *ExecTool) executeOnHost(ctx context.Context, command, cwd string) *Resu } if result == "" { - result = "(command completed with no output)" + result = execExitZeroNoStdout } return SilentResult(capExecOutput(result, execMaxOutputChars)) @@ -332,7 +335,8 @@ func (t *ExecTool) executeOnHost(ctx context.Context, command, cwd string) *Resu // executeInSandbox routes a command through a Docker sandbox container. func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd, sandboxKey string) *Result { - sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx)) + mount := SandboxHostMountRoot(ctx, t.workspace) + sb, err := t.sandboxMgr.Get(ctx, sandboxKey, mount, SandboxConfigFromCtx(ctx)) if err != nil { if errors.Is(err, sandbox.ErrSandboxDisabled) { return t.executeOnHost(ctx, command, cwd) @@ -346,8 +350,7 @@ func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd, sandboxKe return ErrorResult(fmt.Sprintf("sandbox unavailable: %v (will not fall back to unsandboxed host execution)", err)) } - // Map host workdir to container workdir via SandboxCwd helper. - containerCwd, cwdErr := SandboxCwd(ctx, t.workspace, sandbox.DefaultContainerWorkdir) + containerCwd, cwdErr := SandboxHostPathToContainer(cwd, mount, sandbox.DefaultContainerWorkdir) if cwdErr != nil { return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr)) } @@ -373,7 +376,7 @@ func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd, sandboxKe return ErrorResult(output) } if output == "" { - output = "(command completed with no output)" + output = execExitZeroNoStdout } return SilentResult(capExecOutput(output, execMaxOutputChars)) diff --git a/internal/tools/workspace_resolver.go b/internal/tools/workspace_resolver.go index d61cf61a1..59ba10058 100644 --- a/internal/tools/workspace_resolver.go +++ b/internal/tools/workspace_resolver.go @@ -78,3 +78,13 @@ func SanitizePathSegment(s string) string { } return b.String() } + +// WorkspaceUserSegment maps gateway user ids to a path segment; for "group:{ch}:{chatId}" +// only chatId is used (channel is usually already in the profile workspace base). +func WorkspaceUserSegment(userID string) string { + parts := strings.SplitN(userID, ":", 3) + if len(parts) == 3 && parts[0] == "group" && parts[2] != "" { + return SanitizePathSegment(parts[2]) + } + return SanitizePathSegment(userID) +} diff --git a/internal/tools/workspace_resolver_test.go b/internal/tools/workspace_resolver_test.go index 134bda678..afb8bcbcf 100644 --- a/internal/tools/workspace_resolver_test.go +++ b/internal/tools/workspace_resolver_test.go @@ -177,6 +177,23 @@ func TestResolveWorkspace_SharedTrue(t *testing.T) { } } +func TestWorkspaceUserSegment(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"group:telegram:-1003819627125", "-1003819627125"}, + {"guild:9:user:1", "guild_9_user_1"}, + {"user:telegram:12345", "user_telegram_12345"}, + } + for _, tt := range tests { + got := WorkspaceUserSegment(tt.input) + if got != tt.want { + t.Errorf("WorkspaceUserSegment(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + func TestSanitizePathSegment(t *testing.T) { tests := []struct { input string diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index a401060b8..06e3f46a4 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -645,7 +645,10 @@ }, "sandbox": { "title": "Sandbox", - "description": "Docker sandbox for code execution isolation", + "description": "Docker or Bubblewrap isolation for code execution", + "backend": "Backend", + "backendTip": "docker: container image; bwrap: host /usr/bin/bwrap; cgroup limits need working systemd-run when allowed.", + "bwrapNote": "Requires /usr/bin/bwrap. Install bubblewrap via apt/yum/apk. Memory/CPU/pids caps need systemd-run scopes; if that fails (e.g. non-root), sandbox still runs without those limits.", "mode": "Mode", "workspaceAccess": "Workspace Access", "image": "Image", diff --git a/ui/web/src/i18n/locales/en/config.json b/ui/web/src/i18n/locales/en/config.json index 3d5a4b174..5d9fdcc43 100644 --- a/ui/web/src/i18n/locales/en/config.json +++ b/ui/web/src/i18n/locales/en/config.json @@ -108,7 +108,13 @@ "agents.pruning.keepLastAssistantsTip": "Number of recent assistant turns to always preserve during pruning.", "agents.sandbox.title": "Sandbox", - "agents.sandbox.desc": "Docker sandbox for code execution", + "agents.sandbox.desc": "Isolate tool execution with Docker or Bubblewrap (host namespaces).", + "agents.sandbox.backend": "Backend", + "agents.sandbox.backendTip": "Docker uses containers; Bubblewrap uses /usr/bin/bwrap on the host. Memory/CPU/pids limits need systemd-run --scope when permitted (often absent in containers or without user-session delegation).", + "agents.sandbox.bwrapNote": "Requires /usr/bin/bwrap (apt/yum/apk install bubblewrap). Resource limits only apply if systemd-run can create scopes; otherwise isolation still works without memory/CPU cgroup caps.", + "agents.sandbox.bwrapSystemdLimitsTitle": "systemd-run scopes unavailable on this host", + "agents.sandbox.bwrapSystemdLimitsBody": "Memory, CPU, and the network toggle below are not enforced for Bubblewrap without systemd transient scopes (common in Docker or without user-session cgroup delegation). Bubblewrap still isolates PID, IPC, UTS, and optionally network via namespaces. Command timeout still applies. Install bubblewrap: apt/yum/apk; fixing scopes usually requires running the gateway on the host or enabling delegation.", + "agents.sandbox.bwrapFieldDisabledTip": "Not applied for Bubblewrap on this server because systemd-run cannot create cgroup scopes.", "agents.sandbox.mode": "Mode", "agents.sandbox.image": "Docker Image", "agents.sandbox.imageTip": "Docker image used for the sandbox container.", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index 2a1a042bf..a641ef6a6 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -645,7 +645,10 @@ }, "sandbox": { "title": "Hộp cát", - "description": "Hộp cát Docker để cô lập thực thi code", + "description": "Cô lập thực thi code bằng Docker hoặc Bubblewrap", + "backend": "Backend", + "backendTip": "docker: container; bwrap: /usr/bin/bwrap trên host; giới hạn cgroup cần systemd-run khi được phép.", + "bwrapNote": "Cần /usr/bin/bwrap. Cài bubblewrap (apt/yum/apk). Giới hạn RAM/CPU/pids cần systemd-run; nếu thất bại (vd. non-root), sandbox vẫn chạy nhưng không áp các giới hạn đó.", "mode": "Chế độ", "workspaceAccess": "Truy cập workspace", "image": "Hình ảnh", diff --git a/ui/web/src/i18n/locales/vi/config.json b/ui/web/src/i18n/locales/vi/config.json index 2dcab8711..9e5d968d6 100644 --- a/ui/web/src/i18n/locales/vi/config.json +++ b/ui/web/src/i18n/locales/vi/config.json @@ -108,7 +108,13 @@ "agents.pruning.keepLastAssistantsTip": "Số lượt trợ lý gần nhất luôn được giữ lại trong quá trình cắt bớt.", "agents.sandbox.title": "Hộp cát", - "agents.sandbox.desc": "Hộp cát Docker để thực thi code", + "agents.sandbox.desc": "Cô lập thực thi công cụ bằng Docker hoặc Bubblewrap (namespace trên máy chủ).", + "agents.sandbox.backend": "Backend", + "agents.sandbox.backendTip": "Docker dùng container; Bubblewrap dùng /usr/bin/bwrap trên host. Giới hạn RAM/CPU/pids cần systemd-run --scope khi được phép (thường không có trong container hoặc không delegate session).", + "agents.sandbox.bwrapNote": "Cần /usr/bin/bwrap (cài bubblewrap qua apt/yum/apk). Giới hạn tài nguyên chỉ áp dụng nếu systemd-run tạo được scope; nếu không, sandbox vẫn chạy nhưng không có cgroup giới hạn bộ nhớ/CPU.", + "agents.sandbox.bwrapSystemdLimitsTitle": "Không tạo được scope systemd-run trên máy này", + "agents.sandbox.bwrapSystemdLimitsBody": "Bộ nhớ, CPU và công tắc mạng bên dưới không được áp dụng cho Bubblewrap khi không có transient scope của systemd (thường gặp trong Docker hoặc không delegate cgroup user-session). Bubblewrap vẫn cô lập PID, IPC, UTS và có thể tách mạng qua namespace. Thời gian chờ lệnh vẫn có hiệu lực. Cài bubblewrap: apt/yum/apk; sửa scope thường cần chạy gateway trên host hoặc bật delegation.", + "agents.sandbox.bwrapFieldDisabledTip": "Không áp dụng cho Bubblewrap trên server này vì systemd-run không tạo được cgroup scope.", "agents.sandbox.mode": "Chế độ", "agents.sandbox.image": "Hình ảnh Docker", "agents.sandbox.imageTip": "Hình ảnh Docker dùng cho container hộp cát.", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index 422eb1c32..c047ec5b1 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -645,7 +645,10 @@ }, "sandbox": { "title": "沙箱", - "description": "用于代码执行隔离的 Docker 沙箱", + "description": "使用 Docker 或 Bubblewrap 隔离代码执行", + "backend": "后端", + "backendTip": "docker:容器镜像;bwrap:主机 /usr/bin/bwrap;cgroup 限额需可用的 systemd-run。", + "bwrapNote": "需要 /usr/bin/bwrap。安装 bubblewrap(apt/yum/apk)。内存/CPU/pids 上限依赖 systemd-run;若失败(如非 root),沙箱仍可运行但不会施加这些 cgroup 限制。", "mode": "模式", "workspaceAccess": "工作区访问", "image": "镜像", diff --git a/ui/web/src/i18n/locales/zh/config.json b/ui/web/src/i18n/locales/zh/config.json index 561b629b7..05abcd4d7 100644 --- a/ui/web/src/i18n/locales/zh/config.json +++ b/ui/web/src/i18n/locales/zh/config.json @@ -108,7 +108,13 @@ "agents.pruning.keepLastAssistantsTip": "裁剪期间始终保留的最近助手轮次数量。", "agents.sandbox.title": "沙箱", - "agents.sandbox.desc": "用于代码执行的 Docker 沙箱", + "agents.sandbox.desc": "使用 Docker 或 Bubblewrap(主机命名空间)隔离工具执行。", + "agents.sandbox.backend": "后端", + "agents.sandbox.backendTip": "Docker 使用容器;Bubblewrap 使用主机上的 /usr/bin/bwrap。内存/CPU/pids 限制需在允许时使用 systemd-run --scope(容器内或无权 delegate 时常不可用)。", + "agents.sandbox.bwrapNote": "需要 /usr/bin/bwrap(通过 apt/yum/apk 安装 bubblewrap)。资源上限仅在 systemd-run 能创建 scope 时生效;否则仍可使用沙箱隔离,但不会施加内存/CPU cgroup 上限。", + "agents.sandbox.bwrapSystemdLimitsTitle": "本机无法使用 systemd-run 创建 scope", + "agents.sandbox.bwrapSystemdLimitsBody": "在未使用 systemd 临时 scope 时,内存、CPU 与下方网络开关对 Bubblewrap 不会生效(常见于 Docker 内或无用户会话 cgroup 委派)。Bubblewrap 仍可通过命名空间隔离 PID、IPC、UTS,并可选择隔离网络。命令超时仍然有效。安装 bubblewrap:apt/yum/apk;修复 scope 通常需在宿主机运行网关或启用委派。", + "agents.sandbox.bwrapFieldDisabledTip": "本服务器上 Bubblewrap 无法通过 systemd-run 创建 cgroup scope,故此项不生效。", "agents.sandbox.mode": "模式", "agents.sandbox.image": "Docker 镜像", "agents.sandbox.imageTip": "沙箱容器使用的 Docker 镜像。", diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/sandbox-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/sandbox-section.tsx index 28260d45e..c9a2d8096 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/sandbox-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/sandbox-section.tsx @@ -29,6 +29,19 @@ export function SandboxSection({ enabled, value, onToggle, onChange }: SandboxSe onToggle={onToggle} >
{t(`${s}.bwrapNote`)}
+ )} + {(value.backend ?? "docker") !== "bwrap" && ( +{t("agents.sandbox.bwrapNote")}
+ )} + {(sandbox.backend ?? "docker") !== "bwrap" && ( +