返回 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 // 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
289 lines GO