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 `; LocalRunner never set a // branch, so passing "" reproduces its exact `git clone ` // 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 }