mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 08:15:29 +00:00
* feat(collaboration): isolate delegated artifacts and child runs Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution. * fix(collaboration): harden delegated task isolation Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces. * fix(collaboration): recover interrupted async tasks * fix(collaboration): normalize persisted child-run status --------- Co-authored-by: ntduc <ntduc@cpp.ai.vn>
278 lines
9.1 KiB
Go
278 lines
9.1 KiB
Go
// 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: "goclaw-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"},
|
|
PidsLimit: 256,
|
|
MaxOutputBytes: 1 << 20, // 1MB
|
|
ContainerPrefix: "goclaw-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
|
|
}
|
|
}
|
|
|
|
// DefaultContainerWorkdir is the default container-side working directory
|
|
// used when no custom Workdir is configured.
|
|
const DefaultContainerWorkdir = "/workspace"
|
|
|
|
// ContainerWorkdir returns the container-side working directory.
|
|
func (c Config) ContainerWorkdir() string {
|
|
if c.Workdir != "" {
|
|
return c.Workdir
|
|
}
|
|
return DefaultContainerWorkdir
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// ExecOption configures optional behavior for sandbox Exec calls.
|
|
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
|
|
}
|
|
|
|
// WithEnv injects additional environment variables into the sandbox exec call.
|
|
// Used by credentialed exec to pass credentials without shell interpretation.
|
|
func WithEnv(env map[string]string) ExecOption {
|
|
return func(o *ExecOpts) { o.Env = env }
|
|
}
|
|
|
|
// ApplyExecOpts resolves variadic ExecOption into ExecOpts.
|
|
func ApplyExecOpts(opts []ExecOption) ExecOpts {
|
|
var o ExecOpts
|
|
for _, opt := range opts {
|
|
opt(&o)
|
|
}
|
|
return o
|
|
}
|
|
|
|
// ReadOnlyMount describes an additional runtime-only bind mount.
|
|
// HostPath must be an absolute, canonical host path. Destination must be an
|
|
// absolute canonical container path strictly beneath the configured workdir.
|
|
type ReadOnlyMount struct {
|
|
Name string
|
|
HostPath string
|
|
Destination string
|
|
}
|
|
|
|
// GetOption configures optional behavior for Manager.Get calls.
|
|
type GetOption func(*GetOpts)
|
|
|
|
// GetOpts holds optional settings applied via GetOption.
|
|
type GetOpts struct {
|
|
ReadOnlyMounts []ReadOnlyMount
|
|
WorkspaceAccessOverride *Access
|
|
}
|
|
|
|
// WithReadOnlyMounts adds runtime-only read-only mounts to a sandbox.
|
|
func WithReadOnlyMounts(mounts ...ReadOnlyMount) GetOption {
|
|
copied := append([]ReadOnlyMount(nil), mounts...)
|
|
return func(o *GetOpts) {
|
|
o.ReadOnlyMounts = append(o.ReadOnlyMounts, copied...)
|
|
}
|
|
}
|
|
|
|
// WithWorkspaceAccessOverride changes access for this isolated sandbox
|
|
// instance only. Callers must validate the workspace authority before using it.
|
|
func WithWorkspaceAccessOverride(access Access) GetOption {
|
|
return func(o *GetOpts) {
|
|
override := access
|
|
o.WorkspaceAccessOverride = &override
|
|
}
|
|
}
|
|
|
|
// ApplyGetOpts resolves variadic GetOption values without retaining caller-owned
|
|
// slices.
|
|
func ApplyGetOpts(opts []GetOption) GetOpts {
|
|
var o GetOpts
|
|
for _, opt := range opts {
|
|
if opt != nil {
|
|
opt(&o)
|
|
}
|
|
}
|
|
o.ReadOnlyMounts = append([]ReadOnlyMount(nil), o.ReadOnlyMounts...)
|
|
return o
|
|
}
|
|
|
|
func applyGetOptsToConfig(cfg Config, opts GetOpts) Config {
|
|
if opts.WorkspaceAccessOverride != nil {
|
|
cfg.WorkspaceAccess = *opts.WorkspaceAccessOverride
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// Sandbox is the interface for sandboxed code execution.
|
|
type Sandbox interface {
|
|
// Exec runs a command inside the sandbox and returns the result.
|
|
// Optional ExecOption (e.g. WithEnv) configures per-call behavior.
|
|
Exec(ctx context.Context, command []string, workDir string, opts ...ExecOption) (*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, workspace mount,
|
|
// and effective config. Implementations must not reuse one container across
|
|
// different mounted workspaces or workspace access settings.
|
|
// For session scope: key = sessionKey
|
|
// For agent scope: key = agentID
|
|
// For shared scope: key = "shared"
|
|
// If cfgOverride is non-nil, it is used instead of the global config for new containers.
|
|
Get(ctx context.Context, key string, workspace string, cfgOverride *Config, opts ...GetOption) (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]any
|
|
}
|
|
|
|
// ErrSandboxDisabled is returned when sandbox mode is "off".
|
|
var ErrSandboxDisabled = fmt.Errorf("sandbox is disabled")
|