package sandbox import ( "context" "os" "os/exec" "path/filepath" "strings" "testing" ) // dockerAvailable reports whether a working Docker daemon is reachable, // mirroring the check the task plan asked for ("confirm Docker actually IS // available... via `docker info`"). Real container-lifecycle tests below // skip gracefully when it is not, the same way // TestNativeRunner_Run_ProjectDir_SandboxToolsAndPush skips when `git` isn't // on PATH. func dockerAvailable(t *testing.T) bool { t.Helper() if _, err := exec.LookPath("docker"); err != nil { return false } if err := exec.Command("docker", "info").Run(); err != nil { return false } return true } // --- Unit tests: exec.CommandContext mocked out, mirroring // executor.ContainerRunner's Command-field mocking pattern, so these run // without Docker and assert the argument construction / control flow in // isolation. --- // recordingCmds captures every (name, args) pair passed to DockerSandbox's // cmd() so tests can assert on the docker invocations without a real // daemon. type recordingCmds struct { calls [][]string // containerID is returned as the (fake) stdout of the "docker run" // call, so ensureContainerLocked has something to parse. containerID string } func (r *recordingCmds) command(ctx context.Context, name string, arg ...string) *exec.Cmd { call := append([]string{name}, arg...) r.calls = append(r.calls, call) if name == "docker" && len(arg) > 0 && arg[0] == "run" { return exec.Command("echo", r.containerID) } if name == "git" && len(arg) > 0 && arg[0] == "clone" { dir := arg[len(arg)-1] if err := os.MkdirAll(dir, 0755); err != nil { return exec.Command("false") } return exec.Command("true") } return exec.Command("true") } func newMockedDockerSandbox() (*DockerSandbox, *recordingCmds) { rec := &recordingCmds{containerID: "fakecontainerid123"} s := NewDockerSandbox("test-image:latest", "") s.command = rec.command return s, rec } func TestDockerSandbox_GitClone_StartsContainer(t *testing.T) { s, rec := newMockedDockerSandbox() if err := s.GitClone(context.Background(), "https://example.com/repo.git", ""); err != nil { t.Fatalf("GitClone: %v", err) } defer os.RemoveAll(s.WorkDir()) if s.WorkDir() == "" { t.Fatal("expected WorkDir to be set after GitClone") } var sawClone, sawRun bool for _, call := range rec.calls { if call[0] == "git" && len(call) > 1 && call[1] == "clone" { sawClone = true } if call[0] == "docker" && len(call) > 1 && call[1] == "run" { sawRun = true // Confirm the bind mount and uid/gid remap are present, mirroring // ContainerRunner.buildDockerArgs' --user/-v conventions. joined := strings.Join(call, " ") if !strings.Contains(joined, "--user=") { t.Errorf("docker run args missing --user=: %v", call) } if !strings.Contains(joined, s.WorkDir()+":/workspace") { t.Errorf("docker run args missing bind mount: %v", call) } } } if !sawClone { t.Error("expected a git clone invocation") } if !sawRun { t.Error("expected a docker run invocation") } } func TestDockerSandbox_RunBash_NoWorkingDirectory_Errors(t *testing.T) { s, _ := newMockedDockerSandbox() _, _, _, err := s.RunBash(context.Background(), "echo hi") if err == nil || !strings.Contains(err.Error(), "no sandbox working directory") { t.Errorf("expected 'no sandbox working directory' error, got %v", err) } } func TestDockerSandbox_RunBash_ReusesRunningContainer(t *testing.T) { s, rec := newMockedDockerSandbox() if err := s.GitClone(context.Background(), "https://example.com/repo.git", ""); err != nil { t.Fatalf("GitClone: %v", err) } defer os.RemoveAll(s.WorkDir()) runCallsBefore := countDockerRun(rec.calls) if _, _, _, err := s.RunBash(context.Background(), "echo hi"); err != nil { t.Fatalf("RunBash: %v", err) } if _, _, _, err := s.RunBash(context.Background(), "echo bye"); err != nil { t.Fatalf("RunBash: %v", err) } if got := countDockerRun(rec.calls); got != runCallsBefore { t.Errorf("expected no additional 'docker run' calls once a container is running, went from %d to %d", runCallsBefore, got) } var execCount int for _, call := range rec.calls { if call[0] == "docker" && len(call) > 1 && call[1] == "exec" { execCount++ } } if execCount != 2 { t.Errorf("expected 2 'docker exec' calls, got %d", execCount) } } func TestDockerSandbox_Cleanup_RemovesContainer(t *testing.T) { s, rec := newMockedDockerSandbox() if err := s.GitClone(context.Background(), "https://example.com/repo.git", ""); err != nil { t.Fatalf("GitClone: %v", err) } defer os.RemoveAll(s.WorkDir()) if err := s.Cleanup(context.Background()); err != nil { t.Fatalf("Cleanup: %v", err) } var sawRM bool for _, call := range rec.calls { if call[0] == "docker" && len(call) >= 3 && call[1] == "rm" { sawRM = true joined := strings.Join(call, " ") if !strings.Contains(joined, "fakecontainerid123") { t.Errorf("expected docker rm to target the started container, got %v", call) } } } if !sawRM { t.Error("expected a 'docker rm' invocation") } // Cleanup is idempotent: a second call with no container running is a // no-op, not an error. if err := s.Cleanup(context.Background()); err != nil { t.Errorf("second Cleanup should be a no-op, got %v", err) } } func TestDockerSandbox_NewDockerSandbox_DefaultsImage(t *testing.T) { s := NewDockerSandbox("", "") if s.image != defaultSandboxImage { t.Errorf("expected default image %q, got %q", defaultSandboxImage, s.image) } } func countDockerRun(calls [][]string) int { n := 0 for _, c := range calls { if c[0] == "docker" && len(c) > 1 && c[1] == "run" { n++ } } return n } // --- Real integration test: exercises actual container lifecycle. Skips // gracefully when Docker isn't available in the test environment, per the // same convention executor.container_test.go's TestNativeRunner-style tests // use for `git`. --- // TestDockerSandbox_RealContainer_Lifecycle mirrors // executor.TestNativeRunner_Run_ProjectDir_SandboxToolsAndPush's bare-origin- // repo setup, but drives DockerSandbox directly (GitClone, WriteFile, // RunBash, ReadFile, GitPush, Cleanup) against a real docker daemon, then // confirms `docker ps -a` shows the container gone after Cleanup. func TestDockerSandbox_RealContainer_Lifecycle(t *testing.T) { if !dockerAvailable(t) { t.Skip("docker not available in this environment") } if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } // Bare origin repo so `git push` from the sandbox clone doesn't hit // receive.denyCurrentBranch. origin := t.TempDir() run := func(dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com", ) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } } run(origin, "init", "--bare", "-b", "main") seed := t.TempDir() run(seed, "init", "-b", "main") run(seed, "remote", "add", "origin", origin) if err := os.WriteFile(filepath.Join(seed, "README.md"), []byte("seed\n"), 0o644); err != nil { t.Fatal(err) } run(seed, "add", "-A") run(seed, "commit", "-m", "seed") run(seed, "push", "origin", "main") s := NewDockerSandbox("", "") // default image (agent-base) ctx := context.Background() if err := s.GitClone(ctx, origin, ""); err != nil { t.Fatalf("GitClone: %v", err) } workDir := s.WorkDir() containerID := s.containerID defer os.RemoveAll(workDir) defer s.Cleanup(ctx) if containerID == "" { t.Fatal("expected a running container after GitClone") } if err := s.WriteFile(ctx, "hello.txt", "hello from docker sandbox"); err != nil { t.Fatalf("WriteFile: %v", err) } content, err := s.ReadFile(ctx, "hello.txt") if err != nil { t.Fatalf("ReadFile: %v", err) } if content != "hello from docker sandbox" { t.Errorf("ReadFile: got %q", content) } stdout, stderr, exitCode, err := s.RunBash(ctx, "cat hello.txt && wc -l < hello.txt") if err != nil { t.Fatalf("RunBash: %v", err) } if exitCode != 0 { t.Fatalf("RunBash exit code: %d, stderr: %s", exitCode, stderr) } if !strings.Contains(stdout, "hello from docker sandbox") { t.Errorf("RunBash stdout: %q", stdout) } matches, err := s.Glob(ctx, "*.txt") if err != nil { t.Fatalf("Glob: %v", err) } if len(matches) != 1 || matches[0] != "hello.txt" { t.Errorf("Glob: want [hello.txt], got %v", matches) } if _, _, _, err := s.RunBash(ctx, "git add -A && git -c user.name=test -c user.email=test@example.com commit -m 'add hello'"); err != nil { t.Fatalf("RunBash (commit): %v", err) } if err := s.GitPush(ctx, ""); err != nil { t.Fatalf("GitPush: %v", err) } logOut, err := exec.Command("git", "-C", origin, "log", "--oneline", "main").CombinedOutput() if err != nil { t.Fatalf("git log on origin: %v\n%s", err, logOut) } if !strings.Contains(string(logOut), "add hello") { t.Errorf("expected pushed commit 'add hello' in origin log, got:\n%s", logOut) } if err := s.Cleanup(ctx); err != nil { t.Fatalf("Cleanup: %v", err) } psOut, err := exec.Command("docker", "ps", "-a", "-q", "-f", "id="+containerID).CombinedOutput() if err != nil { t.Fatalf("docker ps: %v\n%s", err, psOut) } if strings.TrimSpace(string(psOut)) != "" { t.Errorf("expected container %s to be gone after Cleanup, docker ps -a shows: %s", containerID, psOut) } } func TestDockerSandbox_RealContainer_GuardedRejectionDoesNotStartExtraContainer(t *testing.T) { if !dockerAvailable(t) { t.Skip("docker not available in this environment") } if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } origin := t.TempDir() run := func(dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com", ) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } } run(origin, "init", "--bare", "-b", "main") seed := t.TempDir() run(seed, "init", "-b", "main") run(seed, "remote", "add", "origin", origin) if err := os.WriteFile(filepath.Join(seed, "README.md"), []byte("seed\n"), 0o644); err != nil { t.Fatal(err) } run(seed, "add", "-A") run(seed, "commit", "-m", "seed") run(seed, "push", "origin", "main") base := NewDockerSandbox("", "") g := &Guarded{Sandbox: base, Hooks: DefaultHooks()} ctx := context.Background() if err := g.GitClone(ctx, origin, ""); err != nil { t.Fatalf("GitClone: %v", err) } defer os.RemoveAll(g.WorkDir()) defer g.Cleanup(ctx) // A guardrail rejection must not touch the real sandbox at all — the // dangerous command never reaches `docker exec`, and the write to a // protected path never reaches the container's filesystem. if _, _, _, err := g.RunBash(ctx, "rm -rf /"); err == nil { t.Fatal("expected rejection for rm -rf /") } if err := g.WriteFile(ctx, ".env", "SECRET=1"); err == nil { t.Fatal("expected rejection for write to .env") } if _, err := base.ReadFile(ctx, ".env"); err == nil { t.Error("expected .env to not exist in the container (write was rejected)") } // An allowed command still works normally through the guard. if _, _, exitCode, err := g.RunBash(ctx, "echo still-works"); err != nil || exitCode != 0 { t.Fatalf("expected allowed command to succeed, exitCode=%d err=%v", exitCode, err) } }