feat: Introduce Docker-based sandboxing with container lifecycle management, execution, pruning, and core sandbox interfaces.

This commit is contained in:
viettranx committed 2026-02-26 16:45:45 +07:00
1 parent f3ba434e8e
commit 1088ffceae
5 files changed
+918 -1

No files matched your search

-1
View File
@@ -17,7 +17,6 @@ openclaw-go
app
browser-poc
sandbox
openclaw-go
goclaw
config.json
+451
View File
@@ -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()
}
+128
View File
@@ -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)
}
}
}
+149
View File
@@ -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
}
+190
View File
@@ -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")