package sandbox import ( "bytes" "context" "fmt" "os" "os/exec" "path" "strings" "sync" ) // defaultSandboxImage is used when DockerSandbox is constructed with an // empty image. It reuses images/agent-base — the same image // executor.ContainerRunner defaults to ("claudomator-agent:latest") for the // claude/gemini CLI-subprocess path — rather than adding a second, // purpose-built minimal image for this phase. agent-base already has // everything the sandbox tool set needs (git, bash, coreutils via Ubuntu // 24.04) and `git config --system safe.directory '*'` baked in, which // DockerSandbox's bind-mount-owned-by-host-uid setup relies on. The // tradeoff: the image also carries Node/Go/the claude and gemini CLIs that // a generic tool-execution sandbox never uses, so container start is // slower and the image is larger than strictly necessary. A follow-up phase // can split out a slim `claudomator-sandbox:latest` (git + bash + coreutils // only) if that overhead becomes a real problem; standing up and // maintaining a second image wasn't worth it for this phase, especially // since Docker was not available to build/verify one in this environment. const defaultSandboxImage = "claudomator-agent:latest" // DockerSandbox implements Sandbox by running tool calls inside a Docker // container instead of directly on the host (compare HostSandbox). It // mirrors executor.ContainerRunner's clone-on-host-then-bind-mount pattern // rather than cloning inside the container: // // - GitClone clones the repo into a fresh host temp directory with a // plain `git clone` (identical to HostSandbox.GitClone), then starts a // container with that directory bind-mounted at /workspace and left // running idle (`docker run -d ... sleep infinity`), so each // ReadFile/WriteFile/RunBash/Glob call becomes a `docker exec` into an // already-running container instead of paying container-start latency // per call. // - The container runs as `--user :`, matching // ContainerRunner.buildDockerArgs, so files it creates in the bind // mount are host-owned rather than root-owned. // // WorkDir/resume design: WorkDir returns the HOST bind-mount directory, not // the in-container /workspace path. This is the value NativeRunner persists // to executions.sandbox_dir for a BLOCKED→QUEUED resume (see // internal/executor/nativerunner.go), and it's the only thing persisted — // the running container's ID is not stored anywhere durable. Consequently a // resumed DockerSandbox does NOT attempt to reattach to the original // container; instead every tool method lazily starts a fresh container // bind-mounted onto the persisted host directory if one isn't already // running on this *DockerSandbox value (see ensureContainerLocked). This // was chosen over reattachment because the original container ID has // nowhere to live in the current single-string sandbox_dir contract, and // even if it did, reattachment is fragile (the container may already be // gone — host reboot, manual `docker rm`, OOM kill) where re-mounting the // host directory into a brand new container is not: as long as the host // directory survived, a resumed DockerSandbox behaves identically to a // fresh one. // // Known limitation (not fixed in this phase): internal/agentloop.Loop.Run // only calls Sandbox.Cleanup() on the non-blocked finish path — on // ask_user (BLOCKED) the sandbox is deliberately left alive so a resume can // reuse it. For HostSandbox that only leaves a temp directory on disk. For // DockerSandbox it also leaves the idle `sleep infinity` container running // until the task is eventually resumed and finishes cleanly (at which point // Cleanup finally removes it), or some future reaper is added. This mirrors // the existing resume contract as-is rather than extending it; a general // "reap idle blocked sandboxes" mechanism is out of scope here. type DockerSandbox struct { mu sync.Mutex image string hostDir string // host bind-mount source directory; "" until established containerID string // "" until a container is running for hostDir uid, gid int // command allows mocking exec.CommandContext in tests, mirroring // executor.ContainerRunner.Command. command func(ctx context.Context, name string, arg ...string) *exec.Cmd } var _ Sandbox = (*DockerSandbox)(nil) // NewDockerSandbox returns a DockerSandbox using image (falling back to // defaultSandboxImage when empty). Pass hostDir="" to construct a // not-yet-cloned sandbox — GitClone establishes the directory and starts // the container. Pass an existing host directory (e.g. a resumed BLOCKED // task's e.SandboxDir) to reuse a prior clone without cloning again; a // container is started lazily on the first tool call. func NewDockerSandbox(image, hostDir string) *DockerSandbox { if image == "" { image = defaultSandboxImage } return &DockerSandbox{ image: image, hostDir: hostDir, uid: os.Getuid(), gid: os.Getgid(), } } func (s *DockerSandbox) cmd(ctx context.Context, name string, arg ...string) *exec.Cmd { if s.command != nil { return s.command(ctx, name, arg...) } return exec.CommandContext(ctx, name, arg...) } // WorkDir returns the host bind-mount directory, or "" if none has been // established yet. See the resume design note on DockerSandbox. func (s *DockerSandbox) WorkDir() string { s.mu.Lock() defer s.mu.Unlock() return s.hostDir } // GitClone clones remote into a fresh host temp directory (if this sandbox // has no working directory yet) and starts the idle container bind-mounting // it. branch, if non-empty, is passed as `git clone -b `. func (s *DockerSandbox) GitClone(ctx context.Context, remote, branch string) error { s.mu.Lock() defer s.mu.Unlock() if s.hostDir == "" { tmpDir, err := os.MkdirTemp("", "claudomator-dockersandbox-*") if err != nil { return fmt.Errorf("sandbox: create temp dir: %w", err) } s.hostDir = tmpDir } args := []string{"clone"} if branch != "" { args = append(args, "-b", branch) } args = append(args, remote, s.hostDir) out, err := s.cmd(ctx, "git", args...).CombinedOutput() if err != nil { os.RemoveAll(s.hostDir) s.hostDir = "" return fmt.Errorf("sandbox: git clone: %w\n%s", err, out) } if err := s.ensureContainerLocked(ctx); err != nil { return fmt.Errorf("sandbox: %w", err) } return nil } // ensureContainerLocked starts a container bind-mounting s.hostDir at // /workspace if one isn't already running for this sandbox value. Must be // called with s.mu held, and s.hostDir must already be set. func (s *DockerSandbox) ensureContainerLocked(ctx context.Context) error { if s.containerID != "" { return nil } if s.hostDir == "" { return fmt.Errorf("no sandbox working directory") } args := []string{ "run", "-d", fmt.Sprintf("--user=%d:%d", s.uid, s.gid), "-v", s.hostDir + ":/workspace", "-w", "/workspace", s.image, "sleep", "infinity", } out, err := s.cmd(ctx, "docker", args...).CombinedOutput() if err != nil { return fmt.Errorf("docker run: %w\n%s", err, strings.TrimSpace(string(out))) } id := strings.TrimSpace(string(out)) if id == "" { return fmt.Errorf("docker run: empty container ID") } s.containerID = id return nil } // GitPush pushes the container workspace's current HEAD to origin. branch, // if non-empty, is used as the push ref; "" pushes HEAD, matching // HostSandbox.GitPush. func (s *DockerSandbox) GitPush(ctx context.Context, branch string) error { s.mu.Lock() if s.hostDir == "" { s.mu.Unlock() return fmt.Errorf("sandbox: git push: no sandbox working directory") } if err := s.ensureContainerLocked(ctx); err != nil { s.mu.Unlock() return fmt.Errorf("sandbox: git push: %w", err) } containerID := s.containerID s.mu.Unlock() ref := branch if ref == "" { ref = "HEAD" } out, err := s.cmd(ctx, "docker", "exec", containerID, "git", "push", "origin", ref).CombinedOutput() if err != nil { return fmt.Errorf("sandbox: git push: %w\n%s", err, out) } return nil } // Cleanup removes the running container (docker rm -f), if any. The // host-side bind-mount directory is intentionally left in place — matching // ContainerRunner's workspace-disposal behavior, where the host workspace // (not the container) is the thing preservation/removal decisions are made // about — so callers that want the host directory gone too must remove it // themselves (NativeRunner/agentloop never do; they rely on the OS temp dir // eventually being cleaned, same as HostSandbox's dir would be if a caller // forgot to call Cleanup). func (s *DockerSandbox) Cleanup(ctx context.Context) error { s.mu.Lock() containerID := s.containerID s.containerID = "" s.mu.Unlock() if containerID == "" { return nil } out, err := s.cmd(ctx, "docker", "rm", "-f", containerID).CombinedOutput() if err != nil { return fmt.Errorf("sandbox: docker rm: %w\n%s", err, out) } return nil } // resolveContainerPath ensures a container is running and returns // (containerID, in-container path) for p, joined against /workspace when // relative — mirroring HostSandbox.resolvePath's absolute-path passthrough. func (s *DockerSandbox) resolveContainerPath(ctx context.Context, p string) (containerID, containerPath string, err error) { s.mu.Lock() defer s.mu.Unlock() if s.hostDir == "" { return "", "", fmt.Errorf("no sandbox working directory") } if err := s.ensureContainerLocked(ctx); err != nil { return "", "", err } cp := p if !path.IsAbs(cp) { cp = path.Join("/workspace", cp) } return s.containerID, cp, nil } func (s *DockerSandbox) ReadFile(ctx context.Context, p string) (string, error) { containerID, cp, err := s.resolveContainerPath(ctx, p) if err != nil { return "", fmt.Errorf("read_file: %w", err) } var out, errBuf bytes.Buffer cmd := s.cmd(ctx, "docker", "exec", containerID, "cat", cp) cmd.Stdout = &out cmd.Stderr = &errBuf if err := cmd.Run(); err != nil { return "", fmt.Errorf("read_file: %w: %s", err, strings.TrimSpace(errBuf.String())) } return out.String(), nil } func (s *DockerSandbox) WriteFile(ctx context.Context, p, content string) error { containerID, cp, err := s.resolveContainerPath(ctx, p) if err != nil { return fmt.Errorf("write_file: %w", err) } dir := path.Dir(cp) if out, err := s.cmd(ctx, "docker", "exec", containerID, "mkdir", "-p", dir).CombinedOutput(); err != nil { return fmt.Errorf("write_file: mkdir -p %s: %w\n%s", dir, err, out) } // `sh -c 'cat > "$1"' sh ` writes stdin to without // re-interpreting it as shell, and passes the path as $1 (arg after the // script name) rather than interpolating it into the script string, so // paths containing quotes/spaces/etc. can't break out. cmd := s.cmd(ctx, "docker", "exec", "-i", containerID, "sh", "-c", `cat > "$1"`, "sh", cp) cmd.Stdin = strings.NewReader(content) var errBuf bytes.Buffer cmd.Stderr = &errBuf if err := cmd.Run(); err != nil { return fmt.Errorf("write_file: %w: %s", err, strings.TrimSpace(errBuf.String())) } return nil } func (s *DockerSandbox) RunBash(ctx context.Context, command string) (stdout, stderr string, exitCode int, err error) { s.mu.Lock() if s.hostDir == "" { s.mu.Unlock() return "", "", 0, fmt.Errorf("run_bash: no sandbox working directory") } if ensureErr := s.ensureContainerLocked(ctx); ensureErr != nil { s.mu.Unlock() return "", "", 0, fmt.Errorf("run_bash: %w", ensureErr) } containerID := s.containerID s.mu.Unlock() cmd := s.cmd(ctx, "docker", "exec", containerID, "sh", "-c", command) var outBuf, errBuf bytes.Buffer cmd.Stdout = &outBuf cmd.Stderr = &errBuf runErr := cmd.Run() if runErr != nil { if exitErr, ok := runErr.(*exec.ExitError); ok { exitCode = exitErr.ExitCode() } else { return "", "", 0, fmt.Errorf("run_bash: %w", runErr) } } stdout = outBuf.String() stderr = errBuf.String() if len(stdout) > maxToolOutput { stdout = stdout[:maxToolOutput] + "\n[truncated]" } if len(stderr) > maxToolOutput { stderr = stderr[:maxToolOutput] + "\n[truncated]" } return stdout, stderr, exitCode, nil } // Glob lists files matching pattern relative to /workspace inside the // container, via a shell glob expansion (`for f in ; do ...`) // rather than `find`, to keep single-segment patterns like "*.go" behaving // the same as HostSandbox.Glob's filepath.Glob. Unlike filepath.Glob, // "**" does not recurse in a POSIX shell without bash's globstar — patterns // relying on recursive "**" matching are a known approximation gap here. func (s *DockerSandbox) Glob(ctx context.Context, pattern string) ([]string, error) { s.mu.Lock() if s.hostDir == "" { s.mu.Unlock() return nil, fmt.Errorf("glob: no sandbox working directory") } if err := s.ensureContainerLocked(ctx); err != nil { s.mu.Unlock() return nil, fmt.Errorf("glob: %w", err) } containerID := s.containerID s.mu.Unlock() script := fmt.Sprintf(`cd /workspace && for f in %s; do [ -e "$f" ] && echo "$f"; done`, pattern) var outBuf, errBuf bytes.Buffer cmd := s.cmd(ctx, "docker", "exec", containerID, "sh", "-c", script) cmd.Stdout = &outBuf cmd.Stderr = &errBuf if err := cmd.Run(); err != nil { return nil, fmt.Errorf("glob: %w: %s", err, strings.TrimSpace(errBuf.String())) } var matches []string for _, l := range strings.Split(strings.TrimSpace(outBuf.String()), "\n") { if l != "" { matches = append(matches, l) } } return matches, nil }