284 lines
9.9 KiB
Go
284 lines
9.9 KiB
Go
package builtin
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"reasonix/internal/netclient"
|
|
"reasonix/internal/sandbox"
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
// Workspace builds a built-in tool set bound to a working directory, so several
|
|
// agents can run concurrently with independent path roots — a desktop front-end
|
|
// opening one tab per project, say. The process working directory is global and
|
|
// cannot be made per-agent (os.Chdir is process-wide), so each tool instead
|
|
// resolves relative paths against this directory and bash runs in it.
|
|
//
|
|
// Dir is that directory (empty yields process-cwd tools, byte-identical to the
|
|
// compile-time built-ins). WriteRoots confines the file-writers (as
|
|
// ConfineWriters); when empty and Dir is set, Dir itself becomes the sole write
|
|
// root, so writes stay inside the project by default. ForbidReadRoots confines
|
|
// the read/list/search built-ins so they cannot peek at the listed directories.
|
|
// Bash is the OS-sandbox spec for the bash tool (as ConfineBash). SessionGuard
|
|
// rejects writer-tool targets inside Reasonix's own session stores and makes
|
|
// bash warn when a command references them (see SessionDataGuard).
|
|
type Workspace struct {
|
|
Dir string
|
|
WriteRoots []string
|
|
ForbidReadRoots []string
|
|
Bash sandbox.Spec
|
|
BashTimeout time.Duration
|
|
Search SearchSpec
|
|
ProxySpec netclient.ProxySpec
|
|
ReadPaths *PathResolver
|
|
SessionGuard SessionDataGuard
|
|
// ManagedConfig names the Reasonix-owned config files the file-writers may
|
|
// touch outside WriteRoots after a fresh per-write human approval (see
|
|
// ManagedConfigPaths). The zero value disables the escape hatch.
|
|
ManagedConfig ManagedConfigPaths
|
|
// FileOverlay, when non-nil, serves read_file/write_file content through the
|
|
// host transport (unsaved editor buffers) with disk fallback; Terminal, when
|
|
// non-nil, runs foreground bash in a host-owned terminal when the local OS
|
|
// sandbox is not enforcing. Both are nil outside host transports like ACP.
|
|
FileOverlay FileOverlay
|
|
Terminal TerminalRunner
|
|
}
|
|
|
|
// Tools returns the built-in tools bound to the workspace, ready to Add to a
|
|
// per-run tool.Registry. An empty enabled list yields every built-in; otherwise
|
|
// only the named ones are returned (unknown names are ignored). This is the
|
|
// per-workspace analogue of the cli's process-cwd assembly — a desktop driver
|
|
// calls it once per agent instead of relying on the global working directory.
|
|
func (w Workspace) Tools(enabled ...string) []tool.Tool {
|
|
writeRoots := w.WriteRoots
|
|
if len(writeRoots) == 0 && w.Dir != "" {
|
|
writeRoots = []string{w.Dir}
|
|
}
|
|
roots := realRoots(writeRoots)
|
|
forbidRoots := realRoots(w.ForbidReadRoots)
|
|
|
|
overrides := map[string]tool.Tool{
|
|
"read_file": readFile{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots, overlay: w.FileOverlay},
|
|
"write_file": writeFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig, overlay: w.FileOverlay},
|
|
"edit_file": editFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig},
|
|
"multi_edit": multiEdit{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig},
|
|
"move_file": moveFile{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig},
|
|
"notebook_edit": notebookEdit{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig},
|
|
"delete_range": deleteRange{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig},
|
|
"delete_symbol": deleteSymbol{workDir: w.Dir, roots: roots, guard: w.SessionGuard, managed: w.ManagedConfig},
|
|
"code_index": codeIndex{workDir: w.Dir, forbidRoots: forbidRoots},
|
|
"bash": bash{workDir: w.Dir, sb: w.Bash, timeout: w.BashTimeout, guard: w.SessionGuard, terminal: w.Terminal},
|
|
"ls": listDir{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots},
|
|
"glob": globTool{workDir: w.Dir, paths: w.ReadPaths, forbidRoots: forbidRoots},
|
|
"grep": grepTool{workDir: w.Dir, paths: w.ReadPaths, rg: w.Search.RgPath, forbidRoots: forbidRoots, sb: w.Bash},
|
|
"web_fetch": webFetch{proxySpec: w.ProxySpec},
|
|
}
|
|
all := tool.Builtins()
|
|
if len(enabled) == 0 {
|
|
for i, t := range all {
|
|
if bound, ok := overrides[t.Name()]; ok {
|
|
all[i] = bound
|
|
}
|
|
}
|
|
return all
|
|
}
|
|
want := make(map[string]bool, len(enabled))
|
|
for _, n := range enabled {
|
|
want[n] = true
|
|
}
|
|
out := make([]tool.Tool, 0, len(enabled))
|
|
for _, t := range all {
|
|
if want[t.Name()] {
|
|
if bound, ok := overrides[t.Name()]; ok {
|
|
t = bound
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// resolveIn maps a tool's path/pattern argument into a working directory. With
|
|
// an empty workDir it returns p unchanged — the process-cwd behavior the
|
|
// compile-time built-ins have always had, so existing callers are unaffected.
|
|
// Otherwise a relative p is joined onto workDir; an absolute p is returned as-is
|
|
// (an explicit absolute path is honored verbatim — the write-confiner, not this,
|
|
// enforces the workspace boundary). An empty p resolves to workDir itself, so a
|
|
// defaulted "." (ls/grep) targets the workspace root.
|
|
func resolveIn(workDir, p string) string {
|
|
if workDir == "" {
|
|
return p
|
|
}
|
|
if p == "" || p == "." {
|
|
return workDir
|
|
}
|
|
if filepath.IsAbs(p) {
|
|
return p
|
|
}
|
|
return filepath.Join(workDir, p)
|
|
}
|
|
|
|
// PathResolver maps session-authorized token paths to local read-only roots.
|
|
// It is intentionally used only by read tools; write tools continue to rely on
|
|
// WriteRoots confinement and never resolve these aliases.
|
|
type PathResolver struct {
|
|
mu sync.RWMutex
|
|
roots map[string]string
|
|
}
|
|
|
|
// NewPathResolver returns a resolver whose root set can be updated after the
|
|
// workspace tools have been registered.
|
|
func NewPathResolver() *PathResolver {
|
|
return &PathResolver{roots: map[string]string{}}
|
|
}
|
|
|
|
// RegisterReadRoot authorizes token and all of its local subpaths to resolve
|
|
// under root for read-only tools in this session.
|
|
func (r *PathResolver) RegisterReadRoot(token, root string) {
|
|
if r == nil {
|
|
return
|
|
}
|
|
token = normalizeReadToken(token)
|
|
root = filepath.Clean(strings.TrimSpace(root))
|
|
if token == "" || root == "" {
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.roots == nil {
|
|
r.roots = map[string]string{}
|
|
}
|
|
r.roots[token] = root
|
|
}
|
|
|
|
// Resolve maps a submitted path or pattern to a local path when it begins with
|
|
// a registered token. ok is false for ordinary workspace paths.
|
|
func (r *PathResolver) Resolve(path string) (ResolvedPath, bool) {
|
|
if r == nil {
|
|
return ResolvedPath{}, false
|
|
}
|
|
key := normalizeReadToken(path)
|
|
if key == "" {
|
|
return ResolvedPath{}, false
|
|
}
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
if root, ok := r.roots[key]; ok {
|
|
return ResolvedPath{Path: root, DisplayPath: key, Root: root, DisplayRoot: key, External: true}, true
|
|
}
|
|
for token, root := range r.roots {
|
|
if !strings.HasPrefix(key, token+"/") {
|
|
continue
|
|
}
|
|
sub, ok := cleanReadSubpath(strings.TrimPrefix(key, token+"/"))
|
|
if !ok {
|
|
return ResolvedPath{}, false
|
|
}
|
|
return ResolvedPath{
|
|
Path: filepath.Join(root, filepath.FromSlash(sub)),
|
|
DisplayPath: token + "/" + sub,
|
|
Root: root,
|
|
DisplayRoot: token,
|
|
External: true,
|
|
}, true
|
|
}
|
|
return ResolvedPath{}, false
|
|
}
|
|
|
|
// ResolvedPath carries both the local path used for I/O and the token path that
|
|
// should appear in tool output.
|
|
type ResolvedPath struct {
|
|
Path string
|
|
DisplayPath string
|
|
Root string
|
|
DisplayRoot string
|
|
External bool
|
|
}
|
|
|
|
func (p ResolvedPath) DisplayFor(path string) string {
|
|
if !p.External {
|
|
return path
|
|
}
|
|
rel, err := filepath.Rel(p.Root, path)
|
|
if err != nil || !filepath.IsLocal(rel) {
|
|
return path
|
|
}
|
|
if rel == "." {
|
|
return p.DisplayRoot
|
|
}
|
|
return filepath.ToSlash(filepath.Join(p.DisplayRoot, rel))
|
|
}
|
|
|
|
func (p ResolvedPath) ErrorText(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
msg := err.Error()
|
|
if !p.External {
|
|
return msg
|
|
}
|
|
return strings.ReplaceAll(msg, p.Root, p.DisplayRoot)
|
|
}
|
|
|
|
func resolveReadablePath(workDir, path string, resolver *PathResolver) ResolvedPath {
|
|
if rp, ok := resolver.Resolve(path); ok {
|
|
return rp
|
|
}
|
|
p := resolveIn(workDir, path)
|
|
return ResolvedPath{Path: p, DisplayPath: p, Root: p, DisplayRoot: p}
|
|
}
|
|
|
|
func normalizeReadToken(token string) string {
|
|
token = strings.TrimSpace(token)
|
|
token = strings.TrimPrefix(token, "@")
|
|
token = filepath.ToSlash(token)
|
|
token = strings.TrimRight(token, "/")
|
|
return token
|
|
}
|
|
|
|
func cleanReadSubpath(sub string) (string, bool) {
|
|
sub = strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(sub)), "/")
|
|
if sub == "" || sub == "." {
|
|
return ".", true
|
|
}
|
|
cleaned := filepath.Clean(filepath.FromSlash(sub))
|
|
if cleaned == "." {
|
|
return ".", true
|
|
}
|
|
if !filepath.IsLocal(cleaned) {
|
|
return "", false
|
|
}
|
|
return filepath.ToSlash(cleaned), true
|
|
}
|
|
|
|
// vendorDirs are directory names grep and glob skip during a recursive walk:
|
|
// dependency, VCS, and build-cache trees that almost never hold the searched
|
|
// source and would otherwise dominate the walk (node_modules alone can be 100k+
|
|
// files) and fill the result cap with noise. Only skipped when nested — a walk
|
|
// rooted directly at one (an explicit `grep node_modules`) still searches it.
|
|
var vendorDirs = map[string]bool{
|
|
".git": true, ".svn": true, ".hg": true, ".jj": true,
|
|
"node_modules": true, "vendor": true, ".venv": true,
|
|
"__pycache__": true, ".mypy_cache": true, ".pytest_cache": true,
|
|
}
|
|
|
|
// skipWalkDir reports whether a directory should be pruned from a recursive walk
|
|
// rooted at root. The root itself is never pruned, so explicitly targeting a
|
|
// vendor dir still works.
|
|
func skipWalkDir(root, path, name string) bool {
|
|
if path == root {
|
|
return false
|
|
}
|
|
return vendorDirs[name] || isProtectedDir(absClean(path))
|
|
}
|
|
|
|
// skipForbidDir reports whether a directory should be pruned from a recursive
|
|
// walk because it is within any forbid-read root. forbidRoots are pre-resolved
|
|
// absolute paths; empty means unconfined.
|
|
func skipForbidDir(path string, forbidRoots []string) bool {
|
|
return confineRead(forbidRoots, path)
|
|
}
|