From 1088ffceae7ab439a6b21babe1b2b04ebba10da9 Mon Sep 17 00:00:00 2001 From: viettranx Date: Thu, 26 Feb 2026 16:45:45 +0700 Subject: [PATCH] feat: Introduce Docker-based sandboxing with container lifecycle management, execution, pruning, and core sandbox interfaces. --- .gitignore | 1 - internal/sandbox/docker.go | 451 ++++++++++++++++++++++++++++++++ internal/sandbox/docker_test.go | 128 +++++++++ internal/sandbox/fsbridge.go | 149 +++++++++++ internal/sandbox/sandbox.go | 190 ++++++++++++++ 5 files changed, 918 insertions(+), 1 deletion(-) create mode 100644 internal/sandbox/docker.go create mode 100644 internal/sandbox/docker_test.go create mode 100644 internal/sandbox/fsbridge.go create mode 100644 internal/sandbox/sandbox.go diff --git a/.gitignore b/.gitignore index 5a08b96f..60284fb8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,6 @@ openclaw-go app browser-poc -sandbox openclaw-go goclaw config.json diff --git a/internal/sandbox/docker.go b/internal/sandbox/docker.go new file mode 100644 index 00000000..579a4092 --- /dev/null +++ b/internal/sandbox/docker.go @@ -0,0 +1,451 @@ +package sandbox + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "os/exec" + "strings" + "sync" + "time" +) + +// CheckDockerAvailable verifies that the Docker CLI and daemon are accessible. +// Returns nil if Docker is ready, or an error describing the failure. +func CheckDockerAvailable(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{.ServerVersion}}").CombinedOutput() + if err != nil { + return fmt.Errorf("docker not available: %w (output: %s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +// DockerSandbox is a sandbox backed by a Docker container. +type DockerSandbox struct { + containerID string + config Config + workspace string + createdAt time.Time + lastUsed time.Time + mu sync.Mutex // protects lastUsed +} + +// newDockerSandbox creates and starts a Docker container for sandboxed execution. +// Matching TS buildSandboxCreateArgs() + createSandboxContainer(). +func newDockerSandbox(ctx context.Context, name string, cfg Config, workspace string) (*DockerSandbox, error) { + args := []string{ + "run", "-d", + "--name", name, + "--label", "openclaw.sandbox=true", + } + + // Security hardening (matching TS buildSandboxCreateArgs) + if cfg.ReadOnlyRoot { + args = append(args, "--read-only") + } + for _, t := range cfg.Tmpfs { + // Append default size if not already specified and TmpfsSizeMB > 0 + if cfg.TmpfsSizeMB > 0 && !strings.Contains(t, ":") { + t = fmt.Sprintf("%s:size=%dm", t, cfg.TmpfsSizeMB) + } + args = append(args, "--tmpfs", t) + } + for _, cap := range cfg.CapDrop { + args = append(args, "--cap-drop", cap) + } + args = append(args, "--security-opt", "no-new-privileges") + + // Non-root user (reduces attack surface) + if cfg.User != "" { + args = append(args, "--user", cfg.User) + } + + // Resource limits + if cfg.MemoryMB > 0 { + args = append(args, "--memory", fmt.Sprintf("%dm", cfg.MemoryMB)) + } + if cfg.CPUs > 0 { + args = append(args, "--cpus", fmt.Sprintf("%.1f", cfg.CPUs)) + } + if cfg.PidsLimit > 0 { + args = append(args, "--pids-limit", fmt.Sprintf("%d", cfg.PidsLimit)) + } + + // Network + if !cfg.NetworkEnabled { + args = append(args, "--network", "none") + } + + // Workspace mount + containerWorkdir := cfg.ContainerWorkdir() + if workspace != "" && cfg.WorkspaceAccess != AccessNone { + mountOpt := "rw" + if cfg.WorkspaceAccess == AccessRO { + mountOpt = "ro" + } + args = append(args, "-v", fmt.Sprintf("%s:%s:%s", workspace, containerWorkdir, mountOpt)) + } + args = append(args, "-w", containerWorkdir) + + // Environment variables + for k, v := range cfg.Env { + args = append(args, "-e", k+"="+v) + } + + // Image + keep-alive command + args = append(args, cfg.Image, "sleep", "infinity") + + slog.Debug("creating sandbox container", "name", name, "args", args) + + cmd := exec.CommandContext(ctx, "docker", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("docker run failed: %w\nstderr: %s", err, stderr.String()) + } + + containerID := strings.TrimSpace(stdout.String()) + if len(containerID) > 12 { + containerID = containerID[:12] + } + + slog.Info("sandbox container created", "id", containerID, "name", name, "image", cfg.Image) + + // Run optional setup command (matching TS setupCommand) + if cfg.SetupCommand != "" { + setupCmd := exec.CommandContext(ctx, "docker", "exec", "-i", containerID, "sh", "-lc", cfg.SetupCommand) + if out, err := setupCmd.CombinedOutput(); err != nil { + slog.Warn("sandbox setup command failed", "id", containerID, "error", err, "output", string(out)) + } else { + slog.Info("sandbox setup command completed", "id", containerID) + } + } + + now := time.Now() + return &DockerSandbox{ + containerID: containerID, + config: cfg, + workspace: workspace, + createdAt: now, + lastUsed: now, + }, nil +} + +// Exec runs a command inside the container. +func (s *DockerSandbox) Exec(ctx context.Context, command []string, workDir string) (*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() + + args := []string{"exec"} + if workDir != "" { + args = append(args, "-w", workDir) + } + args = append(args, s.containerID) + args = append(args, command...) + + cmd := exec.CommandContext(execCtx, "docker", args...) + + // Limit output capture to prevent OOM from large command output + maxOut := s.config.MaxOutputBytes + if maxOut <= 0 { + maxOut = 1 << 20 // 1MB default + } + stdout := &limitedBuffer{max: maxOut} + stderr := &limitedBuffer{max: maxOut} + cmd.Stdout = stdout + cmd.Stderr = stderr + + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return nil, fmt.Errorf("docker exec: %w", err) + } + } + + result := &ExecResult{ + ExitCode: exitCode, + Stdout: stdout.String(), + Stderr: stderr.String(), + } + if stdout.truncated { + result.Stdout += "\n...[output truncated]" + } + if stderr.truncated { + result.Stderr += "\n...[output truncated]" + } + return result, nil +} + +// Destroy removes the container. +func (s *DockerSandbox) Destroy(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "docker", "rm", "-f", s.containerID) + if err := cmd.Run(); err != nil { + slog.Warn("failed to remove sandbox container", "id", s.containerID, "error", err) + return err + } + slog.Info("sandbox container destroyed", "id", s.containerID) + return nil +} + +// ID returns the container ID. +func (s *DockerSandbox) ID() string { return s.containerID } + +// DockerManager manages Docker sandbox containers based on scope. +type DockerManager struct { + config Config + sandboxes map[string]*DockerSandbox + mu sync.RWMutex + stopCh chan struct{} // signals pruning goroutine to stop +} + +// NewDockerManager creates a manager for Docker sandboxes. +// Automatically starts background pruning if configured. +func NewDockerManager(cfg Config) *DockerManager { + m := &DockerManager{ + config: cfg, + sandboxes: make(map[string]*DockerSandbox), + stopCh: make(chan struct{}), + } + m.startPruning() + return m +} + +// Get returns an existing sandbox or creates a new one for the given key. +func (m *DockerManager) Get(ctx context.Context, key string, workspace string) (Sandbox, error) { + if m.config.Mode == ModeOff { + return nil, ErrSandboxDisabled + } + + m.mu.RLock() + if sb, ok := m.sandboxes[key]; ok { + m.mu.RUnlock() + return sb, nil + } + m.mu.RUnlock() + + m.mu.Lock() + defer m.mu.Unlock() + + // Double-check + if sb, ok := m.sandboxes[key]; ok { + return sb, nil + } + + prefix := m.config.ContainerPrefix + if prefix == "" { + prefix = "openclaw-sbx-" + } + name := prefix + sanitizeKey(key) + sb, err := newDockerSandbox(ctx, name, m.config, workspace) + if err != nil { + return nil, err + } + + m.sandboxes[key] = sb + return sb, nil +} + +// Release destroys a sandbox by key. +func (m *DockerManager) Release(ctx context.Context, key string) error { + m.mu.Lock() + sb, ok := m.sandboxes[key] + if ok { + delete(m.sandboxes, key) + } + m.mu.Unlock() + + if ok { + return sb.Destroy(ctx) + } + return nil +} + +// ReleaseAll destroys all active sandboxes. +func (m *DockerManager) ReleaseAll(ctx context.Context) error { + m.mu.Lock() + sbs := make(map[string]*DockerSandbox, len(m.sandboxes)) + for k, v := range m.sandboxes { + sbs[k] = v + } + m.sandboxes = make(map[string]*DockerSandbox) + m.mu.Unlock() + + for key, sb := range sbs { + if err := sb.Destroy(ctx); err != nil { + slog.Warn("failed to release sandbox", "key", key, "error", err) + } + } + return nil +} + +// Stats returns information about active sandboxes. +func (m *DockerManager) Stats() map[string]interface{} { + m.mu.RLock() + defer m.mu.RUnlock() + + containers := make(map[string]string, len(m.sandboxes)) + for key, sb := range m.sandboxes { + containers[key] = sb.containerID + } + + return map[string]interface{}{ + "mode": m.config.Mode, + "image": m.config.Image, + "active": len(m.sandboxes), + "containers": containers, + } +} + +// Stop signals the pruning goroutine to stop. +// Called during shutdown before ReleaseAll. +func (m *DockerManager) Stop() { + select { + case <-m.stopCh: + // already closed + default: + close(m.stopCh) + } +} + +// startPruning launches a background goroutine that periodically prunes idle/old containers. +// Matching TS maybePruneSandboxes(). +func (m *DockerManager) startPruning() { + interval := time.Duration(m.config.PruneIntervalMin) * time.Minute + if interval <= 0 { + interval = 5 * time.Minute + } + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-m.stopCh: + return + case <-ticker.C: + m.Prune(context.Background()) + } + } + }() + + slog.Debug("sandbox pruning started", "interval", interval) +} + +// Prune removes containers that are idle too long or exceed max age. +// Matching TS SandboxPruneSettings (idleHours, maxAgeDays). +func (m *DockerManager) Prune(ctx context.Context) { + idleHours := m.config.IdleHours + if idleHours <= 0 { + idleHours = 24 + } + maxAgeDays := m.config.MaxAgeDays + if maxAgeDays <= 0 { + maxAgeDays = 7 + } + + now := time.Now() + idleThreshold := now.Add(-time.Duration(idleHours) * time.Hour) + ageThreshold := now.Add(-time.Duration(maxAgeDays) * 24 * time.Hour) + + // Collect keys to prune + m.mu.RLock() + var toRemove []string + for key, sb := range m.sandboxes { + sb.mu.Lock() + lastUsed := sb.lastUsed + created := sb.createdAt + sb.mu.Unlock() + + if lastUsed.Before(idleThreshold) || created.Before(ageThreshold) { + toRemove = append(toRemove, key) + } + } + m.mu.RUnlock() + + if len(toRemove) == 0 { + return + } + + // Remove them + for _, key := range toRemove { + m.mu.Lock() + sb, ok := m.sandboxes[key] + if ok { + delete(m.sandboxes, key) + } + m.mu.Unlock() + + if ok { + if err := sb.Destroy(ctx); err != nil { + slog.Warn("prune: failed to destroy sandbox", "key", key, "error", err) + } else { + slog.Info("pruned idle sandbox container", "key", key, "container", sb.containerID) + } + } + } + + slog.Info("sandbox prune completed", "removed", len(toRemove)) +} + +// sanitizeKey makes a key safe for Docker container names. +func sanitizeKey(key string) string { + safe := strings.NewReplacer( + ":", "-", + "/", "-", + " ", "-", + ".", "-", + ).Replace(key) + + if len(safe) > 50 { + safe = safe[:50] + } + return safe +} + +// limitedBuffer is a bytes.Buffer that stops accepting writes after max bytes. +// Prevents OOM when commands produce large output. +type limitedBuffer struct { + buf bytes.Buffer + max int + truncated bool +} + +func (lb *limitedBuffer) Write(p []byte) (int, error) { + if lb.truncated { + return len(p), nil // discard silently + } + remaining := lb.max - lb.buf.Len() + if remaining <= 0 { + lb.truncated = true + return len(p), nil + } + if len(p) > remaining { + lb.buf.Write(p[:remaining]) + lb.truncated = true + return len(p), nil + } + return lb.buf.Write(p) +} + +func (lb *limitedBuffer) String() string { + return lb.buf.String() +} diff --git a/internal/sandbox/docker_test.go b/internal/sandbox/docker_test.go new file mode 100644 index 00000000..c402e22b --- /dev/null +++ b/internal/sandbox/docker_test.go @@ -0,0 +1,128 @@ +package sandbox + +import ( + "strings" + "testing" +) + +func TestLimitedBuffer_UnderLimit(t *testing.T) { + lb := &limitedBuffer{max: 100} + n, err := lb.Write([]byte("hello")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != 5 { + t.Errorf("expected 5, got %d", n) + } + if lb.String() != "hello" { + t.Errorf("expected 'hello', got %q", lb.String()) + } + if lb.truncated { + t.Error("should not be truncated") + } +} + +func TestLimitedBuffer_AtLimit(t *testing.T) { + lb := &limitedBuffer{max: 5} + lb.Write([]byte("hello")) + if lb.truncated { + t.Error("exactly at limit should not be truncated") + } + if lb.String() != "hello" { + t.Errorf("expected 'hello', got %q", lb.String()) + } +} + +func TestLimitedBuffer_OverLimit(t *testing.T) { + lb := &limitedBuffer{max: 5} + n, err := lb.Write([]byte("hello world")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should report all bytes as "written" (consumed) even though truncated + if n != 11 { + t.Errorf("expected 11 (full input consumed), got %d", n) + } + if lb.String() != "hello" { + t.Errorf("expected 'hello', got %q", lb.String()) + } + if !lb.truncated { + t.Error("should be truncated") + } +} + +func TestLimitedBuffer_MultipleWrites(t *testing.T) { + lb := &limitedBuffer{max: 10} + lb.Write([]byte("aaaa")) + lb.Write([]byte("bbbb")) + lb.Write([]byte("cccc")) // should be partially truncated + + if lb.buf.Len() != 10 { + t.Errorf("expected 10 bytes, got %d", lb.buf.Len()) + } + if !lb.truncated { + t.Error("should be truncated after exceeding max") + } + if lb.String() != "aaaabbbbcc" { + t.Errorf("expected 'aaaabbbbcc', got %q", lb.String()) + } +} + +func TestLimitedBuffer_DiscardAfterTruncation(t *testing.T) { + lb := &limitedBuffer{max: 3} + lb.Write([]byte("abc")) + lb.Write([]byte("def")) // should be silently discarded + + if lb.String() != "abc" { + t.Errorf("expected 'abc', got %q", lb.String()) + } + if !lb.truncated { + t.Error("should be truncated") + } +} + +func TestDefaultConfig_MaxOutputBytes(t *testing.T) { + cfg := DefaultConfig() + if cfg.MaxOutputBytes != 1<<20 { + t.Errorf("expected 1MB default, got %d", cfg.MaxOutputBytes) + } +} + +func TestSanitizeKey(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"agent:main:telegram:direct:123", "agent-main-telegram-direct-123"}, + {"simple", "simple"}, + {"has/slash", "has-slash"}, + {"has space", "has-space"}, + {strings.Repeat("x", 100), strings.Repeat("x", 50)}, + } + for _, tc := range tests { + got := sanitizeKey(tc.input) + if got != tc.expected { + t.Errorf("sanitizeKey(%q) = %q, want %q", tc.input, got, tc.expected) + } + } +} + +func TestResolveScopeKey(t *testing.T) { + tests := []struct { + scope Scope + key string + expected string + }{ + {ScopeShared, "agent:main:telegram:direct:123", "shared"}, + {ScopeAgent, "agent:main:telegram:direct:123", "agent:main"}, + {ScopeSession, "agent:main:telegram:direct:123", "agent:main:telegram:direct:123"}, + {ScopeSession, "", "default"}, + } + for _, tc := range tests { + cfg := Config{Scope: tc.scope} + got := cfg.ResolveScopeKey(tc.key) + if got != tc.expected { + t.Errorf("scope=%s key=%q → %q, want %q", tc.scope, tc.key, got, tc.expected) + } + } +} diff --git a/internal/sandbox/fsbridge.go b/internal/sandbox/fsbridge.go new file mode 100644 index 00000000..59f6e59d --- /dev/null +++ b/internal/sandbox/fsbridge.go @@ -0,0 +1,149 @@ +// Package sandbox — fsbridge.go provides sandboxed file operations via Docker 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" + "strings" +) + +// FsBridge provides sandboxed file operations via Docker exec. +// Matching TS SandboxFsBridge in fs-bridge.ts. +type FsBridge struct { + containerID string + workdir string // container-side working directory (e.g. "/workspace") +} + +// NewFsBridge creates a bridge to a running sandbox container. +func NewFsBridge(containerID, workdir string) *FsBridge { + if workdir == "" { + workdir = "/workspace" + } + return &FsBridge{ + containerID: containerID, + workdir: workdir, + } +} + +// ReadFile reads file contents from inside the container. +// Matching TS FsBridge.readFile(). +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) + if err != nil { + return "", fmt.Errorf("fsbridge read: %w", err) + } + if exitCode != 0 { + return "", fmt.Errorf("read failed: %s", strings.TrimSpace(stderr)) + } + + return stdout, nil +} + +// WriteFile writes content to a file inside the container, creating directories as needed. +// Matching TS FsBridge.writeFile(). +func (b *FsBridge) WriteFile(ctx context.Context, path, content string) error { + resolved := b.resolvePath(path) + + // Create parent directory + dir := resolved[:strings.LastIndex(resolved, "/")] + if dir != "" && dir != "/" { + _, _, _, _ = b.dockerExec(ctx, nil, "mkdir", "-p", dir) + } + + // Write content via stdin pipe + _, stderr, exitCode, err := b.dockerExec(ctx, []byte(content), "sh", "-c", fmt.Sprintf("cat > %q", resolved)) + if err != nil { + return fmt.Errorf("fsbridge write: %w", err) + } + if exitCode != 0 { + return fmt.Errorf("write failed: %s", strings.TrimSpace(stderr)) + } + + return nil +} + +// ListDir lists files and directories inside the container. +// Matching TS FsBridge.readdir(). +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) + if err != nil { + return "", fmt.Errorf("fsbridge list: %w", err) + } + if exitCode != 0 { + return "", fmt.Errorf("list failed: %s", strings.TrimSpace(stderr)) + } + + return 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) + if err != nil { + return "", fmt.Errorf("fsbridge stat: %w", err) + } + if exitCode != 0 { + return "", fmt.Errorf("stat failed: %s", strings.TrimSpace(stderr)) + } + + return stdout, nil +} + +// resolvePath resolves a path relative to the container workdir. +func (b *FsBridge) resolvePath(path string) string { + if path == "" || path == "." { + return b.workdir + } + // Absolute paths pass through + if strings.HasPrefix(path, "/") { + return path + } + // Relative paths resolve against workdir + return 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/sandbox.go b/internal/sandbox/sandbox.go new file mode 100644 index 00000000..1173aa22 --- /dev/null +++ b/internal/sandbox/sandbox.go @@ -0,0 +1,190 @@ +// Package sandbox provides Docker-based code execution isolation. +// +// Agents can run tool commands (exec, shell) inside Docker containers +// 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 +// +// Workspace access levels: +// - none: no filesystem access +// - ro: read-only workspace mount +// - rw: read-write workspace mount +// +// Sandbox scope controls container reuse: +// - session: one container per session (max isolation) +// - agent: shared container per agent +// - shared: one container for all agents +package sandbox + +import ( + "context" + "fmt" + "strings" +) + +// Mode determines which agents are sandboxed. +type Mode string + +const ( + ModeOff Mode = "off" // no sandbox + ModeNonMain Mode = "non-main" // all except "main" agent + ModeAll Mode = "all" // every agent +) + +// Access determines workspace filesystem permissions. +type Access string + +const ( + AccessNone Access = "none" // no filesystem + AccessRO Access = "ro" // read-only + AccessRW Access = "rw" // read-write +) + +// Scope determines container reuse granularity. +type Scope string + +const ( + ScopeSession Scope = "session" // one container per session + ScopeAgent Scope = "agent" // one container per agent + ScopeShared Scope = "shared" // one container for all +) + +// Config configures the sandbox system. +// Matches TS SandboxDockerSettings + SandboxConfig. +type Config struct { + Mode Mode `json:"mode"` + Image string `json:"image"` + WorkspaceAccess Access `json:"workspace_access"` + Scope Scope `json:"scope"` + MemoryMB int `json:"memory_mb"` + CPUs float64 `json:"cpus"` + TimeoutSec int `json:"timeout_sec"` + NetworkEnabled bool `json:"network_enabled"` + RestrictedDomains []string `json:"restricted_domains,omitempty"` + Env map[string]string `json:"env,omitempty"` + + // Security hardening (matching TS buildSandboxCreateArgs) + ReadOnlyRoot bool `json:"read_only_root"` + CapDrop []string `json:"cap_drop,omitempty"` + Tmpfs []string `json:"tmpfs,omitempty"` // e.g. "/tmp", "/tmp:size=64m" + TmpfsSizeMB int `json:"tmpfs_size_mb,omitempty"` // default size for tmpfs mounts without explicit :size= (0 = Docker default) + PidsLimit int `json:"pids_limit,omitempty"` + User string `json:"user,omitempty"` // container user (e.g. "1000:1000", "nobody") + MaxOutputBytes int `json:"max_output_bytes,omitempty"` // limit exec stdout+stderr capture (default 1MB, 0 = unlimited) + SetupCommand string `json:"setup_command,omitempty"` + ContainerPrefix string `json:"container_prefix,omitempty"` + 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) + MaxAgeDays int `json:"max_age_days,omitempty"` // prune containers older than N days (default 7) + PruneIntervalMin int `json:"prune_interval_min,omitempty"` // check interval in minutes (default 5) +} + +// DefaultConfig returns sensible defaults matching TS sandbox defaults. +func DefaultConfig() Config { + return Config{ + Mode: ModeOff, + Image: "openclaw-sandbox:bookworm-slim", + WorkspaceAccess: AccessRW, + Scope: ScopeSession, + MemoryMB: 512, + CPUs: 1.0, + TimeoutSec: 300, + NetworkEnabled: false, + ReadOnlyRoot: true, + CapDrop: []string{"ALL"}, + Tmpfs: []string{"/tmp", "/var/tmp", "/run"}, + MaxOutputBytes: 1 << 20, // 1MB + ContainerPrefix: "openclaw-sbx-", + Workdir: "/workspace", + IdleHours: 24, + MaxAgeDays: 7, + PruneIntervalMin: 5, + } +} + +// ShouldSandbox returns true if the given agent should run in a sandbox. +func (c Config) ShouldSandbox(agentID string) bool { + switch c.Mode { + case ModeAll: + return true + case ModeNonMain: + return agentID != "main" && agentID != "default" + default: + return false + } +} + +// ContainerWorkdir returns the container-side working directory. +func (c Config) ContainerWorkdir() string { + if c.Workdir != "" { + return c.Workdir + } + return "/workspace" +} + +// ResolveScopeKey maps a session key to a sandbox scope key. +// Matching TS resolveSandboxScopeKey(). +func (c Config) ResolveScopeKey(sessionKey string) string { + switch c.Scope { + case ScopeShared: + return "shared" + case ScopeAgent: + // Extract agent ID from session key "agent:{agentId}:{rest}" + parts := strings.SplitN(sessionKey, ":", 3) + if len(parts) >= 2 { + return "agent:" + parts[1] + } + return "agent:default" + default: // ScopeSession + if sessionKey == "" { + return "default" + } + return sessionKey + } +} + +// ExecResult is the output of a command executed in a sandbox container. +type ExecResult struct { + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +// Sandbox is the interface for sandboxed code execution. +type Sandbox interface { + // Exec runs a command inside the sandbox and returns the result. + Exec(ctx context.Context, command []string, workDir string) (*ExecResult, error) + + // Destroy removes the sandbox container and cleans up resources. + Destroy(ctx context.Context) error + + // ID returns the sandbox's unique identifier (container ID). + ID() string +} + +// Manager manages sandbox lifecycle based on scope. +type Manager interface { + // Get returns (or creates) a sandbox for the given scope key. + // For session scope: key = sessionKey + // For agent scope: key = agentID + // For shared scope: key = "shared" + Get(ctx context.Context, key string, workspace string) (Sandbox, error) + + // Release destroys a sandbox by key. + Release(ctx context.Context, key string) error + + // ReleaseAll destroys all active sandboxes. + ReleaseAll(ctx context.Context) error + + // Stop signals background goroutines (pruning) to stop. + Stop() + + // Stats returns info about active sandboxes. + Stats() map[string]interface{} +} + +// ErrSandboxDisabled is returned when sandbox mode is "off". +var ErrSandboxDisabled = fmt.Errorf("sandbox is disabled")