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