package sandbox import ( "bytes" "context" "fmt" "log/slog" "maps" "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", "goclaw.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. // Optional ExecOption (e.g. WithEnv) injects per-call env vars via docker exec -e. func (s *DockerSandbox) Exec(ctx context.Context, command []string, workDir string, opts ...ExecOption) (*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() o := ApplyExecOpts(opts) args := []string{"exec"} // Inject env vars as -e flags before containerID (credentialed exec) for k, v := range o.Env { args = append(args, "-e", k+"="+v) } 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. // If cfgOverride is non-nil, it is used for new containers instead of the global config. func (m *DockerManager) Get(ctx context.Context, key string, workspace string, cfgOverride *Config) (Sandbox, error) { cfg := m.config if cfgOverride != nil { cfg = *cfgOverride } if cfg.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 := cfg.ContainerPrefix if prefix == "" { prefix = "goclaw-sbx-" } name := prefix + sanitizeKey(key) sb, err := newDockerSandbox(ctx, name, cfg, 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)) maps.Copy(sbs, m.sandboxes) 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]any { 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]any{ "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() }