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
|
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
}
|