mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 02:16:56 +00:00
feat: Introduce Docker-based sandboxing with container lifecycle management, execution, pruning, and core sandbox interfaces.
This commit is contained in:
1 parent
f3ba434e8e
commit
1088ffceae
5 files changed
+918
-1
No files matched your search
@@ -17,7 +17,6 @@ openclaw-go
|
||||
|
||||
app
|
||||
browser-poc
|
||||
sandbox
|
||||
openclaw-go
|
||||
goclaw
|
||||
config.json
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user