返回 DeepSeek-Reasonix
workspace.go
根目录 / internal / tool / builtin / workspace.go
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 // WriteRootSet is the live writable-root manager. When set, file writers
40 // and bash read Baseline+Session+per-call roots from it instead of a
41 // static WriteRoots snapshot.
42 WriteRootSet *sandbox.WritableRootSet
43 // ManagedConfig names the Reasonix-owned config files the file-writers may
44 // touch outside WriteRoots after a fresh per-write human approval (see
45 // ManagedConfigPaths). The zero value disables the escape hatch.
46 ManagedConfig ManagedConfigPaths
47 // FileOverlay, when non-nil, serves every file tool's content through the
48 // host transport (unsaved editor buffers) with disk fallback; Terminal, when
49 // non-nil, runs foreground bash in a host-owned terminal when the local OS
50 // sandbox is not enforcing. Both are nil outside host transports like ACP.
51 FileOverlay FileOverlay
52 Terminal TerminalRunner
53 // SessionTemp is the logical-session private temporary directory manager
54 // shared by bash and ripgrep-backed grep. Nil leaves those tools without a
55 // session-private temp (platform defaults apply).
56 SessionTemp *sessiontemp.Manager
57 // FileWriteReceipt receives prior-state evidence after a successful
58 // write_file mutation. It is instance-scoped so concurrent runtimes never
59 // record into another session's recovery ledger.
60 FileWriteReceipt func(path string, hadPrior bool, prior []byte)
61 }
62
63 // Tools returns the built-in tools bound to the workspace, ready to Add to a
64 // per-run tool.Registry. An empty enabled list yields every built-in; otherwise
65 // only the named ones are returned (unknown names are ignored). This is the
66 // per-workspace analogue of the cli's process-cwd assembly — a desktop driver
67 // calls it once per agent instead of relying on the global working directory.
68 func (w Workspace) Tools(enabled ...string) []tool.Tool {
69 writeRoots := w.WriteRoots
70 if len(writeRoots) == 0 && w.Dir != "" {
71 writeRoots = []string{w.Dir}
72 }
73 roots := realRoots(writeRoots)
74 forbidRoots := realRoots(w.ForbidReadRoots)
75
76 shell := w.Bash.Shell
77 if shell.Path == "" {
78 shell = sandbox.ResolveShell("", "", nil)
79 }
80 shellTool := bash{workDir: w.Dir, sb: w.Bash, shell: shell, timeout: w.BashTimeout, guard: w.SessionGuard, terminal: w.Terminal, sessionTemp: w.SessionTemp}
81 if shell.Kind == sandbox.ShellPowerShell {
82 shellTool.name = "pwsh"
83 }
84 legacyShell := tool.Tool(shellTool)
85 if shellTool.Name() == "pwsh" {
86 legacyShell, _ = AliasBash(shellTool, "bash")
87 }
88 overrides := map[string]tool.Tool{
89 "view_image": viewImage{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots},
90 "present": present{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots},
91 "read_file": readFile{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots, overlay: w.FileOverlay},
92 "write_file": writeFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay, receipt: w.FileWriteReceipt},
93 "edit_file": editFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay},
94 "multi_edit": multiEdit{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay},
95 "move_file": moveFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig},
96 "notebook_edit": notebookEdit{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay},
97 "delete_range": deleteRange{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay},
98 "delete_symbol": deleteSymbol{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay},
99 "code_index": codeIndex{workDir: w.Dir, forbidRoots: forbidRoots},
100 "bash": legacyShell,
101 "pwsh": shellTool,
102 "ls": listDir{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots},
103 "glob": globTool{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots},
104 "grep": grepTool{workDir: w.Dir, paths: w.ReadPaths, rg: w.Search.RgPath, forbidRoots: forbidRoots, sb: w.Bash, sessionTemp: w.SessionTemp, overlay: w.FileOverlay},
105 "web_fetch": webFetch{proxySpec: w.ProxySpec},
106 }
107 all := tool.Builtins()
108 if len(enabled) == 0 {
109 for i, t := range all {
110 if bound, ok := overrides[t.Name()]; ok {
111 all[i] = BindWriteRootSet(bound, w.WriteRootSet)
112 }
113 }
114 if shellTool.Name() == "pwsh" {
115 primary := BindWriteRootSet(shellTool, w.WriteRootSet)
116 withoutLegacy := all[:0]
117 for _, t := range all {
118 if t.Name() != "bash" {
119 withoutLegacy = append(withoutLegacy, t)
120 }
121 }
122 all = append(withoutLegacy, primary)
123 }
124 return all
125 }
126 want := make(map[string]bool, len(enabled))
127 for _, n := range enabled {
128 if tool.IsShellToolName(n) {
129 n = "bash"
130 }
131 want[n] = true
132 }
133 out := make([]tool.Tool, 0, len(enabled))
134 for _, t := range all {
135 if want[t.Name()] {
136 if shellTool.Name() == "pwsh" && t.Name() == "bash" {
137 continue
138 }
139 if bound, ok := overrides[t.Name()]; ok {
140 t = BindWriteRootSet(bound, w.WriteRootSet)
141 }
142 out = append(out, t)
143 }
144 }
145 if shellTool.Name() == "pwsh" && want["bash"] {
146 primary := BindWriteRootSet(shellTool, w.WriteRootSet)
147 out = append(out, primary)
148 }
149 return out
150 }
151
152 // resolveIn maps a tool's path/pattern argument into a working directory. With
153 // an empty workDir it returns p unchanged — the process-cwd behavior the
154 // compile-time built-ins have always had, so existing callers are unaffected.
155 // Otherwise a relative p is joined onto workDir; an absolute p is returned as-is
156 // (an explicit absolute path is honored verbatim — the write-confiner, not this,
157 // enforces the workspace boundary). An empty p resolves to workDir itself, so a
158 // defaulted "." (ls/grep) targets the workspace root.
159 func resolveIn(workDir, p string) string {
160 if workDir == "" {
161 return p
162 }
163 if p == "" || p == "." {
164 return workDir
165 }
166 if filepath.IsAbs(p) {
167 return p
168 }
169 return filepath.Join(workDir, p)
170 }
171
172 // PathResolver maps session-authorized token paths to local read-only roots.
173 // It is intentionally used only by read tools; write tools continue to rely on
174 // WriteRoots confinement and never resolve these aliases.
175 type PathResolver struct {
176 mu sync.RWMutex
177 roots map[string]string
178 }
179
180 // NewPathResolver returns a resolver whose root set can be updated after the
181 // workspace tools have been registered.
182 func NewPathResolver() *PathResolver {
183 return &PathResolver{roots: map[string]string{}}
184 }
185
186 // RegisterReadRoot authorizes token and all of its local subpaths to resolve
187 // under root for read-only tools in this session.
188 func (r *PathResolver) RegisterReadRoot(token, root string) {
189 if r == nil {
190 return
191 }
192 token = normalizeReadToken(token)
193 root = filepath.Clean(strings.TrimSpace(root))
194 if token == "" || root == "" {
195 return
196 }
197 r.mu.Lock()
198 defer r.mu.Unlock()
199 if r.roots == nil {
200 r.roots = map[string]string{}
201 }
202 r.roots[token] = root
203 }
204
205 // Resolve maps a submitted path or pattern to a local path when it begins with
206 // a registered token. ok is false for ordinary workspace paths.
207 func (r *PathResolver) Resolve(path string) (ResolvedPath, bool) {
208 if r == nil {
209 return ResolvedPath{}, false
210 }
211 key := normalizeReadToken(path)
212 if key == "" {
213 return ResolvedPath{}, false
214 }
215 r.mu.RLock()
216 defer r.mu.RUnlock()
217 if root, ok := r.roots[key]; ok {
218 return ResolvedPath{Path: root, DisplayPath: key, Root: root, DisplayRoot: key, External: true}, true
219 }
220 for token, root := range r.roots {
221 if !strings.HasPrefix(key, token+"/") {
222 continue
223 }
224 sub, ok := cleanReadSubpath(strings.TrimPrefix(key, token+"/"))
225 if !ok {
226 return ResolvedPath{}, false
227 }
228 return ResolvedPath{
229 Path: filepath.Join(root, filepath.FromSlash(sub)),
230 DisplayPath: token + "/" + sub,
231 Root: root,
232 DisplayRoot: token,
233 External: true,
234 }, true
235 }
236 return ResolvedPath{}, false
237 }
238
239 // ResolvedPath carries both the local path used for I/O and the token path that
240 // should appear in tool output.
241 type ResolvedPath struct {
242 Path string
243 DisplayPath string
244 Root string
245 DisplayRoot string
246 External bool
247 }
248
249 func (p ResolvedPath) DisplayFor(path string) string {
250 if !p.External {
251 return path
252 }
253 rel, err := filepath.Rel(p.Root, path)
254 if err != nil || !filepath.IsLocal(rel) {
255 return path
256 }
257 if rel == "." {
258 return p.DisplayRoot
259 }
260 return filepath.ToSlash(filepath.Join(p.DisplayRoot, rel))
261 }
262
263 func (p ResolvedPath) ErrorText(err error) string {
264 if err == nil {
265 return ""
266 }
267 msg := err.Error()
268 if !p.External {
269 return msg
270 }
271 return strings.ReplaceAll(msg, p.Root, p.DisplayRoot)
272 }
273
274 func resolveReadablePath(workDir, path string, resolver *PathResolver) ResolvedPath {
275 if rp, ok := resolver.Resolve(path); ok {
276 return rp
277 }
278 p := resolveIn(workDir, path)
279 return ResolvedPath{Path: p, DisplayPath: p, Root: p, DisplayRoot: p}
280 }
281
282 func normalizeReadToken(token string) string {
283 token = strings.TrimSpace(token)
284 token = strings.TrimPrefix(token, "@")
285 token = filepath.ToSlash(token)
286 token = strings.TrimRight(token, "/")
287 return token
288 }
289
290 func cleanReadSubpath(sub string) (string, bool) {
291 sub = strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(sub)), "/")
292 if sub == "" || sub == "." {
293 return ".", true
294 }
295 cleaned := filepath.Clean(filepath.FromSlash(sub))
296 if cleaned == "." {
297 return ".", true
298 }
299 if !filepath.IsLocal(cleaned) {
300 return "", false
301 }
302 return filepath.ToSlash(cleaned), true
303 }
304
305 // vendorDirs are directory names grep and glob skip during a recursive walk:
306 // dependency, VCS, and build-cache trees that almost never hold the searched
307 // source and would otherwise dominate the walk (node_modules alone can be 100k+
308 // files) and fill the result cap with noise. Only skipped when nested — a walk
309 // rooted directly at one (an explicit `grep node_modules`) still searches it.
310 var vendorDirs = map[string]bool{
311 ".git": true, ".svn": true, ".hg": true, ".jj": true,
312 "node_modules": true, "vendor": true, ".venv": true,
313 "__pycache__": true, ".mypy_cache": true, ".pytest_cache": true,
314 }
315
316 // skipWalkDir reports whether a directory should be pruned from a recursive walk
317 // rooted at root. The root itself is never pruned, so explicitly targeting a
318 // vendor dir still works.
319 func skipWalkDir(root, path, name string) bool {
320 if path == root {
321 return false
322 }
323 return vendorDirs[name] || isProtectedDir(absClean(path))
324 }
325
326 // skipForbidDir reports whether a directory should be pruned from a recursive
327 // walk because it is within any forbid-read root. forbidRoots are pre-resolved
328 // absolute paths; empty means unconfined.
329 func skipForbidDir(path string, forbidRoots []string) bool {
330 return confineRead(forbidRoots, path)
331 }
332
332 lines GO