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.
@@ -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