dsh-remote-workspaces 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/README.zh.md +151 -0
- package/cordis.patch.yml +25 -0
- package/package.json +44 -0
- package/src/anchor.js +67 -0
- package/src/client.js +756 -0
- package/src/containment.js +71 -0
- package/src/errors.js +17 -0
- package/src/fs-sftp.js +231 -0
- package/src/index.js +327 -0
- package/src/local-backend.js +224 -0
- package/src/machine-store.js +238 -0
- package/src/registry.js +76 -0
- package/src/routing-fs.js +230 -0
- package/src/search.js +286 -0
- package/src/shell-exec.js +423 -0
- package/src/ssh-config.js +52 -0
- package/src/ssh-uri.js +19 -0
- package/src/transport.js +401 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing filesystem: the plugin's `ctx.fs` provider.
|
|
3
|
+
*
|
|
4
|
+
* Routes by the session cwd. Two remote triggers:
|
|
5
|
+
* - an `ssh://[user@]host[:port]/path` cwd (URI form), and
|
|
6
|
+
* - a LOCAL anchor directory registered in the remote-workspace registry
|
|
7
|
+
* (`anchors.json`), whose real content lives on the remote host.
|
|
8
|
+
* Everything else goes to the local backend (with the workspace-write fence).
|
|
9
|
+
*
|
|
10
|
+
* The world identity is ENCODED into the target key (`ssh://host/path` for
|
|
11
|
+
* remote, bare absolute path for local) and decoded on every later operation.
|
|
12
|
+
*
|
|
13
|
+
* Self-contained: it does not extend `@deepseek-ai/dsh-fs`'s `FileSystem`
|
|
14
|
+
* (whose base class is a Service marker plus a `sandboxMode` getter only), so
|
|
15
|
+
* the bundle resolves with no harness dependency. Consumers read the service
|
|
16
|
+
* structurally (`ctx.fs.resolve/readText/…` and `ctx.fs.sandboxMode`), never
|
|
17
|
+
* via `instanceof`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { isAbsolute, posix } from 'node:path'
|
|
21
|
+
import { LocalBackend } from './local-backend.js'
|
|
22
|
+
import { SftpBackend } from './fs-sftp.js'
|
|
23
|
+
import { isRemoteCwd, parseSshUri } from './ssh-uri.js'
|
|
24
|
+
import { findByCwd } from './registry.js'
|
|
25
|
+
import { fsError } from './errors.js'
|
|
26
|
+
|
|
27
|
+
export class RoutingFileSystem {
|
|
28
|
+
constructor({ getPolicy, clientForRemote } = {}) {
|
|
29
|
+
this.getPolicy = getPolicy
|
|
30
|
+
this.local = new LocalBackend({ getPolicy })
|
|
31
|
+
this.clientForRemote = clientForRemote
|
|
32
|
+
this.remoteBackends = new Map()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Report a DEFINED `sandboxMode` so the tool layer treats this provider as
|
|
37
|
+
* confining: it resolves the per-session policy, stamps every mutation with
|
|
38
|
+
* it, and advertises escalation. Only definedness is read — the value itself
|
|
39
|
+
* is a stand-in; the real per-call mode rides `sandboxPolicy`. The local
|
|
40
|
+
* half fences writes in `LocalBackend.checkedTarget`; the remote half is
|
|
41
|
+
* fenced here (`read-only` denies, `workspace-write` contains to the remote
|
|
42
|
+
* workspace root, `danger-full-access` delegates). The SSH account's own
|
|
43
|
+
* permissions remain the outer boundary.
|
|
44
|
+
*/
|
|
45
|
+
get sandboxMode() {
|
|
46
|
+
return 'workspace-write'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
remoteBackend(host, user, port) {
|
|
50
|
+
const key = `${user ?? ''}@${host}:${port ?? 22}`
|
|
51
|
+
if (!this.remoteBackends.has(key)) {
|
|
52
|
+
this.remoteBackends.set(key, new SftpBackend(this.clientForRemote(host, user, port)))
|
|
53
|
+
}
|
|
54
|
+
return this.remoteBackends.get(key)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the remote execution-world cwd for a session cwd: either the
|
|
59
|
+
* `ssh://` URI form or a registered anchor directory. Returns
|
|
60
|
+
* `{ host, user, port, remoteCwd }` or null for a local cwd.
|
|
61
|
+
*/
|
|
62
|
+
remoteCwd(cwd) {
|
|
63
|
+
if (isRemoteCwd(cwd)) {
|
|
64
|
+
const parsed = parseSshUri(cwd)
|
|
65
|
+
if (parsed !== null) return { host: parsed.host, user: parsed.user, port: parsed.port, remoteCwd: parsed.path }
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
const hit = findByCwd(cwd)
|
|
69
|
+
if (hit === undefined) return null
|
|
70
|
+
const remoteCwd = hit.remoteSubpath === '' ? hit.remotePath : posix.join(hit.remotePath, hit.remoteSubpath)
|
|
71
|
+
return { host: hit.host, user: hit.user, port: hit.port, remoteCwd }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
encodeTarget(host, user, port, subKey) {
|
|
75
|
+
return `ssh://${user ? `${user}@` : ''}${host}${port ? `:${port}` : ''}${subKey}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Decode a target key into { backend, target } using the encoded world prefix. */
|
|
79
|
+
splitTarget(target) {
|
|
80
|
+
const key = String(target.targetKey)
|
|
81
|
+
if (key.startsWith('ssh://')) {
|
|
82
|
+
const parsed = parseSshUri(key)
|
|
83
|
+
if (parsed !== null) {
|
|
84
|
+
return {
|
|
85
|
+
backend: this.remoteBackend(parsed.host, parsed.user, parsed.port),
|
|
86
|
+
target: { targetKey: parsed.path, displayPath: target.displayPath ?? parsed.path },
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { backend: this.local, target }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async resolve(path, opts) {
|
|
94
|
+
const cwd = opts && opts.cwd
|
|
95
|
+
const remote = this.remoteCwd(cwd)
|
|
96
|
+
if (remote !== null) {
|
|
97
|
+
const backend = this.remoteBackend(remote.host, remote.user, remote.port)
|
|
98
|
+
const sub = await backend.resolve(path, { cwd: remote.remoteCwd })
|
|
99
|
+
return {
|
|
100
|
+
targetKey: this.encodeTarget(remote.host, remote.user, remote.port, sub.targetKey),
|
|
101
|
+
displayPath: sub.displayPath,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return this.local.resolve(path, opts)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
processPath(target) {
|
|
108
|
+
const { backend, target: sub } = this.splitTarget(target)
|
|
109
|
+
return backend.processPath(sub)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
processPathFromHostPath(hostPath) {
|
|
113
|
+
// Attachments and other host-owned files live in the LOCAL world; the
|
|
114
|
+
// remote world has no host path. Mirror `fs-local`: absolute identity.
|
|
115
|
+
return isAbsolute(hostPath) ? hostPath : undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fileUrl(target) {
|
|
119
|
+
const key = String(target.targetKey)
|
|
120
|
+
if (key.startsWith('ssh://')) return key
|
|
121
|
+
return this.local.fileUrl(target)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
contains(parent, child) {
|
|
125
|
+
const p = this.splitTarget(parent)
|
|
126
|
+
const c = this.splitTarget(child)
|
|
127
|
+
if (p.backend !== c.backend) return false
|
|
128
|
+
return p.backend.contains(p.target, c.target)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
stat(target) {
|
|
132
|
+
const { backend, target: sub } = this.splitTarget(target)
|
|
133
|
+
return backend.stat(sub)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
lstat(path, opts, signal) {
|
|
137
|
+
const cwd = opts && opts.cwd
|
|
138
|
+
const remote = this.remoteCwd(cwd)
|
|
139
|
+
if (remote !== null) {
|
|
140
|
+
const backend = this.remoteBackend(remote.host, remote.user, remote.port)
|
|
141
|
+
return backend.lstat(path, { cwd: remote.remoteCwd })
|
|
142
|
+
}
|
|
143
|
+
return this.local.lstat(path, opts)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
readText(target) {
|
|
147
|
+
const { backend, target: sub } = this.splitTarget(target)
|
|
148
|
+
return backend.readText(sub)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
streamText(target) {
|
|
152
|
+
const { backend, target: sub } = this.splitTarget(target)
|
|
153
|
+
return backend.streamText(sub)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
readBytes(target, signal, maxBytes) {
|
|
157
|
+
const { backend, target: sub } = this.splitTarget(target)
|
|
158
|
+
return backend.readBytes(sub, signal, maxBytes)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async listDir(target) {
|
|
162
|
+
const key = String(target.targetKey)
|
|
163
|
+
const isRemote = key.startsWith('ssh://')
|
|
164
|
+
const { backend, target: sub } = this.splitTarget(target)
|
|
165
|
+
const entries = await backend.listDir(sub)
|
|
166
|
+
if (!isRemote) return entries
|
|
167
|
+
// Re-encode child target keys as `ssh://…` so later ops on them route back
|
|
168
|
+
// to the SFTP backend (the backend returns bare POSIX keys).
|
|
169
|
+
const parsed = parseSshUri(key)
|
|
170
|
+
return entries.map((e) => ({
|
|
171
|
+
...e,
|
|
172
|
+
target: {
|
|
173
|
+
targetKey: this.encodeTarget(parsed.host, parsed.user, parsed.port, e.target.targetKey),
|
|
174
|
+
displayPath: e.target.displayPath,
|
|
175
|
+
},
|
|
176
|
+
}))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** POSIX containment: `path` is `root` or a descendant of it. */
|
|
180
|
+
posixUnder(path, root) {
|
|
181
|
+
const rel = posix.relative(root, path)
|
|
182
|
+
return rel === '' || (rel !== '..' && !rel.startsWith('../') && !posix.isAbsolute(rel))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Enforce the per-call sandbox policy on a REMOTE mutation (the local half
|
|
187
|
+
* fences itself in `LocalBackend.checkedTarget`). `read-only` denies;
|
|
188
|
+
* `workspace-write` contains the target under the session's remote workspace
|
|
189
|
+
* root (the anchor's remote origin, plus the POSIX temp dirs); a
|
|
190
|
+
* `danger-full-access` policy — or none — delegates unfenced. The target key
|
|
191
|
+
* is already SFTP-canonicalized by `resolve`, so no re-resolve is needed.
|
|
192
|
+
*/
|
|
193
|
+
remoteCheckedTarget(sub, sandboxPolicy) {
|
|
194
|
+
const policy = sandboxPolicy ?? this.getPolicy?.()?.resolve?.()
|
|
195
|
+
if (policy === undefined) return sub
|
|
196
|
+
const { mode } = policy
|
|
197
|
+
if (mode === 'danger-full-access') return sub
|
|
198
|
+
if (mode === 'read-only') {
|
|
199
|
+
throw fsError('FS_SANDBOX_DENIED', `cannot write "${sub.displayPath}": file access denied under read-only mode`)
|
|
200
|
+
}
|
|
201
|
+
// workspace-write: the policy's workspace root is the LOCAL anchor path;
|
|
202
|
+
// its remote origin is the containment boundary.
|
|
203
|
+
const hit = findByCwd(policy.workspaceRoot)
|
|
204
|
+
const remoteRoot = hit === undefined
|
|
205
|
+
? undefined
|
|
206
|
+
: (hit.remoteSubpath === '' ? hit.remotePath : posix.join(hit.remotePath, hit.remoteSubpath))
|
|
207
|
+
if (remoteRoot === undefined) {
|
|
208
|
+
throw fsError('FS_SANDBOX_DENIED', `cannot write "${sub.displayPath}": file access denied under workspace-write mode`)
|
|
209
|
+
}
|
|
210
|
+
const writable = [remoteRoot, '/tmp', '/var/tmp']
|
|
211
|
+
if (!writable.some((root) => this.posixUnder(sub.targetKey, root))) {
|
|
212
|
+
throw fsError('FS_SANDBOX_DENIED', `cannot write "${sub.displayPath}": file access denied under workspace-write mode`)
|
|
213
|
+
}
|
|
214
|
+
return sub
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
writeText(target, content, expected, signal, sandboxPolicy) {
|
|
218
|
+
const { backend, target: sub } = this.splitTarget(target)
|
|
219
|
+
if (backend === this.local) return backend.writeText(sub, content, expected, signal, sandboxPolicy)
|
|
220
|
+
return backend.writeText(this.remoteCheckedTarget(sub, sandboxPolicy), content, expected)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
editText(target, edit, expected, signal, sandboxPolicy) {
|
|
224
|
+
const { backend, target: sub } = this.splitTarget(target)
|
|
225
|
+
if (backend === this.local) return backend.editText(sub, edit, expected, signal, sandboxPolicy)
|
|
226
|
+
return backend.editText(this.remoteCheckedTarget(sub, sandboxPolicy), edit, expected)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export default RoutingFileSystem
|
package/src/search.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote-aware `grep` / `glob` tools (replace the harness's ripgrep-based
|
|
3
|
+
* `tool-fs-search`, which spawns the LOCAL ripgrep against the LOCAL session
|
|
4
|
+
* cwd and therefore only ever sees the empty remote-workspace anchor).
|
|
5
|
+
*
|
|
6
|
+
* These tools run ripgrep in the SAME world as the session cwd: a registered
|
|
7
|
+
* remote anchor runs `rg` over ssh2 exec on the remote host (only matches/file
|
|
8
|
+
* paths come back), while a local cwd spawns the system `rg` through
|
|
9
|
+
* `ctx.subprocess` (plain argv — no shell quoting). The schemas and output
|
|
10
|
+
* shapes mirror the built-in `grep`/`glob` so the model is unaware.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { posix } from 'node:path'
|
|
14
|
+
import { shellQuote } from './transport.js'
|
|
15
|
+
import { findByCwd } from './registry.js'
|
|
16
|
+
|
|
17
|
+
const GREP_MAX_MATCHES = 250
|
|
18
|
+
const GLOB_MAX_RESULTS = 1000
|
|
19
|
+
const RAW_OUTPUT_MAX_BYTES = 20 * 1024 * 1024
|
|
20
|
+
const SEARCH_TIMEOUT_MS = 30_000
|
|
21
|
+
const STDERR_MAX_BYTES = 64 * 1024
|
|
22
|
+
const GRACE_MS = 3_000
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// ripgrep argv (mirrors @deepseek-ai/dsh-tool-fs-search)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
function grepArgv(input) {
|
|
28
|
+
const parts = ['--json', `--regexp=${input.pattern}`]
|
|
29
|
+
if (input.include !== undefined) parts.push(`--glob=${input.include}`)
|
|
30
|
+
// Explicit search root: ripgrep 14+ does NOT search the cwd when no path is
|
|
31
|
+
// given, so a bare `rg --regexp=…` searches zero bytes.
|
|
32
|
+
parts.push('--', input.path ?? '.')
|
|
33
|
+
return parts
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function globArgv(input) {
|
|
37
|
+
const parts = ['--files', `--glob=${input.pattern}`, '--sort=modified', '--no-ignore', '--hidden']
|
|
38
|
+
for (const name of ['.git', '.hg', '.svn']) {
|
|
39
|
+
parts.push(`--glob=!**/${name}`, `--glob=!**/${name}/**`)
|
|
40
|
+
}
|
|
41
|
+
parts.push('--', input.path ?? '.')
|
|
42
|
+
return parts
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Output parsing
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
function parseGrepMatches(stdout) {
|
|
49
|
+
const matches = []
|
|
50
|
+
for (const line of stdout.split('\n')) {
|
|
51
|
+
if (line === '') continue
|
|
52
|
+
let rec
|
|
53
|
+
try { rec = JSON.parse(line) } catch { throw new Error('grep: malformed ripgrep --json output') }
|
|
54
|
+
if (rec === null || typeof rec !== 'object' || rec.type !== 'match') continue
|
|
55
|
+
const d = rec.data
|
|
56
|
+
const path = d && d.path && typeof d.path.text === 'string' ? d.path.text : undefined
|
|
57
|
+
if (path === undefined || typeof d.line_number !== 'number' || d.lines === null || typeof d.lines !== 'object') {
|
|
58
|
+
throw new Error('grep: malformed ripgrep match record')
|
|
59
|
+
}
|
|
60
|
+
let lineText
|
|
61
|
+
if (typeof d.lines.text === 'string') lineText = d.lines.text.replace(/\r?\n$/, '')
|
|
62
|
+
else if (typeof d.lines.bytes === 'string') lineText = '(line is not valid UTF-8)'
|
|
63
|
+
else throw new Error('grep: malformed ripgrep match record')
|
|
64
|
+
matches.push({ path: path.replace(/^\.\//, ''), lineNumber: d.line_number, line: lineText })
|
|
65
|
+
}
|
|
66
|
+
return matches
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseGlobPaths(stdout) {
|
|
70
|
+
return stdout.split('\n').filter((p) => p !== '').map((p) => p.replace(/^\.\//, ''))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Map a model-supplied search path to a remote path. A LOCAL anchor directory
|
|
75
|
+
* (what the system prompt shows as the "working directory") is translated to
|
|
76
|
+
* its remote origin; relative paths and already-remote POSIX paths pass
|
|
77
|
+
* through unchanged (ripgrep resolves relative paths against the remote cwd).
|
|
78
|
+
*/
|
|
79
|
+
function translatePath(path) {
|
|
80
|
+
if (path === undefined || path === '' || path === '.') return '.'
|
|
81
|
+
const hit = findByCwd(path)
|
|
82
|
+
if (hit !== undefined) {
|
|
83
|
+
return hit.remoteSubpath === '' ? hit.remotePath : posix.join(hit.remotePath, hit.remoteSubpath)
|
|
84
|
+
}
|
|
85
|
+
return path
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Execution (local subprocess vs remote ssh2 exec)
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
async function localRg(deps, argv, cwd, signal) {
|
|
92
|
+
const subprocess = deps.getSubprocess()
|
|
93
|
+
if (!subprocess) throw new Error('grep/glob: subprocess service unavailable')
|
|
94
|
+
const handle = subprocess.spawn({
|
|
95
|
+
argv: ['rg', ...argv],
|
|
96
|
+
cwd,
|
|
97
|
+
stdio: {
|
|
98
|
+
stdin: 'ignore',
|
|
99
|
+
stdout: { maxBytes: RAW_OUTPUT_MAX_BYTES },
|
|
100
|
+
stderr: { maxBytes: STDERR_MAX_BYTES },
|
|
101
|
+
},
|
|
102
|
+
graceMs: GRACE_MS,
|
|
103
|
+
signal,
|
|
104
|
+
})
|
|
105
|
+
const outcome = await handle.done
|
|
106
|
+
const stdout = handle.collected.stdout?.readFrom(0)
|
|
107
|
+
const stderr = handle.collected.stderr?.readFrom(0)
|
|
108
|
+
if (outcome.exitCode !== 0 && outcome.exitCode !== 1) {
|
|
109
|
+
throw new Error(`grep/glob: rg failed (exit ${outcome.exitCode}): ${stderr?.text ?? ''}`)
|
|
110
|
+
}
|
|
111
|
+
return stdout?.text ?? ''
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function remoteRg(client, argv, remoteCwd, signal) {
|
|
115
|
+
const command = ['rg', ...argv].map(shellQuote).join(' ')
|
|
116
|
+
const result = await client.execShell(command, {
|
|
117
|
+
cwd: remoteCwd,
|
|
118
|
+
timeoutMs: SEARCH_TIMEOUT_MS,
|
|
119
|
+
stdoutMaxBytes: RAW_OUTPUT_MAX_BYTES,
|
|
120
|
+
stderrMaxBytes: STDERR_MAX_BYTES,
|
|
121
|
+
...(signal !== undefined ? { signal } : {}),
|
|
122
|
+
})
|
|
123
|
+
if (!result.ok) throw new Error(`grep/glob: remote rg failed: ${result.error ?? ''}`)
|
|
124
|
+
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
|
125
|
+
throw new Error(`grep/glob: remote rg failed (exit ${result.exitCode}): ${result.stderr.text}`)
|
|
126
|
+
}
|
|
127
|
+
return result.stdout.text
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Model-facing formatting
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
function formatGrep(matches) {
|
|
134
|
+
if (matches.length === 0) return 'No matches found'
|
|
135
|
+
const byFile = new Map()
|
|
136
|
+
for (const m of matches) {
|
|
137
|
+
const g = byFile.get(m.path)
|
|
138
|
+
if (g) g.push(m)
|
|
139
|
+
else byFile.set(m.path, [m])
|
|
140
|
+
}
|
|
141
|
+
const sections = []
|
|
142
|
+
for (const [path, group] of byFile) {
|
|
143
|
+
sections.push(`${path}\n${group.map((m) => `Line ${m.lineNumber}: ${m.line}`).join('\n')}`)
|
|
144
|
+
}
|
|
145
|
+
return `Found ${matches.length} ${matches.length === 1 ? 'match' : 'matches'}\n\n${sections.join('\n\n')}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function formatGlob(paths) {
|
|
149
|
+
if (paths.length === 0) return 'No files found'
|
|
150
|
+
return paths.join('\n')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Tool construction + registration
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
export function createSearchTools(deps) {
|
|
157
|
+
function resolveWorld(exec) {
|
|
158
|
+
const cwd = exec.agent?.session.header.cwd
|
|
159
|
+
const hit = cwd ? findByCwd(cwd) : undefined
|
|
160
|
+
if (hit === undefined) return { local: true, cwd: cwd ?? process.cwd() }
|
|
161
|
+
const remoteCwd = hit.remoteSubpath === '' ? hit.remotePath : posix.join(hit.remotePath, hit.remoteSubpath)
|
|
162
|
+
return { local: false, remoteCwd, client: deps.clientForRemote(hit.host, hit.user, hit.port) }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function runRg(exec, buildArgv, input) {
|
|
166
|
+
const world = resolveWorld(exec)
|
|
167
|
+
if (world.local) return localRg(deps, buildArgv(input), world.cwd, exec.signal)
|
|
168
|
+
const remoteInput = input.path !== undefined
|
|
169
|
+
? { ...input, path: translatePath(input.path) }
|
|
170
|
+
: input
|
|
171
|
+
return remoteRg(world.client, buildArgv(remoteInput), world.remoteCwd, exec.signal)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const grep = {
|
|
175
|
+
name: 'grep',
|
|
176
|
+
description: 'Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. '
|
|
177
|
+
+ `Returns up to ${GREP_MAX_MATCHES} matches. Use read on a matched file for surrounding context. `
|
|
178
|
+
+ 'On a remote workspace the search runs on the remote host.',
|
|
179
|
+
parameters: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: {
|
|
182
|
+
pattern: { type: 'string', description: 'Regular expression to search for (ripgrep syntax).' },
|
|
183
|
+
path: { type: 'string', description: 'File or directory to search. Defaults to the session workspace; a relative path resolves against it.' },
|
|
184
|
+
include: { type: 'string', description: 'One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported.' },
|
|
185
|
+
},
|
|
186
|
+
required: ['pattern'],
|
|
187
|
+
additionalProperties: false,
|
|
188
|
+
},
|
|
189
|
+
timeoutMs: SEARCH_TIMEOUT_MS,
|
|
190
|
+
output: {
|
|
191
|
+
schema: {
|
|
192
|
+
type: 'object',
|
|
193
|
+
additionalProperties: false,
|
|
194
|
+
properties: {
|
|
195
|
+
matches: {
|
|
196
|
+
type: 'array',
|
|
197
|
+
items: {
|
|
198
|
+
type: 'object',
|
|
199
|
+
additionalProperties: false,
|
|
200
|
+
properties: {
|
|
201
|
+
path: { type: 'string' },
|
|
202
|
+
lineNumber: { type: 'integer' },
|
|
203
|
+
line: { type: 'string' },
|
|
204
|
+
},
|
|
205
|
+
required: ['path', 'lineNumber', 'line'],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
required: ['matches'],
|
|
210
|
+
},
|
|
211
|
+
render: (_args, value) => [{ type: 'text', text: formatGrep(value.matches) }],
|
|
212
|
+
},
|
|
213
|
+
async execute(args, exec) {
|
|
214
|
+
const input = {
|
|
215
|
+
pattern: args.pattern,
|
|
216
|
+
...(args.path !== undefined ? { path: args.path } : {}),
|
|
217
|
+
...(args.include !== undefined ? { include: args.include } : {}),
|
|
218
|
+
}
|
|
219
|
+
const stdout = await runRg(exec, grepArgv, input)
|
|
220
|
+
return { matches: parseGrepMatches(stdout).slice(0, GREP_MAX_MATCHES) }
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const glob = {
|
|
225
|
+
name: 'glob',
|
|
226
|
+
description: 'Find files matching a glob pattern. Returns file paths, modification-time ordered. '
|
|
227
|
+
+ `Returns up to ${GLOB_MAX_RESULTS} paths. On a remote workspace the search runs on the remote host.`,
|
|
228
|
+
parameters: {
|
|
229
|
+
type: 'object',
|
|
230
|
+
properties: {
|
|
231
|
+
pattern: { type: 'string', description: 'Glob pattern to match file names against (e.g. "*.ts", "src/**/*.js").' },
|
|
232
|
+
path: { type: 'string', description: 'Directory to search. Defaults to the session workspace; a relative path resolves against it.' },
|
|
233
|
+
},
|
|
234
|
+
required: ['pattern'],
|
|
235
|
+
additionalProperties: false,
|
|
236
|
+
},
|
|
237
|
+
timeoutMs: SEARCH_TIMEOUT_MS,
|
|
238
|
+
output: {
|
|
239
|
+
schema: {
|
|
240
|
+
type: 'object',
|
|
241
|
+
additionalProperties: false,
|
|
242
|
+
properties: {
|
|
243
|
+
paths: { type: 'array', items: { type: 'string' } },
|
|
244
|
+
},
|
|
245
|
+
required: ['paths'],
|
|
246
|
+
},
|
|
247
|
+
render: (_args, value) => [{ type: 'text', text: formatGlob(value.paths) }],
|
|
248
|
+
},
|
|
249
|
+
async execute(args, exec) {
|
|
250
|
+
const input = { pattern: args.pattern, ...(args.path !== undefined ? { path: args.path } : {}) }
|
|
251
|
+
const stdout = await runRg(exec, globArgv, input)
|
|
252
|
+
return { paths: parseGlobPaths(stdout).slice(0, GLOB_MAX_RESULTS) }
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { grep, glob }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function applySearchTools(ctx, deps) {
|
|
260
|
+
const { grep, glob } = createSearchTools(deps)
|
|
261
|
+
|
|
262
|
+
// Global registration: the fallback for a rosterless deployment (no agent
|
|
263
|
+
// preset), where the model-facing rows sit in the host composition and the
|
|
264
|
+
// built-in `tool-fs-search` is disabled by the patch layer. Under a preset
|
|
265
|
+
// this registration is harmless — it lives in the farthest layer and every
|
|
266
|
+
// nearer layer shadows it.
|
|
267
|
+
ctx.tools.register(grep)
|
|
268
|
+
ctx.tools.register(glob)
|
|
269
|
+
|
|
270
|
+
// Per-agent registration: the model-facing grep/glob actually ship in the
|
|
271
|
+
// agent PRESET (`tool-fs-search` in `agent.cordis.yml`), mounted under a
|
|
272
|
+
// STANDING scope that is an ancestor of each agent's own scope. A host-plane
|
|
273
|
+
// `disabled` patch cannot reach that composition — the web bundle already
|
|
274
|
+
// disables the HOST row, and the preset row is a separate mount. Registering
|
|
275
|
+
// in the agent's OWN scope layer (the nearest) shadows the preset's built-in
|
|
276
|
+
// grep/glob without touching the preset. The inject fiber is owned by
|
|
277
|
+
// `agent.ctx`, so it unwinds with the agent.
|
|
278
|
+
ctx.on('agent/created', ({ agent }) => {
|
|
279
|
+
agent.ctx.inject(['tools'], (scope) => {
|
|
280
|
+
scope.tools.register(grep)
|
|
281
|
+
scope.tools.register(glob)
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export default applySearchTools
|