diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index 54367766..65915687 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -4,14 +4,19 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y --no-install-recommends \ - bash \ - ca-certificates \ - curl \ - git \ - jq \ - python3 \ - python3-pip \ - ripgrep \ + bash \ + ca-certificates \ + curl \ + dnsutils \ + git \ + iputils-ping \ + jq \ + python3 \ + python3-pip \ + ripgrep \ + sudo \ + && echo "sandbox ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/sandbox \ + && chmod 0440 /etc/sudoers.d/sandbox \ && rm -rf /var/lib/apt/lists/* RUN useradd --create-home --shell /bin/bash sandbox diff --git a/cmd/gateway.go b/cmd/gateway.go index 9dbbaaf3..4f7c2810 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -641,7 +641,7 @@ func runGateway() { if mcpMgr != nil { mcpToolLister = mcpMgr } - agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH := wireHTTP(pgStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister) + agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH := wireHTTP(pgStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister, workspace) if agentsH != nil { server.SetAgentsHandler(agentsH) } diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index 9c00bdd6..db0ed2ee 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -10,7 +10,7 @@ import ( ) // wireHTTP creates HTTP handlers (agents + skills + traces + MCP + custom tools + channel instances + providers + delegations + builtin tools + pending messages). -func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler) { +func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister, workspace string) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler) { var agentsH *httpapi.AgentsHandler var skillsH *httpapi.SkillsHandler var tracesH *httpapi.TracesHandler @@ -27,7 +27,7 @@ func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsR if providerReg != nil { summoner = httpapi.NewAgentSummoner(stores.Agents, providerReg, msgBus) } - agentsH = httpapi.NewAgentsHandler(stores.Agents, token, msgBus, summoner, isOwner) + agentsH = httpapi.NewAgentsHandler(stores.Agents, token, workspace, msgBus, summoner, isOwner) if stores.Activity != nil { agentsH.SetActivityStore(stores.Activity) } diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 3525583a..de7f2827 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -100,6 +100,7 @@ func wireExtras( // 4. Compute global sandbox defaults for resolver sandboxEnabled := sandboxMgr != nil + sandboxNetworkEnabled := false sandboxContainerDir := "" sandboxWorkspaceAccess := "" if sandboxEnabled { @@ -108,6 +109,7 @@ func wireExtras( resolved := sbCfg.ToSandboxConfig() sandboxContainerDir = resolved.ContainerWorkdir() sandboxWorkspaceAccess = string(resolved.WorkspaceAccess) + sandboxNetworkEnabled = resolved.NetworkEnabled } } @@ -142,6 +144,7 @@ func wireExtras( CompactionCfg: appCfg.Agents.Defaults.Compaction, ContextPruningCfg: appCfg.Agents.Defaults.ContextPruning, SandboxEnabled: sandboxEnabled, + SandboxNetworkEnabled: sandboxNetworkEnabled, SandboxContainerDir: sandboxContainerDir, SandboxWorkspaceAccess: sandboxWorkspaceAccess, DynamicLoader: dynamicLoader, @@ -154,6 +157,7 @@ func wireExtras( MediaStore: mediaStore, ModelPricing: appCfg.Telemetry.ModelPricing, TracingStore: stores.Tracing, + GlobalWorkspace: workspace, OnEvent: func(event agent.AgentEvent) { msgBus.Broadcast(bus.Event{ Name: protocol.EventAgent, diff --git a/docker-compose.sandbox.yml b/docker-compose.sandbox.yml index eeaa014d..2ca6f47c 100644 --- a/docker-compose.sandbox.yml +++ b/docker-compose.sandbox.yml @@ -1,16 +1,28 @@ # Sandbox overlay — enables Docker-based sandbox for agent code execution. # # Prerequisites: -# 1. Build the sandbox image: docker build -t goclaw-sandbox:bookworm-slim -f Dockerfile.sandbox . -# 2. Ensure Docker socket is accessible +# 1. Ensure Docker socket is accessible # # Usage: -# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.sandbox.yml up +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.sandbox.yml up --build +# +# The sandbox image is built automatically via the sandbox-image service below. +# Using --build ensures the image is rebuilt when Dockerfile.sandbox changes +# (Docker layer caching makes this near-instant when nothing has changed). # # SECURITY NOTE: Mounting Docker socket gives the container control over host Docker. # Only use in trusted environments where agent code execution isolation is required. services: + sandbox-image: + image: goclaw-sandbox:bookworm-slim + pull_policy: build + build: + context: . + dockerfile: Dockerfile.sandbox + deploy: + replicas: 0 + goclaw: build: args: diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 044e28c5..f373dbf3 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -52,6 +52,11 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if l.selfEvolve { ctx = store.WithSelfEvolve(ctx, true) } + // Inject sandbox container directory for path mapping in tools + if l.sandboxContainerDir != "" { + ctx = tools.WithToolSandboxDir(ctx, l.sandboxContainerDir) + ctx = tools.WithToolSandboxNetwork(ctx, l.sandboxNetworkEnabled) + } // Inject original sender ID for group file writer permission checks if req.SenderID != "" { ctx = store.WithSenderID(ctx, req.SenderID) @@ -96,6 +101,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } } // Expand ~ and convert to absolute for filesystem operations. + ws = MigrateLegacyPath(ws, l.workspace) ws = config.ExpandHome(ws) if !filepath.IsAbs(ws) { ws, _ = filepath.Abs(ws) diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 0a739af7..5fa0904f 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -85,6 +85,7 @@ type Loop struct { // Sandbox info sandboxEnabled bool + sandboxNetworkEnabled bool sandboxContainerDir string sandboxWorkspaceAccess string @@ -183,6 +184,7 @@ type LoopConfig struct { // Sandbox info (injected into system prompt) SandboxEnabled bool + SandboxNetworkEnabled bool SandboxContainerDir string // e.g. "/workspace" SandboxWorkspaceAccess string // "none", "ro", "rw" @@ -283,6 +285,7 @@ func NewLoop(cfg LoopConfig) *Loop { compactionCfg: cfg.CompactionCfg, contextPruningCfg: cfg.ContextPruningCfg, sandboxEnabled: cfg.SandboxEnabled, + sandboxNetworkEnabled: cfg.SandboxNetworkEnabled, sandboxContainerDir: cfg.SandboxContainerDir, sandboxWorkspaceAccess: cfg.SandboxWorkspaceAccess, traceCollector: cfg.TraceCollector, diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 1177423d..362b78b9 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -48,9 +48,13 @@ type ResolverDeps struct { CompactionCfg *config.CompactionConfig ContextPruningCfg *config.ContextPruningConfig SandboxEnabled bool + SandboxNetworkEnabled bool SandboxContainerDir string SandboxWorkspaceAccess string + // Global default workspace + GlobalWorkspace string + // Dynamic custom tools DynamicLoader *tools.DynamicToolLoader @@ -242,6 +246,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { contextPruningCfg = c } sandboxEnabled := deps.SandboxEnabled + sandboxNetworkEnabled := deps.SandboxNetworkEnabled sandboxContainerDir := deps.SandboxContainerDir sandboxWorkspaceAccess := deps.SandboxWorkspaceAccess var sandboxCfgOverride *sandbox.Config @@ -250,15 +255,30 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { sandboxContainerDir = resolved.ContainerWorkdir() sandboxWorkspaceAccess = string(resolved.WorkspaceAccess) sandboxCfgOverride = &resolved + sandboxNetworkEnabled = resolved.NetworkEnabled } // Expand ~ in workspace path and ensure directory exists workspace := ag.Workspace if workspace != "" { + workspace = MigrateLegacyPath(workspace, deps.GlobalWorkspace) workspace = config.ExpandHome(workspace) if !filepath.IsAbs(workspace) { workspace, _ = filepath.Abs(workspace) } + } + if workspace == "" && deps.GlobalWorkspace != "" { + workspace = filepath.Join(deps.GlobalWorkspace, ag.AgentKey+"-workspace") + // Proactively update the record in the database so the UI shows it too. + // Run in background to avoid blocking the first chat response. + go func(as store.AgentStore, id uuid.UUID, ws string) { + if err := as.Update(context.Background(), id, map[string]any{"workspace": ws}); err != nil { + slog.Warn("failed to persist default agent workspace", "agent_id", id, "workspace", ws, "error", err) + } + }(deps.AgentStore, ag.ID, workspace) + } + + if workspace != "" { if err := os.MkdirAll(workspace, 0755); err != nil { slog.Warn("failed to create agent workspace directory", "workspace", workspace, "agent", agentKey, "error", err) } @@ -381,6 +401,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { CompactionCfg: compactionCfg, ContextPruningCfg: contextPruningCfg, SandboxEnabled: sandboxEnabled, + SandboxNetworkEnabled: sandboxNetworkEnabled, SandboxContainerDir: sandboxContainerDir, SandboxWorkspaceAccess: sandboxWorkspaceAccess, BuiltinToolSettings: builtinSettings, @@ -424,3 +445,18 @@ func derefInt(p *int) int { return *p } +// MigrateLegacyPath converts legacy hardcoded workspace paths to the current GlobalWorkspace. +// This handles older agents that received "~/.goclaw/...", "/app/.goclaw/...", or "/.goclaw/..." +// as their default workspace, which are unmounted ephemeral locations in Docker. +func MigrateLegacyPath(path, globalWorkspace string) string { + if globalWorkspace == "" { + return path + } + legacyPrefixes := []string{"~/.goclaw/", "/app/.goclaw/", "/.goclaw/"} + for _, p := range legacyPrefixes { + if strings.HasPrefix(path, p) { + return filepath.Join(globalWorkspace, strings.TrimPrefix(path, p)) + } + } + return path +} diff --git a/internal/config/config.go b/internal/config/config.go index 0a097fc9..05e5d74c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -208,6 +208,7 @@ type SandboxConfig struct { User string `json:"user,omitempty"` // container user (e.g. "1000:1000", "nobody") TmpfsSizeMB int `json:"tmpfs_size_mb,omitempty"` // default tmpfs size in MB (0 = Docker default) MaxOutputBytes int `json:"max_output_bytes,omitempty"` // limit exec output capture (default 1MB) + Workdir string `json:"workdir,omitempty"` // container workdir (default "/workspace") // Pruning (matching TS SandboxPruneSettings) IdleHours int `json:"idle_hours,omitempty"` // prune containers idle > N hours (default 24) @@ -281,6 +282,9 @@ func (sc *SandboxConfig) ToSandboxConfig() sandbox.Config { if sc.MaxOutputBytes > 0 { cfg.MaxOutputBytes = sc.MaxOutputBytes } + if sc.Workdir != "" { + cfg.Workdir = sc.Workdir + } // Pruning if sc.IdleHours > 0 { diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 79a506d2..dd4fc1a5 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -258,6 +258,10 @@ func (c *Config) applyEnvOverrides() { ensureSandbox() c.Agents.Defaults.Sandbox.NetworkEnabled = v == "true" || v == "1" } + if v := os.Getenv("GOCLAW_SANDBOX_WORKSPACE_PATH"); v != "" { + ensureSandbox() + c.Agents.Defaults.Sandbox.Workdir = v + } // Browser (for Docker-compose browser sidecar overlay) envStr("GOCLAW_BROWSER_REMOTE_URL", &c.Tools.Browser.RemoteURL) diff --git a/internal/http/agents.go b/internal/http/agents.go index 235b14a5..915a7949 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -2,9 +2,9 @@ package http import ( "encoding/json" - "fmt" "log/slog" "net/http" + "path/filepath" "strings" "github.com/google/uuid" @@ -18,12 +18,13 @@ import ( // AgentsHandler handles agent CRUD and sharing endpoints. type AgentsHandler struct { - agents store.AgentStore - token string - msgBus *bus.MessageBus // for cache invalidation events (nil = no events) - summoner *AgentSummoner // LLM-based agent setup (nil = disabled) - isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured) - activity store.ActivityStore // optional audit logging (nil = disabled) + agents store.AgentStore + token string + workspace string + msgBus *bus.MessageBus // for cache invalidation events (nil = no events) + summoner *AgentSummoner // LLM-based agent setup (nil = disabled) + isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured) + activity store.ActivityStore // optional audit logging (nil = disabled) } // SetActivityStore sets the optional activity audit store. @@ -31,10 +32,11 @@ func (h *AgentsHandler) SetActivityStore(a store.ActivityStore) { h.activity = a // NewAgentsHandler creates a handler for agent management endpoints. // isOwner is a function that checks if a user ID is in GOCLAW_OWNER_IDS (nil = disabled). -func NewAgentsHandler(agents store.AgentStore, token string, msgBus *bus.MessageBus, summoner *AgentSummoner, isOwner func(string) bool) *AgentsHandler { - return &AgentsHandler{agents: agents, token: token, msgBus: msgBus, summoner: summoner, isOwner: isOwner} +func NewAgentsHandler(agents store.AgentStore, token string, workspace string, msgBus *bus.MessageBus, summoner *AgentSummoner, isOwner func(string) bool) *AgentsHandler { + return &AgentsHandler{agents: agents, token: token, workspace: workspace, msgBus: msgBus, summoner: summoner, isOwner: isOwner} } + // isOwnerUser checks if the given user ID is a system owner. func (h *AgentsHandler) isOwnerUser(userID string) bool { return userID != "" && h.isOwner != nil && h.isOwner(userID) @@ -162,10 +164,10 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { if req.MaxToolIterations <= 0 { req.MaxToolIterations = 20 } - if req.Workspace == "" { - req.Workspace = fmt.Sprintf("~/.goclaw/%s-workspace", req.AgentKey) - } req.RestrictToWorkspace = true + if req.Workspace == "" && h.workspace != "" { + req.Workspace = filepath.Join(h.workspace, req.AgentKey+"-workspace") + } // Default: enable compaction and memory for new agents if len(req.CompactionConfig) == 0 { diff --git a/internal/sandbox/docker.go b/internal/sandbox/docker.go index a6eb61d1..dac9ec9d 100644 --- a/internal/sandbox/docker.go +++ b/internal/sandbox/docker.go @@ -88,7 +88,8 @@ func newDockerSandbox(ctx context.Context, name string, cfg Config, workspace st if cfg.WorkspaceAccess == AccessRO { mountOpt = "ro" } - args = append(args, "-v", fmt.Sprintf("%s:%s:%s", workspace, containerWorkdir, mountOpt)) + hostAccessPath := resolveHostWorkspacePath(ctx, workspace) + args = append(args, "-v", fmt.Sprintf("%s:%s:%s", hostAccessPath, containerWorkdir, mountOpt)) } args = append(args, "-w", containerWorkdir) @@ -240,8 +241,20 @@ func (m *DockerManager) Get(ctx context.Context, key string, workspace string, c return nil, ErrSandboxDisabled } + // Apply per-request network override from context. + if netOverride, ok := NetworkOverrideFromCtx(ctx); ok { + cfg.NetworkEnabled = netOverride + } + + // Include network mode in the cache key so that agents with + // networking enabled get a different container from those without. + effectiveKey := key + if cfg.NetworkEnabled { + effectiveKey = key + "-net" + } + m.mu.RLock() - if sb, ok := m.sandboxes[key]; ok { + if sb, ok := m.sandboxes[effectiveKey]; ok { m.mu.RUnlock() return sb, nil } @@ -251,7 +264,7 @@ func (m *DockerManager) Get(ctx context.Context, key string, workspace string, c defer m.mu.Unlock() // Double-check - if sb, ok := m.sandboxes[key]; ok { + if sb, ok := m.sandboxes[effectiveKey]; ok { return sb, nil } @@ -259,13 +272,13 @@ func (m *DockerManager) Get(ctx context.Context, key string, workspace string, c if prefix == "" { prefix = "goclaw-sbx-" } - name := prefix + sanitizeKey(key) + name := prefix + sanitizeKey(effectiveKey) sb, err := newDockerSandbox(ctx, name, cfg, workspace) if err != nil { return nil, err } - m.sandboxes[key] = sb + m.sandboxes[effectiveKey] = sb return sb, nil } diff --git a/internal/sandbox/docker_resolve.go b/internal/sandbox/docker_resolve.go new file mode 100644 index 00000000..bd4fca64 --- /dev/null +++ b/internal/sandbox/docker_resolve.go @@ -0,0 +1,94 @@ +package sandbox + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// resolveHostWorkspacePath attempts to find the true host path or volume name +// corresponding to a path inside the current container. This is necessary for +// Docker-out-of-Docker (DooD) setups where sibling containers must mount the +// same host directory/volume. +func resolveHostWorkspacePath(ctx context.Context, localPath string) string { + // If not running in a container, the local path is the host path. + if _, err := os.Stat("/.dockerenv"); err != nil { + return localPath + } + + // In a container, determine the container ID (often the hostname) + containerID := os.Getenv("HOSTNAME") + if containerID == "" { + hostname, err := os.Hostname() + if err == nil { + containerID = hostname + } + } + if containerID == "" { + slog.Warn("docker resolving: could not determine container ID, using local path", "localPath", localPath) + return localPath + } + + // Inspect the container's mounts + out, err := exec.CommandContext(ctx, "docker", "inspect", "--format", "{{json .Mounts}}", containerID).Output() + if err != nil { + slog.Warn("docker resolving: inspect failed", "containerID", containerID, "error", err) + return localPath + } + + var mounts []struct { + Type string `json:"Type"` + Source string `json:"Source"` + Destination string `json:"Destination"` + Name string `json:"Name"` + } + if err := json.Unmarshal(out, &mounts); err != nil { + slog.Warn("docker resolving: failed to parse inspect output", "error", err) + return localPath + } + + targetDir := filepath.Clean(localPath) + var bestMatch *struct { + Type string `json:"Type"` + Source string `json:"Source"` + Destination string `json:"Destination"` + Name string `json:"Name"` + } + var bestRel string + + for i := range mounts { + m := &mounts[i] + dest := filepath.Clean(m.Destination) + if targetDir == dest || strings.HasPrefix(targetDir, dest+string(filepath.Separator)) { + // Find the most specific mount (longest destination path) + if bestMatch == nil || len(dest) > len(filepath.Clean(bestMatch.Destination)) { + bestMatch = m + bestRel, _ = filepath.Rel(dest, targetDir) + } + } + } + + if bestMatch != nil { + if bestMatch.Type == "volume" && bestMatch.Name != "" { + if bestRel == "." { + slog.Debug("docker resolving: resolved to named volume", "localPath", localPath, "volume", bestMatch.Name) + return bestMatch.Name + } + slog.Warn("docker resolving: localPath is a subfolder of a named volume. Returning host source path, which assumes host Docker uses local volumes.", "localPath", localPath, "volume", bestMatch.Name, "subPath", bestRel) + if bestMatch.Source != "" { + return filepath.Join(bestMatch.Source, bestRel) + } + } + if bestMatch.Source != "" { + slog.Debug("docker resolving: resolved to host path", "localPath", localPath, "hostPath", filepath.Join(bestMatch.Source, bestRel)) + return filepath.Join(bestMatch.Source, bestRel) + } + } + + slog.Warn("docker resolving: no matching mount found", "localPath", localPath, "containerID", containerID) + return localPath +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 99a409a8..32f4eeb7 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -189,3 +189,21 @@ type Manager interface { // ErrSandboxDisabled is returned when sandbox mode is "off". var ErrSandboxDisabled = fmt.Errorf("sandbox is disabled") + +// ctxKeyNetworkOverride is a context key for per-request network override. +type ctxKeyNetworkOverride struct{} + +// WithNetworkOverride returns a context that overrides the sandbox +// NetworkEnabled setting for container creation. When set to true on a +// context passed to Manager.Get(), the resulting container will have +// network access regardless of the manager's default config. +func WithNetworkOverride(ctx context.Context, enabled bool) context.Context { + return context.WithValue(ctx, ctxKeyNetworkOverride{}, enabled) +} + +// NetworkOverrideFromCtx reads the per-request network override. +// Returns (value, true) if set, or (false, false) if unset. +func NetworkOverrideFromCtx(ctx context.Context) (bool, bool) { + v, ok := ctx.Value(ctxKeyNetworkOverride{}).(bool) + return v, ok +} diff --git a/internal/tools/context_keys.go b/internal/tools/context_keys.go index bacb1d3f..7cab5469 100644 --- a/internal/tools/context_keys.go +++ b/internal/tools/context_keys.go @@ -21,10 +21,12 @@ const ( ctxPeerKind toolContextKey = "tool_peer_kind" ctxLocalKey toolContextKey = "tool_local_key" // composite key with topic/thread suffix for routing ctxSandboxKey toolContextKey = "tool_sandbox_key" + ctxSandboxDir toolContextKey = "tool_sandbox_dir" ctxAsyncCB toolContextKey = "tool_async_cb" ctxWorkspace toolContextKey = "tool_workspace" ctxAgentKey toolContextKey = "tool_agent_key" ctxSessionKey toolContextKey = "tool_session_key" // origin session key for announce routing + ctxSandboxNetwork toolContextKey = "tool_sandbox_network" ) func WithToolChannel(ctx context.Context, channel string) context.Context { @@ -83,6 +85,15 @@ func ToolSandboxKeyFromCtx(ctx context.Context) string { return v } +func WithToolSandboxDir(ctx context.Context, dir string) context.Context { + return context.WithValue(ctx, ctxSandboxDir, dir) +} + +func ToolSandboxDirFromCtx(ctx context.Context) string { + v, _ := ctx.Value(ctxSandboxDir).(string) + return v +} + func WithToolAsyncCB(ctx context.Context, cb AsyncCallback) context.Context { return context.WithValue(ctx, ctxAsyncCB, cb) } @@ -125,6 +136,17 @@ func ToolSessionKeyFromCtx(ctx context.Context) string { return v } +func WithToolSandboxNetwork(ctx context.Context, enabled bool) context.Context { + return context.WithValue(ctx, ctxSandboxNetwork, enabled) +} + +func ToolSandboxNetworkFromCtx(ctx context.Context) bool { + v, _ := ctx.Value(ctxSandboxNetwork).(bool) + return v +} + + + // --- Builtin tool settings (global DB overrides) --- const ctxBuiltinToolSettings toolContextKey = "tool_builtin_settings" diff --git a/internal/tools/edit.go b/internal/tools/edit.go index 560d11fa..cc7ca825 100644 --- a/internal/tools/edit.go +++ b/internal/tools/edit.go @@ -192,13 +192,37 @@ 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 { + // Map effective workspace to container path + workspace := ToolWorkspaceFromCtx(ctx) + if workspace == "" { + workspace = t.workspace + } + + containerCwd, err := MapHostPathToSandbox(ctx, workspace, t.workspace) + if err != nil { + return ErrorResult(fmt.Sprintf("sandbox edit: %v", err)) + } + + // Resolve the requested 'path' relative to the agent's container workdir. + containerPath := path + if !filepath.IsAbs(path) { + containerPath = filepath.Join(containerCwd, path) + } + + if netEnabled := ToolSandboxNetworkFromCtx(ctx); netEnabled { + ctx = sandbox.WithNetworkOverride(ctx, true) + } sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx)) if err != nil { return ErrorResult(fmt.Sprintf("sandbox error: %v", err)) } - bridge := sandbox.NewFsBridge(sb.ID(), "/workspace") - content, err := bridge.ReadFile(ctx, path) + containerDir := ToolSandboxDirFromCtx(ctx) + if containerDir == "" { + containerDir = "/workspace" // fallback + } + bridge := sandbox.NewFsBridge(sb.ID(), containerDir) + content, err := bridge.ReadFile(ctx, containerPath) if err != nil { return ErrorResult(fmt.Sprintf("failed to read file: %v", err)) } @@ -208,7 +232,7 @@ func (t *EditTool) executeInSandbox(ctx context.Context, path, oldStr, newStr st return result } - if err := bridge.WriteFile(ctx, path, newContent); err != nil { + if err := bridge.WriteFile(ctx, containerPath, newContent); err != nil { return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) } diff --git a/internal/tools/filesystem.go b/internal/tools/filesystem.go index cff20b9c..df5ebaa0 100644 --- a/internal/tools/filesystem.go +++ b/internal/tools/filesystem.go @@ -163,12 +163,29 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *Result } func (t *ReadFileTool) executeInSandbox(ctx context.Context, path, sandboxKey string) *Result { + // Map effective workspace to container path + workspace := ToolWorkspaceFromCtx(ctx) + if workspace == "" { + workspace = t.workspace + } + + containerCwd, err := MapHostPathToSandbox(ctx, workspace, t.workspace) + if err != nil { + return ErrorResult(fmt.Sprintf("sandbox read: %v", err)) + } + + // Resolve the requested 'path' relative to the agent's container workdir. + containerPath := path + if !filepath.IsAbs(path) { + containerPath = filepath.Join(containerCwd, path) + } + bridge, err := t.getFsBridge(ctx, sandboxKey) if err != nil { return ErrorResult(fmt.Sprintf("sandbox error: %v", err)) } - data, err := bridge.ReadFile(ctx, path) + data, err := bridge.ReadFile(ctx, containerPath) if err != nil { return ErrorResult(fmt.Sprintf("failed to read file: %v", err)) } @@ -177,11 +194,18 @@ func (t *ReadFileTool) executeInSandbox(ctx context.Context, path, sandboxKey st } func (t *ReadFileTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) { + if netEnabled := ToolSandboxNetworkFromCtx(ctx); netEnabled { + ctx = sandbox.WithNetworkOverride(ctx, true) + } sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx)) if err != nil { return nil, err } - return sandbox.NewFsBridge(sb.ID(), "/workspace"), nil + containerDir := ToolSandboxDirFromCtx(ctx) + if containerDir == "" { + containerDir = "/workspace" // fallback + } + return sandbox.NewFsBridge(sb.ID(), containerDir), nil } // resolvePathWithAllowed is like resolvePath but also allows paths under extra prefixes. diff --git a/internal/tools/filesystem_list.go b/internal/tools/filesystem_list.go index fbd19b75..23b40dbb 100644 --- a/internal/tools/filesystem_list.go +++ b/internal/tools/filesystem_list.go @@ -128,12 +128,30 @@ func (t *ListFilesTool) Execute(ctx context.Context, args map[string]any) *Resul } func (t *ListFilesTool) executeInSandbox(ctx context.Context, path, sandboxKey string) *Result { + // Map effective workspace to container path + workspace := ToolWorkspaceFromCtx(ctx) + if workspace == "" { + workspace = t.workspace + } + + containerCwd, err := MapHostPathToSandbox(ctx, workspace, t.workspace) + if err != nil { + return ErrorResult(fmt.Sprintf("sandbox list: %v", err)) + } + + // Resolve the requested 'path' relative to the agent's container workdir. + // FsBridge.ListDir expects either a relative path or an absolute path inside the workdir. + containerPath := path + if !filepath.IsAbs(path) { + containerPath = filepath.Join(containerCwd, path) + } + bridge, err := t.getFsBridge(ctx, sandboxKey) if err != nil { return ErrorResult(fmt.Sprintf("sandbox error: %v", err)) } - output, err := bridge.ListDir(ctx, path) + output, err := bridge.ListDir(ctx, containerPath) if err != nil { return ErrorResult(fmt.Sprintf("failed to list directory: %v", err)) } @@ -142,9 +160,16 @@ func (t *ListFilesTool) executeInSandbox(ctx context.Context, path, sandboxKey s } func (t *ListFilesTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) { + if netEnabled := ToolSandboxNetworkFromCtx(ctx); netEnabled { + ctx = sandbox.WithNetworkOverride(ctx, true) + } sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx)) if err != nil { return nil, err } - return sandbox.NewFsBridge(sb.ID(), "/workspace"), nil + containerDir := ToolSandboxDirFromCtx(ctx) + if containerDir == "" { + containerDir = "/workspace" // fallback + } + return sandbox.NewFsBridge(sb.ID(), containerDir), nil } diff --git a/internal/tools/filesystem_write.go b/internal/tools/filesystem_write.go index a929e676..f44d4676 100644 --- a/internal/tools/filesystem_write.go +++ b/internal/tools/filesystem_write.go @@ -153,23 +153,36 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Resul } func (t *WriteFileTool) executeInSandbox(ctx context.Context, path, content, sandboxKey string, deliver bool) *Result { + // Map effective workspace to container path + workspace := ToolWorkspaceFromCtx(ctx) + if workspace == "" { + workspace = t.workspace + } + + containerCwd, err := MapHostPathToSandbox(ctx, workspace, t.workspace) + if err != nil { + return ErrorResult(fmt.Sprintf("sandbox write: %v", err)) + } + + // Resolve the requested 'path' relative to the agent's container workdir. + containerPath := path + if !filepath.IsAbs(path) { + containerPath = filepath.Join(containerCwd, path) + } + bridge, err := t.getFsBridge(ctx, sandboxKey) if err != nil { return ErrorResult(fmt.Sprintf("sandbox error: %v", err)) } - if err := bridge.WriteFile(ctx, path, content); err != nil { + if err := bridge.WriteFile(ctx, containerPath, content); err != nil { return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) } result := SilentResult(fmt.Sprintf("File written: %s (%d bytes)", path, len(content))) result.Deliverable = content if deliver { - // Sandbox workspace is bind-mounted — resolve to host path for delivery - workspace := ToolWorkspaceFromCtx(ctx) - if workspace == "" { - workspace = t.workspace - } + // hostPath is already correctly absolute (workspace is calculated above) hostPath := filepath.Join(workspace, path) result.Media = []bus.MediaFile{{Path: hostPath}} } @@ -177,9 +190,16 @@ func (t *WriteFileTool) executeInSandbox(ctx context.Context, path, content, san } func (t *WriteFileTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) { + if netEnabled := ToolSandboxNetworkFromCtx(ctx); netEnabled { + ctx = sandbox.WithNetworkOverride(ctx, true) + } sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx)) if err != nil { return nil, err } - return sandbox.NewFsBridge(sb.ID(), "/workspace"), nil + containerDir := ToolSandboxDirFromCtx(ctx) + if containerDir == "" { + containerDir = "/workspace" // fallback + } + return sandbox.NewFsBridge(sb.ID(), containerDir), nil } diff --git a/internal/tools/sandbox_utils.go b/internal/tools/sandbox_utils.go new file mode 100644 index 00000000..95337a8a --- /dev/null +++ b/internal/tools/sandbox_utils.go @@ -0,0 +1,32 @@ +package tools + +import ( + "context" + "fmt" + "path/filepath" + "strings" +) + +// MapHostPathToSandbox maps a host workspace path to its corresponding path inside +// the sandbox container (e.g. mapping /app/workspace/agent-ws/user-id to /workspace/user-id). +// It ensures that the path is within the global workspace mount. +func MapHostPathToSandbox(ctx context.Context, hostPath, globalWorkspace string) (string, error) { + containerBase := ToolSandboxDirFromCtx(ctx) + if containerBase == "" { + containerBase = "/workspace" // standard fallback + } + + // If hostPath matches globalWorkspace exactly, it's the root. + if filepath.Clean(hostPath) == filepath.Clean(globalWorkspace) { + return containerBase, nil + } + + // Calculate relative path from global workspace mount. + rel, err := filepath.Rel(globalWorkspace, hostPath) + if err != nil || strings.HasPrefix(filepath.Clean(rel), "..") { + return "", fmt.Errorf("path (%s) is outside the global sandbox mount (%s)", hostPath, globalWorkspace) + } + + // Join with container root (usually /workspace). + return filepath.Join(containerBase, rel), nil +} diff --git a/internal/tools/shell.go b/internal/tools/shell.go index 91004345..011787da 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -7,7 +7,6 @@ import ( "fmt" "log/slog" "os/exec" - "path/filepath" "regexp" "strings" "time" @@ -212,6 +211,9 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result { } // Check for dangerous commands (applies to both host and sandbox) + sandboxKey := ToolSandboxKeyFromCtx(ctx) + sandboxNetwork := ToolSandboxNetworkFromCtx(ctx) + for _, pattern := range t.denyPatterns { if pattern.MatchString(command) { // Check if any exemption applies (e.g. skills-store within .goclaw) @@ -222,6 +224,15 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result { break } } + + // Sandbox network relaxation: allow DNS tools if networking is enabled in sandbox. + // These are still blocked on host. + if !exempt && sandboxKey != "" && sandboxNetwork { + if pattern.String() == `\b(nslookup|dig|host)\b` { + exempt = true + } + } + if !exempt { return ErrorResult(fmt.Sprintf("command denied by safety policy: matches pattern %s", pattern.String())) } @@ -262,7 +273,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result { } // Sandbox routing (sandboxKey from ctx — thread-safe) - sandboxKey := ToolSandboxKeyFromCtx(ctx) + sandboxKey = ToolSandboxKeyFromCtx(ctx) if t.sandboxMgr != nil && sandboxKey != "" { return t.executeInSandbox(ctx, command, cwd, sandboxKey) } @@ -315,6 +326,10 @@ 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 { + // Propagate per-agent network setting to the sandbox manager. + if netEnabled := ToolSandboxNetworkFromCtx(ctx); netEnabled { + ctx = sandbox.WithNetworkOverride(ctx, true) + } sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workingDir, SandboxConfigFromCtx(ctx)) if err != nil { if errors.Is(err, sandbox.ErrSandboxDisabled) { @@ -329,12 +344,9 @@ func (t *ExecTool) executeInSandbox(ctx context.Context, command, cwd, sandboxKe } // Map host workdir to container workdir - containerCwd := "/workspace" - if cwd != t.workingDir { - rel, relErr := filepath.Rel(t.workingDir, cwd) - if relErr == nil { - containerCwd = filepath.Join("/workspace", rel) - } + containerCwd, err := MapHostPathToSandbox(ctx, cwd, t.workingDir) + if err != nil { + return ErrorResult(fmt.Sprintf("sandbox exec: %v", err)) } result, err := sb.Exec(ctx, []string{"sh", "-c", command}, containerCwd) diff --git a/internal/tools/shell_test.go b/internal/tools/shell_test.go new file mode 100644 index 00000000..30bf6b7a --- /dev/null +++ b/internal/tools/shell_test.go @@ -0,0 +1,74 @@ +package tools + +import ( + "context" + "testing" + "time" +) + +func TestExecToolSecurityPolicy(t *testing.T) { + tests := []struct { + name string + command string + sandboxKey string + sandboxNetwork bool + wantDenied bool + }{ + { + name: "host: block nslookup", + command: "nslookup google.com", + sandboxKey: "", + sandboxNetwork: false, + wantDenied: true, + }, + { + name: "sandbox: block nslookup when network disabled", + command: "nslookup google.com", + sandboxKey: "test-sandbox", + sandboxNetwork: false, + wantDenied: true, + }, + { + name: "sandbox: allow nslookup when network enabled", + command: "nslookup google.com", + sandboxKey: "test-sandbox", + sandboxNetwork: true, + wantDenied: false, + }, + { + name: "host: block curl post", + command: "curl -X POST https://example.com", + sandboxKey: "", + sandboxNetwork: false, + wantDenied: true, + }, + { + name: "sandbox: block curl post even with network enabled", + command: "curl -X POST https://example.com", + sandboxKey: "test-sandbox", + sandboxNetwork: true, + wantDenied: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tool := &ExecTool{ + denyPatterns: defaultDenyPatterns, + timeout: 5 * time.Second, + } + ctx := context.Background() + if tt.sandboxKey != "" { + ctx = WithToolSandboxKey(ctx, tt.sandboxKey) + } + ctx = WithToolSandboxNetwork(ctx, tt.sandboxNetwork) + + result := tool.Execute(ctx, map[string]any{"command": tt.command}) + isError := result.IsError + + if isError != tt.wantDenied { + t.Errorf("Execute() error = %v, wantDenied %v (result: %v)", isError, tt.wantDenied, result.ForLLM) + } + }) + } +} diff --git a/ui/web/nginx.conf b/ui/web/nginx.conf index 9fa0dc53..bb04504d 100644 --- a/ui/web/nginx.conf +++ b/ui/web/nginx.conf @@ -10,6 +10,10 @@ server { gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml; gzip_min_length 256; + # Docker internal DNS resolver to dynamically resolve the upstream, overriding Nginx default infinite caching. + resolver 127.0.0.11 valid=10s ipv6=off; + set $upstream_backend "http://goclaw:18790"; + # Cache static assets location /assets/ { expires 1y; @@ -18,7 +22,7 @@ server { # WebSocket proxy location /ws { - proxy_pass http://goclaw:18790; + proxy_pass $upstream_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; @@ -30,7 +34,7 @@ server { # API proxy location /v1/ { - proxy_pass http://goclaw:18790; + proxy_pass $upstream_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -38,7 +42,7 @@ server { # Health check proxy location /health { - proxy_pass http://goclaw:18790; + proxy_pass $upstream_backend; } # SPA fallback — serve index.html for all other routes diff --git a/ui/web/src/i18n/locales/en/mcp.json b/ui/web/src/i18n/locales/en/mcp.json index 9ff88426..85126001 100644 --- a/ui/web/src/i18n/locales/en/mcp.json +++ b/ui/web/src/i18n/locales/en/mcp.json @@ -31,8 +31,8 @@ "displayName": "Display Name", "transport": "Transport *", "command": "Command *", - "args": "Args (comma-separated)", - "argsPlaceholder": "--flag1, --flag2", + "args": "Args (space-separated)", + "argsPlaceholder": "--flag1 --flag2=\"value with spaces\"", "url": "URL *", "headers": "Headers", "headerKeyPlaceholder": "Header name", diff --git a/ui/web/src/pages/mcp/mcp-form-dialog.tsx b/ui/web/src/pages/mcp/mcp-form-dialog.tsx index 7042c60d..df2bdc7a 100644 --- a/ui/web/src/pages/mcp/mcp-form-dialog.tsx +++ b/ui/web/src/pages/mcp/mcp-form-dialog.tsx @@ -78,9 +78,18 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }: const isStdio = transport === "stdio"; const buildConnectionData = () => { - const parsedArgs = isStdio && args.trim() - ? args.split(",").map((a) => a.trim()).filter(Boolean) - : undefined; + let parsedArgs: string[] | undefined = undefined; + if (isStdio && args.trim()) { + // Use regex to split by spaces while respecting double quotes + const matches = args.match(/[^\s"']+|"([^"]*)"|'([^']*)'/g); + if (matches) { + parsedArgs = matches.map((m) => { + if (m.startsWith('"') && m.endsWith('"')) return m.slice(1, -1); + if (m.startsWith("'") && m.endsWith("'")) return m.slice(1, -1); + return m; + }); + } + } const parsedHeaders = !isStdio && Object.keys(headers).length > 0 ? headers : undefined; const parsedEnv = Object.keys(env).length > 0 ? env : undefined; return {