summaryrefslogtreecommitdiff
path: root/internal/sandbox/hostsandbox.go
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 08:49:43 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 08:49:43 +0000
commit67e0081c6d573b701ed931f96e14dbe5b4258a17 (patch)
treebc7f8ce0d57876dd85720924d0275dc39c05e5b4 /internal/sandbox/hostsandbox.go
parent3d286974cdc28c68c5ee536ce9303899dae9540e (diff)
refactor(executor): extract provider-neutral tool-use loop (Phase 1)
Splits LocalRunner's OpenAI-specific agentic loop into reusable, provider- agnostic pieces so later phases can add native Anthropic/OpenAI/Google/Groq/ OpenRouter adapters without duplicating the control flow: - internal/provider: neutral Provider/ChatRequest/ChatResponse types, plus an openaicompat adapter wrapping the existing internal/llm.Client unchanged - internal/sandbox: Sandbox interface + HostSandbox (git clone/push/cleanup, read_file/write_file/run_bash/glob), lifted verbatim from local.go/localtools.go - internal/agentloop: the extracted tool-use loop (request/response/tool- dispatch/loop, ask_user blocking, stream-json envelope, summary fallback) - internal/agentchannel: AgentChannel/SubtaskSpec/BlockedError/ErrAgentBlocked moved out of internal/executor so agentloop can use them without an import cycle; internal/executor re-exports via type aliases, so no call site changes - internal/executor/nativerunner.go: NativeRunner replaces LocalRunner, wiring agentloop.Loop + openaicompat + HostSandbox together - config.Providers map[string]ProviderConfig added (unused until Phase 2+) Zero intended behavior change: go test -race ./... passes across all packages, and end-to-end stream-json/summary/changestats output was verified byte-compatible against a fake OpenAI-compatible server. Adds test coverage for sandbox tool-dispatch (git clone/push, read/write/bash/glob) that LocalRunner never had. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal/sandbox/hostsandbox.go')
-rw-r--r--internal/sandbox/hostsandbox.go214
1 files changed, 214 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
+}