1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
|
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 <hostUID>:<hostGID>`, 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 <branch>`.
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 working directory's current HEAD to origin. branch,
// if non-empty, is used as the push ref; "" pushes HEAD, matching
// HostSandbox.GitPush. The push runs from the HOST against hostDir (not
// inside the container) so that local-path remotes — common in tests and
// used in production when cloning from a local mirror — are reachable. The
// clone was performed on the host, so the host git process has identical
// access to the remote.
//
// Security: two flags prevent the sandbox from escaping onto the host:
// - core.hooksPath=/dev/null: ignores any pre-push/post-push hooks that a
// container agent may have planted in the workspace's .git/hooks/ or via
// core.hooksPath in .git/config.
// - protocol.ext.allow=never: blocks the ext:: pseudo-protocol, which can
// run arbitrary shell commands when used as a remote URL.
//
// The ref is also validated to not start with "-" and is passed after "--"
// to prevent argument injection.
func (s *DockerSandbox) GitPush(ctx context.Context, branch string) error {
s.mu.Lock()
hostDir := s.hostDir
s.mu.Unlock()
if hostDir == "" {
return fmt.Errorf("sandbox: git push: no sandbox working directory")
}
ref := branch
if ref == "" {
ref = "HEAD"
}
if strings.HasPrefix(ref, "-") {
return fmt.Errorf("sandbox: git push: invalid ref %q", ref)
}
out, err := s.cmd(ctx, "git",
"-C", hostDir,
"-c", "core.hooksPath=/dev/null",
"-c", "protocol.ext.allow=never",
"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 <path>` writes stdin to <path> 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 <pattern>; 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
}
|