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