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.
- package/LICENSE +21 -0
- package/README.md +680 -2
- package/cordis.patch.yml +11 -0
- package/icon.svg +10 -0
- package/lib/client.js +3598 -0
- package/lib/host/codespaces.js +942 -0
- package/lib/host/gh.js +271 -0
- package/lib/host/github.js +619 -0
- package/lib/host/remote-tools.js +565 -0
- package/lib/host/routes.js +555 -0
- package/lib/host/session.js +267 -0
- package/lib/host/transport.js +463 -0
- package/lib/index.js +170 -0
- package/lib/shared.js +181 -0
- package/locale/en.json +122 -0
- package/locale/zh.json +122 -0
- package/package.json +80 -4
|
@@ -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
|
+
}
|