dsh-codespace-workspace 0.0.0-stage → 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,565 @@
1
+ /**
2
+ * Remote tool surface for one Codespace agent.
3
+ *
4
+ * The mechanism is the supported per-agent seam: tools are registered through
5
+ * `agent.ctx`, which scopes them to that agent, and a scoped registration
6
+ * shadows a same-named global for that agent only. Nothing global is replaced,
7
+ * so every other session and every other plugin is unaffected.
8
+ *
9
+ * The shadow tools deliberately mirror the local tools' `output.schema` and
10
+ * their `[exit code: N]` marker contract, because the presentation layer reads
11
+ * both: the UI renders the same cards, and `parseExitStatus` from
12
+ * `@deepseek-ai/dsh-shell` recovers the exit pill from the rendered text.
13
+ *
14
+ * @module dsh-codespace-workspace/host/remote-tools
15
+ */
16
+
17
+ import { redact } from '../shared.js'
18
+ import { shq } from './gh.js'
19
+ import {
20
+ remoteListDir,
21
+ remoteReadText,
22
+ remoteResolve,
23
+ remoteStat,
24
+ remoteWriteText,
25
+ needsTildeExpansion,
26
+ remoteExpandTilde,
27
+ remoteExec,
28
+ } from './transport.js'
29
+
30
+ /** Default line cap for a remote `read`, matching the local tool's default. */
31
+ const READ_LIMIT = 2000
32
+
33
+ /** Default ceiling for one remote shell command. */
34
+ const EXEC_TIMEOUT_MS = 60000
35
+
36
+ /** Ceiling on a remote glob/grep scan. */
37
+ const SEARCH_TIMEOUT_MS = 30000
38
+
39
+ /** A model-supplied path is only usable once it is absolute on the remote host. */
40
+ async function absolutePath(target, cwd, input, options) {
41
+ const resolved = remoteResolve(cwd, input)
42
+ if (!needsTildeExpansion(resolved)) return resolved
43
+ return remoteExpandTilde(target, resolved, options)
44
+ }
45
+
46
+ /** Format a read window as the same envelope the local `read` tool emits. */
47
+ function formatReadOutput(displayPath, outcome) {
48
+ const endLine = outcome.lines.length > 0 ? outcome.lines[outcome.lines.length - 1].number : Math.max(0, outcome.offset - 1)
49
+ let footer
50
+ if (endLine < outcome.totalLines) {
51
+ footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`
52
+ } else {
53
+ footer = `(End of file - total ${outcome.totalLines} lines)`
54
+ }
55
+ const body = outcome.lines.length > 0
56
+ ? `${outcome.lines.map((line) => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}`
57
+ : footer
58
+ return `<path>${displayPath}</path>\n<type>file</type>\n<content>\n${body}\n</content>`
59
+ }
60
+
61
+ /** Build a read window from raw text. */
62
+ function windowLines(text, offset, limit) {
63
+ const all = text.split('\n')
64
+ // A trailing newline produces one empty final element that is not a line.
65
+ if (all.length > 0 && all[all.length - 1] === '') all.pop()
66
+ const start = Math.max(1, offset)
67
+ const end = Math.min(all.length, start - 1 + limit)
68
+ const lines = []
69
+ for (let index = start - 1; index < end; index += 1) {
70
+ lines.push({ number: index + 1, text: all[index] })
71
+ }
72
+ return { lines, totalLines: all.length }
73
+ }
74
+
75
+ /**
76
+ * Render a remote shell result with the local tool's marker contract.
77
+ *
78
+ * The markers are appended in the same order the local tools use, so
79
+ * `parseExitStatus` recovers the same exit code from the same shape.
80
+ *
81
+ * @param {{stdout: string, stderr: string, exitCode: number, timedOut: boolean, aborted: boolean}} result - the remote result.
82
+ * @returns {string} the model-facing text.
83
+ */
84
+ function renderShellResult(result) {
85
+ let body = result.stdout
86
+ if (result.stderr.length > 0) {
87
+ if (body.length > 0 && !body.endsWith('\n')) body += '\n'
88
+ body += `[stderr]\n${result.stderr}`
89
+ }
90
+ if (body.length === 0) body = '(no output)'
91
+
92
+ const markers = []
93
+ if (result.timedOut) markers.push(`[timed out after ${EXEC_TIMEOUT_MS}ms]`)
94
+ if (result.aborted) markers.push('[aborted]')
95
+ else if (result.exitCode !== 0) markers.push(`[exit code: ${result.exitCode}]`)
96
+ if (markers.length === 0) return body
97
+ if (!body.endsWith('\n')) body += '\n'
98
+ return body + markers.join('\n')
99
+ }
100
+
101
+ /** The remote `bash` tool. */
102
+ function bashTool(target, cwd, run) {
103
+ return {
104
+ name: 'bash',
105
+ description: [
106
+ 'Execute a bash command inside the remote GitHub Codespace and return its stdout/stderr.',
107
+ 'This runs on the Codespace, not on the local machine: paths are POSIX and the working',
108
+ 'directory is the Codespace checkout. Pass `workdir` instead of using `cd`.',
109
+ ].join(' '),
110
+ parameters: {
111
+ command: { type: 'string', required: true, description: 'The bash command to execute.' },
112
+ description: {
113
+ type: 'string',
114
+ required: true,
115
+ description: 'Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI).',
116
+ },
117
+ timeoutMs: { type: 'number', description: 'Timeout in milliseconds.' },
118
+ workdir: { type: 'string', description: 'Working directory for this command; a relative path resolves against the Codespace workspace.' },
119
+ },
120
+ output: {
121
+ schema: {
122
+ type: 'object',
123
+ additionalProperties: false,
124
+ properties: {
125
+ exitCode: { type: 'integer', required: true },
126
+ stdout: { type: 'string', required: true },
127
+ stderr: { type: 'string', required: true },
128
+ timedOut: { type: 'boolean', required: true },
129
+ aborted: { type: 'boolean', required: true },
130
+ },
131
+ },
132
+ render: (_args, value) => [{ type: 'text', text: renderShellResult(value) }],
133
+ },
134
+ isConcurrencySafe: () => false,
135
+ async execute(args, exec) {
136
+ const command = String(args.command ?? '')
137
+ if (command.trim().length === 0) throw new Error('invalid command: expected a non-empty string')
138
+ const workdir = args.workdir === undefined ? cwd : await absolutePath(target, cwd, args.workdir, { run })
139
+ const timeoutMs = Number.isFinite(args.timeoutMs) && args.timeoutMs > 0 ? args.timeoutMs : EXEC_TIMEOUT_MS
140
+ // The workdir is single-quoted, so a failure to enter it is reported by
141
+ // the command itself rather than silently running in the wrong place.
142
+ const result = await remoteExec(target, `cd ${shellQuote(workdir)} 2>/dev/null; ${command}`, {
143
+ timeoutMs,
144
+ signal: exec?.signal,
145
+ run,
146
+ })
147
+ return {
148
+ exitCode: result.exitCode,
149
+ stdout: result.stdout,
150
+ stderr: result.stderr,
151
+ timedOut: result.timedOut,
152
+ aborted: result.aborted,
153
+ }
154
+ },
155
+ }
156
+ }
157
+
158
+ /** The remote `read` tool. */
159
+ function readTool(target, cwd, run) {
160
+ return {
161
+ name: 'read',
162
+ description: 'Read a UTF-8 text file from the remote Codespace and return line-numbered content.',
163
+ parameters: {
164
+ file_path: { type: 'string', required: true, description: 'Path to read, resolved inside the Codespace.' },
165
+ offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
166
+ limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${READ_LIMIT}.` },
167
+ },
168
+ output: {
169
+ schema: {
170
+ type: 'object',
171
+ additionalProperties: false,
172
+ properties: {
173
+ path: { type: 'string', required: true },
174
+ offset: { type: 'integer', required: true },
175
+ lines: {
176
+ type: 'array',
177
+ required: true,
178
+ items: {
179
+ type: 'object',
180
+ additionalProperties: false,
181
+ properties: {
182
+ number: { type: 'integer', required: true },
183
+ text: { type: 'string', required: true },
184
+ },
185
+ },
186
+ },
187
+ totalLines: { type: 'integer', required: true },
188
+ },
189
+ },
190
+ render: (_args, value) => [{ type: 'text', text: formatReadOutput(value.path, value) }],
191
+ },
192
+ isConcurrencySafe: () => true,
193
+ async execute(args, exec) {
194
+ const input = String(args.file_path ?? '')
195
+ if (input.trim().length === 0) throw new Error('file_path must be a non-empty string')
196
+ const offset = Number.isFinite(args.offset) ? Math.max(1, Math.round(args.offset)) : 1
197
+ const limit = Number.isFinite(args.limit) && args.limit > 0 ? Math.round(args.limit) : READ_LIMIT
198
+ const path = await absolutePath(target, cwd, input, { run })
199
+ const info = await remoteStat(target, path, { run, signal: exec?.signal })
200
+ if (info === null) throw new Error(`cannot read "${path}": not found`)
201
+ if (info.type !== 'file') throw new Error(`cannot read "${path}": not a regular file`)
202
+ const read = await remoteReadText(target, path, { run, signal: exec?.signal })
203
+ const window = windowLines(read.text, offset, limit)
204
+ return { path, offset, lines: window.lines, totalLines: window.totalLines }
205
+ },
206
+ }
207
+ }
208
+
209
+ /** The remote `write` tool. */
210
+ function writeTool(target, cwd, run) {
211
+ return {
212
+ name: 'write',
213
+ description: 'Create or fully replace a UTF-8 text file inside the remote Codespace.',
214
+ parameters: {
215
+ file_path: { type: 'string', required: true, description: 'Path to write, resolved inside the Codespace.' },
216
+ content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' },
217
+ },
218
+ output: {
219
+ schema: {
220
+ type: 'object',
221
+ additionalProperties: false,
222
+ properties: {
223
+ path: { type: 'string', required: true },
224
+ operation: { type: 'string', required: true, enum: ['create', 'update'] },
225
+ before: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
226
+ after: { type: 'string', required: true },
227
+ },
228
+ },
229
+ render: (_args, value) => [{
230
+ type: 'text',
231
+ text: `<path>${value.path}</path>\n<type>file</type>\n<content>\n${value.operation === 'create' ? 'Created' : 'Updated'} file\n</content>`,
232
+ }],
233
+ },
234
+ async execute(args, exec) {
235
+ const input = String(args.file_path ?? '')
236
+ if (input.trim().length === 0) throw new Error('file_path must be a non-empty string')
237
+ const content = typeof args.content === 'string' ? args.content : ''
238
+ const path = await absolutePath(target, cwd, input, { run })
239
+ let before = null
240
+ const info = await remoteStat(target, path, { run, signal: exec?.signal })
241
+ if (info !== null) {
242
+ if (info.type !== 'file') throw new Error(`cannot write "${path}": not a regular file`)
243
+ before = (await remoteReadText(target, path, { run, signal: exec?.signal })).text
244
+ }
245
+ await remoteWriteText(target, path, content, { run, signal: exec?.signal })
246
+ return { path, operation: before === null ? 'create' : 'update', before, after: content }
247
+ },
248
+ }
249
+ }
250
+
251
+ /** The remote `edit` tool. */
252
+ function editTool(target, cwd, run) {
253
+ return {
254
+ name: 'edit',
255
+ description: 'Edit an existing UTF-8 text file in the remote Codespace by replacing literal text.',
256
+ parameters: {
257
+ file_path: { type: 'string', required: true, description: 'Path to edit, resolved inside the Codespace.' },
258
+ old_string: { type: 'string', required: true, description: 'Literal text to replace.' },
259
+ new_string: { type: 'string', required: true, description: 'Literal replacement text. Use an empty string to delete the match.' },
260
+ replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
261
+ },
262
+ output: {
263
+ schema: {
264
+ type: 'object',
265
+ additionalProperties: false,
266
+ properties: {
267
+ path: { type: 'string', required: true },
268
+ before: { type: 'string', required: true },
269
+ after: { type: 'string', required: true },
270
+ },
271
+ },
272
+ render: (args, value) => [{
273
+ type: 'text',
274
+ text: args.replace_all === true
275
+ ? `The file ${value.path} has been updated. All occurrences were successfully replaced.`
276
+ : `The file ${value.path} has been updated successfully.`,
277
+ }],
278
+ },
279
+ async execute(args, exec) {
280
+ const input = String(args.file_path ?? '')
281
+ if (input.trim().length === 0) throw new Error('file_path must be a non-empty string')
282
+ const oldString = String(args.old_string ?? '')
283
+ const newString = String(args.new_string ?? '')
284
+ if (oldString.length === 0) throw new Error('old_string must be a non-empty string')
285
+ if (oldString === newString) throw new Error('old_string and new_string must differ')
286
+
287
+ const path = await absolutePath(target, cwd, input, { run })
288
+ const read = await remoteReadText(target, path, { run, signal: exec?.signal })
289
+ const before = read.text
290
+ const parts = before.split(oldString)
291
+ const occurrences = parts.length - 1
292
+ if (occurrences === 0) throw new Error(`cannot edit "${path}": old_string was not found`)
293
+ if (occurrences > 1 && args.replace_all !== true) {
294
+ throw new Error(`cannot edit "${path}": old_string appears ${occurrences} times — pass replace_all, or include more context to make it unique`)
295
+ }
296
+ const after = args.replace_all === true ? parts.join(newString) : before.replace(oldString, newString)
297
+ await remoteWriteText(target, path, after, { run, signal: exec?.signal })
298
+ return { path, before, after }
299
+ },
300
+ }
301
+ }
302
+
303
+ /** The remote `glob` tool. */
304
+ function globTool(target, cwd, run) {
305
+ return {
306
+ name: 'glob',
307
+ description: 'Find files in the remote Codespace whose paths match a glob pattern. Returns up to 200 paths.',
308
+ parameters: {
309
+ pattern: { type: 'string', required: true, description: 'Glob pattern to match file paths against (e.g. "**/*.ts").' },
310
+ path: { type: 'string', description: 'Directory to search in. Defaults to the Codespace workspace root.' },
311
+ },
312
+ output: {
313
+ schema: {
314
+ type: 'object',
315
+ additionalProperties: false,
316
+ properties: {
317
+ root: { type: 'string', required: true },
318
+ paths: { type: 'array', required: true, items: { type: 'string' } },
319
+ },
320
+ },
321
+ render: (_args, value) => [{
322
+ type: 'text',
323
+ text: value.paths.length === 0 ? `(no matches in ${value.root})` : value.paths.join('\n'),
324
+ }],
325
+ },
326
+ async execute(args, exec) {
327
+ const pattern = String(args.pattern ?? '')
328
+ if (pattern.trim().length === 0) throw new Error('pattern must be a non-empty string')
329
+ const root = args.path === undefined ? cwd : await absolutePath(target, cwd, args.path, { run })
330
+ // `-g` takes the pattern as a glob; `--files` lists rather than searches.
331
+ // Both the pattern and the root are single-quoted by buildCommand.
332
+ const command = `cd ${shellQuote(root)} && (rg --files -g ${shellQuote(pattern)} 2>/dev/null || find . -type f -name ${shellQuote(pattern.replace(/^\*\*\//, ''))} 2>/dev/null) | head -n 200`
333
+ const result = await remoteExec(target, command, { timeoutMs: SEARCH_TIMEOUT_MS, run, signal: exec?.signal })
334
+ if (result.exitCode !== 0 && result.stdout.trim().length === 0) {
335
+ throw new Error(`glob failed in "${root}": ${result.stderr.trim() || `exit ${result.exitCode}`}`)
336
+ }
337
+ const paths = result.stdout.split('\n').map((line) => line.trim()).filter((line) => line.length > 0)
338
+ return { root, paths }
339
+ },
340
+ }
341
+ }
342
+
343
+ /** The remote `grep` tool. */
344
+ function grepTool(target, cwd, run) {
345
+ return {
346
+ name: 'grep',
347
+ description: 'Search file contents in the remote Codespace with a regular expression. Returns matching lines with line numbers.',
348
+ parameters: {
349
+ pattern: { type: 'string', required: true, description: 'Regular expression to search for.' },
350
+ path: { type: 'string', description: 'File or directory to search. Defaults to the Codespace workspace root.' },
351
+ include: { type: 'string', description: 'One glob filter for which files to search (e.g. "*.ts").' },
352
+ },
353
+ output: {
354
+ schema: {
355
+ type: 'object',
356
+ additionalProperties: false,
357
+ properties: {
358
+ matches: {
359
+ type: 'array',
360
+ required: true,
361
+ items: {
362
+ type: 'object',
363
+ additionalProperties: false,
364
+ properties: {
365
+ path: { type: 'string', required: true },
366
+ lineNumber: { type: 'integer', required: true },
367
+ line: { type: 'string', required: true },
368
+ },
369
+ },
370
+ },
371
+ },
372
+ },
373
+ render: (_args, value) => {
374
+ if (value.matches.length === 0) return [{ type: 'text', text: '(no matches)' }]
375
+ const byFile = new Map()
376
+ for (const match of value.matches) {
377
+ const list = byFile.get(match.path) ?? []
378
+ list.push(match)
379
+ byFile.set(match.path, list)
380
+ }
381
+ const blocks = [...byFile.entries()].map(([path, matches]) => {
382
+ const head = `${path}:`
383
+ const body = matches.map((match) => ` ${match.lineNumber}: ${match.line}`).join('\n')
384
+ return `${head}\n${body}`
385
+ })
386
+ return [{ type: 'text', text: blocks.join('\n\n') }]
387
+ },
388
+ },
389
+ async execute(args, exec) {
390
+ const pattern = String(args.pattern ?? '')
391
+ if (pattern.trim().length === 0) throw new Error('pattern must be a non-empty string')
392
+ const root = args.path === undefined ? cwd : await absolutePath(target, cwd, args.path, { run })
393
+ const include = typeof args.include === 'string' && args.include.length > 0
394
+ ? ` -g ${shellQuote(args.include)}`
395
+ : ''
396
+ const command = `cd ${shellQuote(cwd)} && rg --line-number --no-heading --color never${include} -- ${shellQuote(pattern)} ${shellQuote(root)} 2>/dev/null | head -n 250`
397
+ const result = await remoteExec(target, command, { timeoutMs: SEARCH_TIMEOUT_MS, run, signal: exec?.signal })
398
+ const matches = []
399
+ for (const raw of result.stdout.split('\n')) {
400
+ if (raw.length === 0) continue
401
+ // `rg` emits `path:line:lineNumber:text`; the path may itself contain colons,
402
+ // so the two trailing numeric fields are located from the right.
403
+ const second = raw.lastIndexOf(':')
404
+ if (second < 0) continue
405
+ const first = raw.lastIndexOf(':', second - 1)
406
+ if (first < 0) continue
407
+ const lineNumber = Number(raw.slice(first + 1, second))
408
+ if (!Number.isFinite(lineNumber)) continue
409
+ matches.push({
410
+ path: raw.slice(0, first),
411
+ lineNumber,
412
+ line: raw.slice(second + 1),
413
+ })
414
+ }
415
+ return { matches }
416
+ },
417
+ }
418
+ }
419
+
420
+ /** Single-quote a value for a remote POSIX shell. Re-exported from `gh.js`'s `shq`. */
421
+ const shellQuote = shq
422
+
423
+ /**
424
+ * Register the remote tool surface on one agent's scope.
425
+ *
426
+ * Everything is registered inside a single `agent.ctx.effect()`, so disposing
427
+ * that effect removes the whole surface at once. The caller additionally keys
428
+ * this disposer in its own plugin effect, because unloading the plugin does not
429
+ * by itself dispose `agent.ctx` registrations.
430
+ *
431
+ * @param {object} agent - the live agent from `agent/created`.
432
+ * @param {{manager: object, workspaceId: string, target: object, localToolNames?: string[], run?: Function}} options - wiring.
433
+ * @returns {() => void} the disposer removing every registration.
434
+ */
435
+ export function installRemoteTools(agent, options) {
436
+ const { manager, workspaceId, target } = options
437
+ const run = options.run
438
+ const localToolNames = Array.isArray(options.localToolNames) ? options.localToolNames : []
439
+ const agentCtx = agent?.ctx
440
+ if (agentCtx === undefined) return () => {}
441
+
442
+ const record = manager?.recordOf?.(workspaceId)
443
+ const cwd = typeof target?.cwd === 'string' && target.cwd.length > 0
444
+ ? target.cwd
445
+ : (record?.path ?? '/')
446
+
447
+ /*
448
+ * Wait for the agent's `tools` service through `inject` instead of reading it
449
+ * off `agent.ctx` right now.
450
+ *
451
+ * `agent.ctx.tools` at `agent/created` time is `undefined`, and it fails
452
+ * *quietly*: `ReflectService.handler.get` ends with
453
+ * `if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false)`, and the agent
454
+ * scope has no running fiber, so the lookup degrades to the "no inject
455
+ * required" read rather than throwing. The old code turned that into
456
+ * `if (tools === undefined) return () => {}` — a SILENT no-op. The observable
457
+ * result was the worst kind: the agent kept its local `read`/`glob`/`pwsh`,
458
+ * never got the remote surface, and nothing was logged, so a Codespace session
459
+ * happily scanned the empty placeholder directory on Windows while the system
460
+ * prompt said it was in the cloud.
461
+ *
462
+ * `agent.ctx.inject([...], cb)` is the documented way to wait (DSH's own
463
+ * `dsh-file-reference-local` does exactly this), and it returns a fiber whose
464
+ * `dispose()` tears the registration down again.
465
+ */
466
+ let fiber = null
467
+ try {
468
+ fiber = agentCtx.inject(['tools'], (scope) => {
469
+ // Read it off the injected scope, never off `agentCtx`: cordis resolves
470
+ // services through the *fiber's* store, and only the scope created by
471
+ // `inject` carries `tools`. (`agentCtx.tools` at this moment is not even an
472
+ // error — `ReflectService.handler` short-circuits to
473
+ // `ctx.reflect.get(prop, false)` when the fiber has no runtime, which is
474
+ // exactly how the old version got `undefined` instead of a loud failure.)
475
+ const tools = scope?.tools
476
+ if (tools === undefined) {
477
+ manager?.log?.('warn', `cannot install remote tools for ${workspaceId}: the agent scope has no tools service`)
478
+ return
479
+ }
480
+ installInto(agentCtx, tools, { manager, workspaceId, target, cwd, localToolNames, run })
481
+ })
482
+ } catch (error) {
483
+ manager?.log?.('warn', `cannot install remote tools: ${redact(error instanceof Error ? error.message : String(error))}`)
484
+ return () => {}
485
+ }
486
+
487
+ return () => {
488
+ try {
489
+ const disposal = fiber?.dispose?.()
490
+ // `dispose()` is async; an unhandled rejection here would surface as a
491
+ // crash during teardown, which is never worth it.
492
+ if (disposal !== undefined && typeof disposal.catch === 'function') disposal.catch(() => {})
493
+ } catch {
494
+ /* idempotent teardown */
495
+ }
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Register the remote surface into an agent scope whose `tools` is ready.
501
+ *
502
+ * @param {object} agentCtx - the agent's context (owns the effect).
503
+ * @param {object} tools - the resolved tools service.
504
+ * @param {object} options - resolved wiring.
505
+ * @returns {void}
506
+ */
507
+ function installInto(agentCtx, tools, options) {
508
+ const { manager, workspaceId, target, cwd, localToolNames, run } = options
509
+ try {
510
+ // The effect is owned by the agent context, so disposing that scope removes
511
+ // the whole surface; no disposer needs to escape here.
512
+ agentCtx.effect(() => {
513
+ const disposers = []
514
+
515
+ // The remote surface. A scoped registration shadows a same-named global
516
+ // for this agent only, and is unaffected by the restrictions below.
517
+ for (const definition of [
518
+ readTool(target, cwd, run),
519
+ writeTool(target, cwd, run),
520
+ editTool(target, cwd, run),
521
+ globTool(target, cwd, run),
522
+ grepTool(target, cwd, run),
523
+ bashTool(target, cwd, run),
524
+ ]) {
525
+ try {
526
+ disposers.push(tools.register(definition))
527
+ } catch (error) {
528
+ manager?.log?.('warn', `cannot register remote tool ${definition.name}: ${redact(error instanceof Error ? error.message : String(error))}`)
529
+ }
530
+ }
531
+
532
+ // Hide the local counterparts this agent must not reach. Only names that
533
+ // actually exist globally are denied: `restrict()` throws on an unknown
534
+ // name, and the local tool set differs between the Windows and POSIX
535
+ // compositions.
536
+ const deny = []
537
+ for (const name of localToolNames) {
538
+ try {
539
+ if (tools.get(name, undefined) !== undefined) deny.push(name)
540
+ } catch {
541
+ /* an unreadable global view just skips the name */
542
+ }
543
+ }
544
+ if (deny.length > 0) {
545
+ try {
546
+ disposers.push(tools.restrict({ deny }))
547
+ } catch (error) {
548
+ manager?.log?.('warn', `cannot restrict local tools ${deny.join(', ')}: ${redact(error instanceof Error ? error.message : String(error))}`)
549
+ }
550
+ }
551
+
552
+ return () => {
553
+ for (const disposer of disposers) {
554
+ try {
555
+ disposer()
556
+ } catch {
557
+ /* idempotent teardown */
558
+ }
559
+ }
560
+ }
561
+ }, `dsh-codespace-workspace: remote tools for ${workspaceId}`)
562
+ } catch (error) {
563
+ manager?.log?.('warn', `cannot install remote tools: ${redact(error instanceof Error ? error.message : String(error))}`)
564
+ }
565
+ }