返回 DeepSeek-Reasonix
workspace_test.go
根目录 / internal / tool / builtin / workspace_test.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "reasonix/internal/sandbox"
12 "reasonix/internal/tool"
13 )
14
15 func TestResolveIn(t *testing.T) {
16 workDir := filepath.Join(t.TempDir(), "proj")
17 absolute := filepath.Join(t.TempDir(), "etc", "passwd")
18 cases := []struct {
19 workDir, p, want string
20 }{
21 {"", "foo.go", "foo.go"}, // empty workDir: unchanged
22 {"", "", ""}, // empty workDir: unchanged
23 {workDir, "foo.go", filepath.Join(workDir, "foo.go")}, // relative joins
24 {workDir, "a/b.go", filepath.Join(workDir, "a", "b.go")}, // nested relative
25 {workDir, ".", workDir}, // "." targets the root
26 {workDir, "", workDir}, // empty targets the root
27 {workDir, absolute, absolute}, // absolute honored verbatim
28 {workDir, "../escape", filepath.Join(filepath.Dir(workDir), "escape")}, // join cleans (confiner enforces)
29 }
30 for _, c := range cases {
31 if got := resolveIn(c.workDir, c.p); got != c.want {
32 t.Errorf("resolveIn(%q, %q) = %q, want %q", c.workDir, c.p, got, c.want)
33 }
34 }
35 }
36
37 // TestWorkspaceBindsReadAndWrite checks that relative paths land inside the
38 // workspace directory rather than the process cwd, for both a reader and a
39 // writer, and that write confinement defaults to the workspace.
40 func TestWorkspaceBindsReadAndWrite(t *testing.T) {
41 dir := t.TempDir()
42 ws := Workspace{Dir: dir}
43 tools := byName(ws.Tools())
44
45 // write_file with a relative path writes inside the workspace.
46 wf := tools["write_file"]
47 if _, err := wf.Execute(context.Background(), argsJSON(t, map[string]any{"path": "out.txt", "content": "hi\n"})); err != nil {
48 t.Fatalf("write: %v", err)
49 }
50 if b, err := os.ReadFile(filepath.Join(dir, "out.txt")); err != nil || string(b) != "hi\n" {
51 t.Fatalf("file not written into workspace: %q err=%v", b, err)
52 }
53
54 // read_file with the same relative path reads it back.
55 rf := tools["read_file"]
56 out, err := rf.Execute(context.Background(), argsJSON(t, map[string]any{"path": "out.txt"}))
57 if err != nil || !strings.Contains(out, "hi") {
58 t.Fatalf("read back: out=%q err=%v", out, err)
59 }
60 }
61
62 // TestWorkspaceWriteConfinement confirms the default write root is the workspace
63 // dir: a relative write succeeds, an absolute write outside it is refused.
64 func TestWorkspaceWriteConfinement(t *testing.T) {
65 dir := t.TempDir()
66 outside := filepath.Join(t.TempDir(), "evil.txt")
67 wf := byName(Workspace{Dir: dir}.Tools())["write_file"]
68
69 // Inside the workspace: allowed.
70 if _, err := wf.Execute(context.Background(), argsJSON(t, map[string]any{"path": "ok.txt", "content": "x"})); err != nil {
71 t.Fatalf("in-workspace write should succeed: %v", err)
72 }
73 // Absolute path outside the workspace: refused by the confiner.
74 if _, err := wf.Execute(context.Background(), argsJSON(t, map[string]any{"path": outside, "content": "x"})); err == nil {
75 t.Error("write outside the workspace should be refused")
76 }
77 }
78
79 func TestWorkspaceMoveFileBindsAndConfines(t *testing.T) {
80 dir := t.TempDir()
81 outside := filepath.Join(t.TempDir(), "evil.txt")
82 if err := os.WriteFile(filepath.Join(dir, "a.md"), []byte("hello"), 0o644); err != nil {
83 t.Fatal(err)
84 }
85 mv := byName(Workspace{Dir: dir}.Tools())["move_file"]
86
87 if _, err := mv.Execute(context.Background(), argsJSON(t, map[string]any{"source_path": "a.md", "destination_path": "docs/a.md"})); err != nil {
88 t.Fatalf("move inside workspace should succeed: %v", err)
89 }
90 if b, err := os.ReadFile(filepath.Join(dir, "docs", "a.md")); err != nil || string(b) != "hello" {
91 t.Fatalf("file not moved inside workspace: %q err=%v", b, err)
92 }
93 if err := os.WriteFile(filepath.Join(dir, "b.md"), []byte("x"), 0o644); err != nil {
94 t.Fatal(err)
95 }
96 if _, err := mv.Execute(context.Background(), argsJSON(t, map[string]any{"source_path": "b.md", "destination_path": outside})); err == nil {
97 t.Fatal("move outside the workspace should be refused")
98 }
99 }
100
101 // TestWorkspaceBashDir checks the platform's primary shell runs in the workspace directory.
102 func TestWorkspaceBashDir(t *testing.T) {
103 dir := t.TempDir()
104 shell := requireWorkspaceShell(t, Workspace{Dir: dir}.Tools())
105 command := "pwd"
106 if shell.Name() == "pwsh" {
107 command = "Get-Location"
108 }
109 out, err := shell.Execute(fullAccessBashTestContext(t.Context()), argsJSON(t, map[string]any{
110 "command": command,
111 "description": "Print workspace directory",
112 }))
113 if err != nil {
114 t.Fatalf("%s: %v", shell.Name(), err)
115 }
116 // macOS /tmp is a symlink to /private/tmp; compare on the resolved base name.
117 if !strings.Contains(out, filepath.Base(dir)) {
118 t.Errorf("bash cwd = %q, want to contain %q", strings.TrimSpace(out), filepath.Base(dir))
119 }
120 }
121
122 // TestWorkspacePreviewBinds confirms a workspace-bound writer previews the file
123 // inside its directory when given a relative path.
124 func TestWorkspacePreviewBinds(t *testing.T) {
125 dir := t.TempDir()
126 wf := byName(Workspace{Dir: dir}.Tools())["write_file"]
127 p, ok := wf.(tool.Previewer)
128 if !ok {
129 t.Fatal("write_file should be a Previewer")
130 }
131 change, err := p.Preview(context.Background(), argsJSON(t, map[string]any{"path": "new.txt", "content": "a\n"}))
132 if err != nil {
133 t.Fatalf("preview: %v", err)
134 }
135 if change.Path != filepath.Join(dir, "new.txt") {
136 t.Errorf("preview path = %q, want inside workspace", change.Path)
137 }
138 }
139
140 // TestWorkspaceEnabledFilter checks the enabled whitelist.
141 func TestWorkspaceEnabledFilter(t *testing.T) {
142 tools := Workspace{Dir: t.TempDir()}.Tools("read_file", "bash", "todo_write", "wait")
143 got := byName(tools)
144 if len(got) != 4 || got["read_file"] == nil || got["todo_write"] == nil || got["wait"] == nil {
145 t.Fatalf("enabled filter returned %d tools: %v", len(got), keys(got))
146 }
147 requireWorkspaceShell(t, tools)
148 }
149
150 func TestWorkspacePreservesSessionLevelBuiltins(t *testing.T) {
151 got := byName(Workspace{Dir: t.TempDir()}.Tools())
152 for _, name := range []string{
153 "todo_write",
154 "bash_output",
155 "kill_shell",
156 "wait",
157 "move_file",
158 "notebook_edit",
159 } {
160 if got[name] == nil {
161 t.Fatalf("workspace tools missing %q; got %v", name, keys(got))
162 }
163 }
164 if got["complete_step"] != nil || got["session_read_strategy_receipt"] != nil {
165 t.Fatal("retired proof/read-policy tools remain discoverable")
166 }
167 }
168
169 func TestWorkspaceToolSchemasStableAcrossRoots(t *testing.T) {
170 firstRoot := t.TempDir()
171 secondRoot := t.TempDir()
172
173 first := workspaceSchemasJSON(t, firstRoot)
174 second := workspaceSchemasJSON(t, secondRoot)
175
176 if first != second {
177 t.Fatalf("workspace tool schemas should not depend on workspace root:\nfirst=%s\nsecond=%s", first, second)
178 }
179 if strings.Contains(first, firstRoot) || strings.Contains(first, secondRoot) {
180 t.Fatalf("workspace paths must not leak into tool schemas: %s", first)
181 }
182
183 resolver := NewPathResolver()
184 resolver.RegisterReadRoot("__reasonix_external_folder/schema/root", t.TempDir())
185 withResolver := workspaceSchemasJSONWithResolver(t, firstRoot, resolver)
186 if first != withResolver {
187 t.Fatalf("workspace tool schemas should not depend on external read roots:\nfirst=%s\nwith=%s", first, withResolver)
188 }
189 }
190
191 // TestWorkspaceEmptyDirUnchanged confirms a zero-Dir workspace yields tools that
192 // behave exactly like the process-cwd built-ins (relative path unchanged).
193 func TestWorkspaceEmptyDirUnchanged(t *testing.T) {
194 tools := Workspace{}.Tools()
195 if len(tools) == 0 {
196 t.Fatal("expected tools")
197 }
198 // A zero-value read_file and the workspace's read_file are equivalent: both
199 // resolve "foo" against the process cwd.
200 if resolveIn("", "foo") != "foo" {
201 t.Fatal("empty workspace should leave paths unresolved")
202 }
203 }
204
205 func TestWorkspaceReadToolsResolveExternalReadRoots(t *testing.T) {
206 workspace := t.TempDir()
207 external := t.TempDir()
208 if err := os.MkdirAll(filepath.Join(external, "src"), 0o755); err != nil {
209 t.Fatal(err)
210 }
211 externalFile := filepath.Join(external, "src", "outside.txt")
212 if err := os.WriteFile(externalFile, []byte("outside\n"), 0o644); err != nil {
213 t.Fatal(err)
214 }
215
216 token := "__reasonix_external_folder/abc123/External"
217 resolver := NewPathResolver()
218 resolver.RegisterReadRoot(token, external)
219 tools := byName(Workspace{Dir: workspace, ReadPaths: resolver}.Tools("read_file", "ls", "grep", "glob"))
220
221 readOut := runTool(t, tools["read_file"], map[string]any{"path": token + "/src/outside.txt"})
222 if !strings.Contains(readOut, "1→outside") {
223 t.Fatalf("read_file external token output = %q, want file content", readOut)
224 }
225
226 lsOut := runTool(t, tools["ls"], map[string]any{"path": token + "/src"})
227 if !strings.Contains(lsOut, "outside.txt") {
228 t.Fatalf("ls external token output = %q, want outside.txt", lsOut)
229 }
230
231 grepOut := runTool(t, tools["grep"], map[string]any{"pattern": "outside", "path": token})
232 if !strings.Contains(grepOut, token+"/src/outside.txt:1:outside") {
233 t.Fatalf("grep external token output = %q, want token path hit", grepOut)
234 }
235 if strings.Contains(grepOut, filepath.ToSlash(external)) {
236 t.Fatalf("grep external token output leaked local path: %q", grepOut)
237 }
238
239 globOut := runTool(t, tools["glob"], map[string]any{"pattern": token + "/**/*.txt"})
240 if !strings.Contains(globOut, token+"/src/outside.txt") {
241 t.Fatalf("glob external token output = %q, want token path hit", globOut)
242 }
243 if strings.Contains(globOut, filepath.ToSlash(external)) {
244 t.Fatalf("glob external token output leaked local path: %q", globOut)
245 }
246
247 assertExternalToolError(t, tools["read_file"], map[string]any{"path": token + "/src/missing.txt"}, token+"/src/missing.txt", external)
248 assertExternalToolError(t, tools["ls"], map[string]any{"path": token + "/missing"}, token+"/missing", external)
249 assertExternalToolError(t, tools["grep"], map[string]any{"pattern": "outside", "path": token + "/missing"}, token+"/missing", external)
250 assertExternalToolError(t, tools["glob"], map[string]any{"pattern": token + "/missing/**/*.go"}, token+"/missing/**/*.go", external)
251 }
252
253 // helpers
254
255 func byName(tools []tool.Tool) map[string]tool.Tool {
256 m := make(map[string]tool.Tool, len(tools))
257 for _, t := range tools {
258 m[t.Name()] = t
259 }
260 return m
261 }
262
263 func keys(m map[string]tool.Tool) []string {
264 out := make([]string, 0, len(m))
265 for k := range m {
266 out = append(out, k)
267 }
268 return out
269 }
270
271 func requireWorkspaceShell(t *testing.T, tools []tool.Tool) tool.Tool {
272 t.Helper()
273 var shells []tool.Tool
274 for _, candidate := range tools {
275 if tool.IsShellToolName(candidate.Name()) {
276 shells = append(shells, candidate)
277 }
278 }
279 if len(shells) != 1 {
280 t.Fatalf("workspace shell tools = %v, want exactly one primary shell", toolNames(shells))
281 }
282 wantName := "bash"
283 if sandbox.ResolveShell("", "", nil).Kind == sandbox.ShellPowerShell {
284 wantName = "pwsh"
285 }
286 if shells[0].Name() != wantName {
287 t.Fatalf("workspace shell name = %q, want %q", shells[0].Name(), wantName)
288 }
289 return shells[0]
290 }
291
292 func workspaceSchemasJSON(t *testing.T, dir string) string {
293 return workspaceSchemasJSONWithResolver(t, dir, nil)
294 }
295
296 func workspaceSchemasJSONWithResolver(t *testing.T, dir string, resolver *PathResolver) string {
297 t.Helper()
298 reg := tool.NewRegistry()
299 for _, tt := range (Workspace{Dir: dir, ReadPaths: resolver}).Tools() {
300 reg.Add(tt)
301 }
302 b, err := json.Marshal(reg.Schemas())
303 if err != nil {
304 t.Fatalf("marshal schemas: %v", err)
305 }
306 return string(b)
307 }
308
309 func assertExternalToolError(t *testing.T, tl tool.Tool, args map[string]any, wantTokenPath, externalRoot string) {
310 t.Helper()
311 _, err := tl.Execute(context.Background(), argsJSON(t, args))
312 if err == nil {
313 t.Fatalf("%s should fail for missing external path", tl.Name())
314 }
315 msg := err.Error()
316 if !strings.Contains(msg, wantTokenPath) {
317 t.Fatalf("%s error = %q, want token path %q", tl.Name(), msg, wantTokenPath)
318 }
319 if strings.Contains(msg, filepath.ToSlash(externalRoot)) || strings.Contains(msg, externalRoot) {
320 t.Fatalf("%s error leaked external root: %q", tl.Name(), msg)
321 }
322 }
323
323 lines GO