summaryrefslogtreecommitdiff
path: root/internal/sandbox
diff options
context:
space:
mode:
Diffstat (limited to 'internal/sandbox')
-rw-r--r--internal/sandbox/hostsandbox.go214
-rw-r--r--internal/sandbox/sandbox.go25
2 files changed, 239 insertions, 0 deletions
diff --git a/internal/sandbox/hostsandbox.go b/internal/sandbox/hostsandbox.go
new file mode 100644
index 0000000..76ed9f7
--- /dev/null
+++ b/internal/sandbox/hostsandbox.go
@@ -0,0 +1,214 @@
+package sandbox
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sync"
+)
+
+// HostSandbox runs tool calls directly on the host filesystem/shell — no
+// container isolation, no new safety checks beyond what LocalRunner already
+// did. It is safe for concurrent use; a mutex guards the working-directory
+// field since GitClone/Cleanup mutate it.
+//
+// A HostSandbox with an empty working directory (the zero value, or one
+// constructed via NewHostSandbox("")) represents "no sandbox configured" —
+// matching LocalRunner's historical behavior for tasks without a
+// project_dir: file/bash/glob tool calls fail with a "no sandbox working
+// directory" error identical to the original dispatchAgentTool guard, and
+// GitClone must be called (or the sandbox constructed with an existing dir)
+// before those tools will work.
+type HostSandbox struct {
+ mu sync.Mutex
+ dir string
+}
+
+var _ Sandbox = (*HostSandbox)(nil)
+
+// NewHostSandbox returns a HostSandbox rooted at dir. Pass "" to construct a
+// not-yet-cloned sandbox (GitClone will establish the directory); pass an
+// existing directory to reuse a prior sandbox (e.g. resuming a BLOCKED task)
+// without cloning again.
+func NewHostSandbox(dir string) *HostSandbox {
+ return &HostSandbox{dir: dir}
+}
+
+// WorkDir returns the current working directory, or "" if none has been
+// established yet.
+func (s *HostSandbox) WorkDir() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.dir
+}
+
+// GitClone clones remote into a fresh temp directory (if this sandbox has no
+// working directory yet) or into the existing one otherwise. branch, if
+// non-empty, is passed as `git clone -b <branch>`; LocalRunner never set a
+// branch, so passing "" reproduces its exact `git clone <remote> <dir>`
+// invocation.
+func (s *HostSandbox) GitClone(ctx context.Context, remote, branch string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.dir == "" {
+ tmpDir, err := os.MkdirTemp("", "claudomator-local-*")
+ if err != nil {
+ return fmt.Errorf("sandbox: create temp dir: %w", err)
+ }
+ s.dir = tmpDir
+ }
+
+ args := []string{"clone"}
+ if branch != "" {
+ args = append(args, "-b", branch)
+ }
+ args = append(args, remote, s.dir)
+ cmd := exec.CommandContext(ctx, "git", args...)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ os.RemoveAll(s.dir)
+ s.dir = ""
+ return fmt.Errorf("sandbox: git clone: %w\n%s", err, out)
+ }
+ return nil
+}
+
+// GitPush pushes the working directory's current HEAD to origin. branch, if
+// non-empty, is used as the push ref; "" reproduces LocalRunner's exact
+// `git push origin HEAD` invocation.
+func (s *HostSandbox) GitPush(ctx context.Context, branch string) error {
+ s.mu.Lock()
+ dir := s.dir
+ s.mu.Unlock()
+ if dir == "" {
+ return fmt.Errorf("sandbox: git push: no sandbox working directory")
+ }
+ ref := branch
+ if ref == "" {
+ ref = "HEAD"
+ }
+ cmd := exec.CommandContext(ctx, "git", "-C", dir, "push", "origin", ref)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("sandbox: git push: %w\n%s", err, out)
+ }
+ return nil
+}
+
+// Cleanup removes the sandbox's working directory and resets it to "". A
+// no-op if no working directory was ever established.
+func (s *HostSandbox) Cleanup(_ context.Context) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.dir == "" {
+ return nil
+ }
+ err := os.RemoveAll(s.dir)
+ s.dir = ""
+ return err
+}
+
+// resolvePath joins a relative path against the working directory, leaving
+// absolute paths untouched — matching dispatchAgentTool's historical
+// behavior exactly.
+func (s *HostSandbox) resolvePath(p string) (string, error) {
+ s.mu.Lock()
+ dir := s.dir
+ s.mu.Unlock()
+ if dir == "" {
+ return "", fmt.Errorf("no sandbox working directory")
+ }
+ if !filepath.IsAbs(p) {
+ p = filepath.Join(dir, p)
+ }
+ return p, nil
+}
+
+func (s *HostSandbox) ReadFile(_ context.Context, path string) (string, error) {
+ p, err := s.resolvePath(path)
+ if err != nil {
+ return "", fmt.Errorf("read_file: %w", err)
+ }
+ data, err := os.ReadFile(p)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+func (s *HostSandbox) WriteFile(_ context.Context, path, content string) error {
+ p, err := s.resolvePath(path)
+ if err != nil {
+ return fmt.Errorf("write_file: %w", err)
+ }
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ return err
+ }
+ return os.WriteFile(p, []byte(content), 0o644)
+}
+
+// maxToolOutput caps stdout/stderr returned from RunBash, matching
+// dispatchAgentTool's historical 8KB truncation.
+const maxToolOutput = 8 * 1024
+
+func (s *HostSandbox) RunBash(ctx context.Context, command string) (stdout, stderr string, exitCode int, err error) {
+ s.mu.Lock()
+ dir := s.dir
+ s.mu.Unlock()
+ if dir == "" {
+ return "", "", 0, fmt.Errorf("run_bash: no sandbox working directory")
+ }
+
+ cmd := exec.CommandContext(ctx, "sh", "-c", command)
+ cmd.Dir = dir
+ 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, 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
+}
+
+func (s *HostSandbox) Glob(_ context.Context, pattern string) ([]string, error) {
+ s.mu.Lock()
+ dir := s.dir
+ s.mu.Unlock()
+ if dir == "" {
+ return nil, fmt.Errorf("glob: no sandbox working directory")
+ }
+ if !filepath.IsAbs(pattern) {
+ pattern = filepath.Join(dir, pattern)
+ }
+ matches, err := filepath.Glob(pattern)
+ if err != nil {
+ return nil, err
+ }
+ rel := make([]string, 0, len(matches))
+ for _, m := range matches {
+ r, relErr := filepath.Rel(dir, m)
+ if relErr != nil {
+ r = m
+ }
+ rel = append(rel, r)
+ }
+ return rel, nil
+}
diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go
new file mode 100644
index 0000000..4275475
--- /dev/null
+++ b/internal/sandbox/sandbox.go
@@ -0,0 +1,25 @@
+// Package sandbox defines the filesystem/shell/git surface an agent loop
+// executes tool calls against. Sandbox is intentionally narrow — just the
+// operations internal/agentloop's read_file/write_file/run_bash/glob tools
+// and its git clone/push/cleanup lifecycle need.
+//
+// Phase 1 ships exactly one implementation, HostSandbox, which lifts the
+// LocalRunner's existing behavior (host git clone into a temp dir, plain
+// os.Exec-backed file/bash tools) verbatim. A DockerSandbox (isolation
+// improvements, new safety checks) is explicitly out of scope for this phase.
+package sandbox
+
+import "context"
+
+// Sandbox is the environment a single task execution runs its tool calls
+// against.
+type Sandbox interface {
+ ReadFile(ctx context.Context, path string) (string, error)
+ WriteFile(ctx context.Context, path, content string) error
+ RunBash(ctx context.Context, command string) (stdout, stderr string, exitCode int, err error)
+ Glob(ctx context.Context, pattern string) ([]string, error)
+ GitClone(ctx context.Context, remote, branch string) error
+ GitPush(ctx context.Context, branch string) error
+ WorkDir() string
+ Cleanup(ctx context.Context) error
+}