| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "path/filepath" |
| 5 | "strings" |
| 6 | "sync" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/netclient" |
| 10 | "reasonix/internal/sandbox" |
| 11 | "reasonix/internal/sessiontemp" |
| 12 | "reasonix/internal/tool" |
| 13 | ) |
| 14 | |
| 15 | // Workspace builds a built-in tool set bound to a working directory, so several |
| 16 | // agents can run concurrently with independent path roots — a desktop front-end |
| 17 | // opening one tab per project, say. The process working directory is global and |
| 18 | // cannot be made per-agent (os.Chdir is process-wide), so each tool instead |
| 19 | // resolves relative paths against this directory and bash runs in it. |
| 20 | // |
| 21 | // Dir is that directory (empty yields process-cwd tools, byte-identical to the |
| 22 | // compile-time built-ins). WriteRoots confines the file-writers (as |
| 23 | // ConfineWriters); when empty and Dir is set, Dir itself becomes the sole write |
| 24 | // root, so writes stay inside the project by default. ForbidReadRoots confines |
| 25 | // the read/list/search built-ins so they cannot peek at the listed directories. |
| 26 | // Bash is the OS-sandbox spec for the bash tool (as ConfineBash). SessionGuard |
| 27 | // rejects writer-tool targets inside Reasonix's own session stores and makes |
| 28 | // bash warn when a command references them (see SessionDataGuard). |
| 29 | type Workspace struct { |
| 30 | Dir string |
| 31 | WriteRoots []string |
| 32 | ForbidReadRoots []string |
| 33 | Bash sandbox.Spec |
| 34 | BashTimeout time.Duration |
| 35 | Search SearchSpec |
| 36 | ProxySpec netclient.ProxySpec |
| 37 | ReadPaths *PathResolver |
| 38 | SessionGuard SessionDataGuard |
| 39 | // ManagedConfig names the Reasonix-owned config files the file-writers may |
| 40 | // touch outside WriteRoots after a fresh per-write human approval (see |
| 41 | // ManagedConfigPaths). The zero value disables the escape hatch. |
| 42 | ManagedConfig ManagedConfigPaths |
| 43 | // FileOverlay, when non-nil, serves read_file/write_file content through the |
| 44 | // host transport (unsaved editor buffers) with disk fallback; Terminal, when |
| 45 | // non-nil, runs foreground bash in a host-owned terminal when the local OS |
| 46 | // sandbox is not enforcing. Both are nil outside host transports like ACP. |
| 47 | FileOverlay FileOverlay |
| 48 | Terminal TerminalRunner |
| 49 | // SessionTemp is the logical-session private temporary directory manager |
| 50 | // shared by bash and ripgrep-backed grep. Nil leaves those tools without a |
| 51 | // session-private temp (platform defaults apply). |
| 52 | SessionTemp *sessiontemp.Manager |
| 53 | } |
| 54 | |
| 55 | // Tools returns the built-in tools bound to the workspace, ready to Add to a |
| 56 | // per-run tool.Registry. An empty enabled list yields every built-in; otherwise |
| 57 | // only the named ones are returned (unknown names are ignored). This is the |
| 58 | // per-workspace analogue of the cli's process-cwd assembly — a desktop driver |
| 59 | // calls it once per agent instead of relying on the global working directory. |
| 60 | func (w Workspace) Tools(enabled ...string) []tool.Tool { |
| 61 | writeRoots := w.WriteRoots |
| 62 | if len(writeRoots) == 0 && w.Dir != "" { |
| 63 | writeRoots = []string{w.Dir} |
| 64 | } |
| 65 | roots := realRoots(writeRoots) |
| 66 | forbidRoots := realRoots(w.ForbidReadRoots) |
| 67 | |
| 68 | overrides := map[string]tool.Tool{ |
| 69 | "read_file": readFile{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots, overlay: w.FileOverlay}, |
| 70 | "write_file": writeFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay}, |
| 71 | "edit_file": editFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig}, |
| 72 | "multi_edit": multiEdit{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig}, |
| 73 | "move_file": moveFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig}, |
| 74 | "notebook_edit": notebookEdit{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig}, |
| 75 | "delete_range": deleteRange{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig}, |
| 76 | "delete_symbol": deleteSymbol{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig}, |
| 77 | "code_index": codeIndex{workDir: w.Dir, forbidRoots: forbidRoots}, |
| 78 | "bash": bash{workDir: w.Dir, sb: w.Bash, timeout: w.BashTimeout, guard: w.SessionGuard, terminal: w.Terminal, sessionTemp: w.SessionTemp}, |
| 79 | "ls": listDir{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots}, |
| 80 | "glob": globTool{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots}, |
| 81 | "grep": grepTool{workDir: w.Dir, paths: w.ReadPaths, rg: w.Search.RgPath, forbidRoots: forbidRoots, sb: w.Bash, sessionTemp: w.SessionTemp}, |
| 82 | "web_fetch": webFetch{proxySpec: w.ProxySpec}, |
| 83 | } |
| 84 | all := tool.Builtins() |
| 85 | if len(enabled) == 0 { |
| 86 | for i, t := range all { |
| 87 | if bound, ok := overrides[t.Name()]; ok { |
| 88 | all[i] = bound |
| 89 | } |
| 90 | } |
| 91 | return all |
| 92 | } |
| 93 | want := make(map[string]bool, len(enabled)) |
| 94 | for _, n := range enabled { |
| 95 | want[n] = true |
| 96 | } |
| 97 | out := make([]tool.Tool, 0, len(enabled)) |
| 98 | for _, t := range all { |
| 99 | if want[t.Name()] { |
| 100 | if bound, ok := overrides[t.Name()]; ok { |
| 101 | t = bound |
| 102 | } |
| 103 | out = append(out, t) |
| 104 | } |
| 105 | } |
| 106 | return out |
| 107 | } |
| 108 | |
| 109 | // resolveIn maps a tool's path/pattern argument into a working directory. With |
| 110 | // an empty workDir it returns p unchanged — the process-cwd behavior the |
| 111 | // compile-time built-ins have always had, so existing callers are unaffected. |
| 112 | // Otherwise a relative p is joined onto workDir; an absolute p is returned as-is |
| 113 | // (an explicit absolute path is honored verbatim — the write-confiner, not this, |
| 114 | // enforces the workspace boundary). An empty p resolves to workDir itself, so a |
| 115 | // defaulted "." (ls/grep) targets the workspace root. |
| 116 | func resolveIn(workDir, p string) string { |
| 117 | if workDir == "" { |
| 118 | return p |
| 119 | } |
| 120 | if p == "" || p == "." { |
| 121 | return workDir |
| 122 | } |
| 123 | if filepath.IsAbs(p) { |
| 124 | return p |
| 125 | } |
| 126 | return filepath.Join(workDir, p) |
| 127 | } |
| 128 | |
| 129 | // PathResolver maps session-authorized token paths to local read-only roots. |
| 130 | // It is intentionally used only by read tools; write tools continue to rely on |
| 131 | // WriteRoots confinement and never resolve these aliases. |
| 132 | type PathResolver struct { |
| 133 | mu sync.RWMutex |
| 134 | roots map[string]string |
| 135 | } |
| 136 | |
| 137 | // NewPathResolver returns a resolver whose root set can be updated after the |
| 138 | // workspace tools have been registered. |
| 139 | func NewPathResolver() *PathResolver { |
| 140 | return &PathResolver{roots: map[string]string{}} |
| 141 | } |
| 142 | |
| 143 | // RegisterReadRoot authorizes token and all of its local subpaths to resolve |
| 144 | // under root for read-only tools in this session. |
| 145 | func (r *PathResolver) RegisterReadRoot(token, root string) { |
| 146 | if r == nil { |
| 147 | return |
| 148 | } |
| 149 | token = normalizeReadToken(token) |
| 150 | root = filepath.Clean(strings.TrimSpace(root)) |
| 151 | if token == "" || root == "" { |
| 152 | return |
| 153 | } |
| 154 | r.mu.Lock() |
| 155 | defer r.mu.Unlock() |
| 156 | if r.roots == nil { |
| 157 | r.roots = map[string]string{} |
| 158 | } |
| 159 | r.roots[token] = root |
| 160 | } |
| 161 | |
| 162 | // Resolve maps a submitted path or pattern to a local path when it begins with |
| 163 | // a registered token. ok is false for ordinary workspace paths. |
| 164 | func (r *PathResolver) Resolve(path string) (ResolvedPath, bool) { |
| 165 | if r == nil { |
| 166 | return ResolvedPath{}, false |
| 167 | } |
| 168 | key := normalizeReadToken(path) |
| 169 | if key == "" { |
| 170 | return ResolvedPath{}, false |
| 171 | } |
| 172 | r.mu.RLock() |
| 173 | defer r.mu.RUnlock() |
| 174 | if root, ok := r.roots[key]; ok { |
| 175 | return ResolvedPath{Path: root, DisplayPath: key, Root: root, DisplayRoot: key, External: true}, true |
| 176 | } |
| 177 | for token, root := range r.roots { |
| 178 | if !strings.HasPrefix(key, token+"/") { |
| 179 | continue |
| 180 | } |
| 181 | sub, ok := cleanReadSubpath(strings.TrimPrefix(key, token+"/")) |
| 182 | if !ok { |
| 183 | return ResolvedPath{}, false |
| 184 | } |
| 185 | return ResolvedPath{ |
| 186 | Path: filepath.Join(root, filepath.FromSlash(sub)), |
| 187 | DisplayPath: token + "/" + sub, |
| 188 | Root: root, |
| 189 | DisplayRoot: token, |
| 190 | External: true, |
| 191 | }, true |
| 192 | } |
| 193 | return ResolvedPath{}, false |
| 194 | } |
| 195 | |
| 196 | // ResolvedPath carries both the local path used for I/O and the token path that |
| 197 | // should appear in tool output. |
| 198 | type ResolvedPath struct { |
| 199 | Path string |
| 200 | DisplayPath string |
| 201 | Root string |
| 202 | DisplayRoot string |
| 203 | External bool |
| 204 | } |
| 205 | |
| 206 | func (p ResolvedPath) DisplayFor(path string) string { |
| 207 | if !p.External { |
| 208 | return path |
| 209 | } |
| 210 | rel, err := filepath.Rel(p.Root, path) |
| 211 | if err != nil || !filepath.IsLocal(rel) { |
| 212 | return path |
| 213 | } |
| 214 | if rel == "." { |
| 215 | return p.DisplayRoot |
| 216 | } |
| 217 | return filepath.ToSlash(filepath.Join(p.DisplayRoot, rel)) |
| 218 | } |
| 219 | |
| 220 | func (p ResolvedPath) ErrorText(err error) string { |
| 221 | if err == nil { |
| 222 | return "" |
| 223 | } |
| 224 | msg := err.Error() |
| 225 | if !p.External { |
| 226 | return msg |
| 227 | } |
| 228 | return strings.ReplaceAll(msg, p.Root, p.DisplayRoot) |
| 229 | } |
| 230 | |
| 231 | func resolveReadablePath(workDir, path string, resolver *PathResolver) ResolvedPath { |
| 232 | if rp, ok := resolver.Resolve(path); ok { |
| 233 | return rp |
| 234 | } |
| 235 | p := resolveIn(workDir, path) |
| 236 | return ResolvedPath{Path: p, DisplayPath: p, Root: p, DisplayRoot: p} |
| 237 | } |
| 238 | |
| 239 | func normalizeReadToken(token string) string { |
| 240 | token = strings.TrimSpace(token) |
| 241 | token = strings.TrimPrefix(token, "@") |
| 242 | token = filepath.ToSlash(token) |
| 243 | token = strings.TrimRight(token, "/") |
| 244 | return token |
| 245 | } |
| 246 | |
| 247 | func cleanReadSubpath(sub string) (string, bool) { |
| 248 | sub = strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(sub)), "/") |
| 249 | if sub == "" || sub == "." { |
| 250 | return ".", true |
| 251 | } |
| 252 | cleaned := filepath.Clean(filepath.FromSlash(sub)) |
| 253 | if cleaned == "." { |
| 254 | return ".", true |
| 255 | } |
| 256 | if !filepath.IsLocal(cleaned) { |
| 257 | return "", false |
| 258 | } |
| 259 | return filepath.ToSlash(cleaned), true |
| 260 | } |
| 261 | |
| 262 | // vendorDirs are directory names grep and glob skip during a recursive walk: |
| 263 | // dependency, VCS, and build-cache trees that almost never hold the searched |
| 264 | // source and would otherwise dominate the walk (node_modules alone can be 100k+ |
| 265 | // files) and fill the result cap with noise. Only skipped when nested — a walk |
| 266 | // rooted directly at one (an explicit `grep node_modules`) still searches it. |
| 267 | var vendorDirs = map[string]bool{ |
| 268 | ".git": true, ".svn": true, ".hg": true, ".jj": true, |
| 269 | "node_modules": true, "vendor": true, ".venv": true, |
| 270 | "__pycache__": true, ".mypy_cache": true, ".pytest_cache": true, |
| 271 | } |
| 272 | |
| 273 | // skipWalkDir reports whether a directory should be pruned from a recursive walk |
| 274 | // rooted at root. The root itself is never pruned, so explicitly targeting a |
| 275 | // vendor dir still works. |
| 276 | func skipWalkDir(root, path, name string) bool { |
| 277 | if path == root { |
| 278 | return false |
| 279 | } |
| 280 | return vendorDirs[name] || isProtectedDir(absClean(path)) |
| 281 | } |
| 282 | |
| 283 | // skipForbidDir reports whether a directory should be pruned from a recursive |
| 284 | // walk because it is within any forbid-read root. forbidRoots are pre-resolved |
| 285 | // absolute paths; empty means unconfined. |
| 286 | func skipForbidDir(path string, forbidRoots []string) bool { |
| 287 | return confineRead(forbidRoots, path) |
| 288 | } |
| 289 |