@hwfee/dsh-git-bash 0.0.0-stage → 0.1.1
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/README.md +105 -2
- package/client/client.js +1135 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +16 -0
- package/lib/index.js +1144 -0
- package/lib/mode-route.js +204 -0
- package/lib/mode.js +244 -0
- package/lib/resolve.js +161 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +62 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,1144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell this plugin runs commands in, and the tool that asks it to.
|
|
3
|
+
*
|
|
4
|
+
* THE SEAM, and why this plugin does not touch it. `@deepseek-ai/dsh-shell`
|
|
5
|
+
* declares `ctx.shell` as a capability seam with ONE implementation per context
|
|
6
|
+
* ("loading a second throws, which is cordis' standard duplicate-service
|
|
7
|
+
* behavior"). On Windows the base bundle composes `pwsh-sandbox`; on POSIX it
|
|
8
|
+
* composes `bash-sandbox`. Both are `ctx.shell`.
|
|
9
|
+
*
|
|
10
|
+
* So the default-shell switch itself is the executor's job. What this plugin
|
|
11
|
+
* owns is the other half of the contract:
|
|
12
|
+
*
|
|
13
|
+
* 1. DECIDE which shell to use, from the user's `auto`/`pwsh`/`gitbash` mode.
|
|
14
|
+
* 2. PUBLISH that decision to the settings row and to the model, with the path
|
|
15
|
+
* it resolved to, so "which shell am I talking to" is never a guess.
|
|
16
|
+
* 3. EXPOSE `run_shell` — one command in the resolved shell, reporting which
|
|
17
|
+
* one ran it.
|
|
18
|
+
*
|
|
19
|
+
* @module @hwfee/dsh-git-bash
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execFile, spawn } from 'node:child_process'
|
|
23
|
+
import { DEFAULT_MODE, MODES, chooseShell, readMode } from './mode.js'
|
|
24
|
+
import { createResolver, execAsync } from './resolve.js'
|
|
25
|
+
import { createModeRoute } from './mode-route.js'
|
|
26
|
+
export const name = 'dsh-git-bash'
|
|
27
|
+
|
|
28
|
+
/** The `tools` registry is the only hard requirement. */
|
|
29
|
+
export const inject = ['tools']
|
|
30
|
+
|
|
31
|
+
/** The tool the model calls. */
|
|
32
|
+
export const TOOL_NAME = 'run_shell'
|
|
33
|
+
|
|
34
|
+
/** The page global carrying the resolved shell, read by the settings row. */
|
|
35
|
+
export const SHELL_GLOBAL = '__dshGitBash'
|
|
36
|
+
|
|
37
|
+
/** The Cordis event the browser half publishes its mode on. */
|
|
38
|
+
export const MODE_EVENT = 'git-bash/mode'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Register the tool, the mode listener, and the page injection.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} ctx the host context
|
|
44
|
+
*/
|
|
45
|
+
export function apply(ctx) {
|
|
46
|
+
const resolver = createResolver()
|
|
47
|
+
|
|
48
|
+
// The user's mode. The page owns the preference (it is the side with the
|
|
49
|
+
// settings UI), so it publishes; the host caches. Default `auto` covers the
|
|
50
|
+
// window before the first publish — and it is also the right answer for a
|
|
51
|
+
// first launch, since the page has no stored choice yet.
|
|
52
|
+
//
|
|
53
|
+
// TWO CHANNELS, ON PURPOSE:
|
|
54
|
+
// - the Cordis event reaches in-process callers (tests, an embedding host);
|
|
55
|
+
// - the HTTP route reaches the BROWSER, which lives in another process.
|
|
56
|
+
// Without it the host stays on `auto` forever and the row lies.
|
|
57
|
+
let mode = DEFAULT_MODE
|
|
58
|
+
const setMode = (next) => {
|
|
59
|
+
if (typeof next === 'string' && MODES.includes(next)) mode = next
|
|
60
|
+
}
|
|
61
|
+
ctx.on(MODE_EVENT, (next) => {
|
|
62
|
+
if (next !== null && typeof next === 'object') setMode(next.mode)
|
|
63
|
+
})
|
|
64
|
+
ctx.inject(['webServer'], (hostCtx) => {
|
|
65
|
+
const webServer = hostCtx.webServer
|
|
66
|
+
if (webServer === undefined || typeof webServer.register !== 'function') return
|
|
67
|
+
// `getOrigin()` throws before the listener binds; the fence reads it lazily.
|
|
68
|
+
const origin = () => webServer.getOrigin()
|
|
69
|
+
hostCtx.effect(
|
|
70
|
+
() => webServer.register(createModeRoute(setMode, { origin, readMode: () => mode })),
|
|
71
|
+
'dsh-git-bash: mode route',
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the current mode.
|
|
77
|
+
*
|
|
78
|
+
* Called on each use so a mode change applies to the NEXT call, but with a
|
|
79
|
+
* fresh memo per call: `auto` asks for two kinds, and both questions must see
|
|
80
|
+
* the same machine. A memo shared across calls would freeze the shell forever,
|
|
81
|
+
* which is the opposite of what a live setting needs.
|
|
82
|
+
*/
|
|
83
|
+
const current = () => resolveShell(mode, { resolve: memoized(resolver), platform: process.platform })
|
|
84
|
+
|
|
85
|
+
ctx.effect(
|
|
86
|
+
() => ctx.tools.register(createRunShellTool(current, {
|
|
87
|
+
// OPTIONAL ON PURPOSE. The host composes exactly one `ctx.shell` executor
|
|
88
|
+
// (pwsh-sandbox on Windows), but a profile may leave it out. `ctx.get`
|
|
89
|
+
// reads without an inject requirement, so the plugin still mounts — and
|
|
90
|
+
// `run_shell` then fails CLOSED rather than spawning unconfined. See
|
|
91
|
+
// `resolveSandboxDecision`.
|
|
92
|
+
getShell: () => (typeof ctx.get === 'function' ? ctx.get('shell') : undefined),
|
|
93
|
+
getWorkingDirectory: () => (typeof ctx.get === 'function' ? ctx.get('workingDirectory') : undefined),
|
|
94
|
+
// THE TWO SERVICES THAT MAKE THE DIRECT PATH ADMISSIBLE. Without these the
|
|
95
|
+
// session's mode cannot be resolved, and `run_shell` refuses instead of
|
|
96
|
+
// guessing.
|
|
97
|
+
getSandboxPolicy: () => (typeof ctx.get === 'function' ? ctx.get('sandboxPolicy') : undefined),
|
|
98
|
+
getSessions: () => (typeof ctx.get === 'function' ? ctx.get('sessions') : undefined),
|
|
99
|
+
})),
|
|
100
|
+
'dsh-git-bash: run_shell tool',
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// PUBLISH EVERY MODE'S RESOLUTION TO THE PAGE.
|
|
104
|
+
//
|
|
105
|
+
// The settings row needs to name what `auto` chose, and it is a BROWSER
|
|
106
|
+
// component: it cannot probe the filesystem. `webserver/index-inject` is the
|
|
107
|
+
// host's seam for a value the page needs before its first paint, computed
|
|
108
|
+
// where the process lives — the same seam cds-theme uses for its fonts base.
|
|
109
|
+
//
|
|
110
|
+
// SYNCHRONOUS AT INJECT TIME, deliberately: the inject fires while the HTML is
|
|
111
|
+
// assembled, so an async resolution that has not settled yet would inject
|
|
112
|
+
// `null` and the row would say "not found" on every launch. The probe costs
|
|
113
|
+
// ~77 ms once per page load, and it is memoized across the three modes.
|
|
114
|
+
//
|
|
115
|
+
// ALL THREE RESOLUTIONS, not just the active one.
|
|
116
|
+
//
|
|
117
|
+
// The page needs to re-render the status line the moment the user picks a
|
|
118
|
+
// different mode, and it cannot probe the filesystem. Injecting only the
|
|
119
|
+
// ACTIVE choice made the row contradict itself: the trigger said "Windows
|
|
120
|
+
// PowerShell" while the line under it still named the Git Bash path the page
|
|
121
|
+
// had been booted with. It also means the row can say what `auto` WOULD pick
|
|
122
|
+
// on this machine, which is the question a user choosing between the modes
|
|
123
|
+
// actually has.
|
|
124
|
+
ctx.on('webserver/index-inject', (table) => {
|
|
125
|
+
const probe = memoized(resolver)
|
|
126
|
+
const byMode = {}
|
|
127
|
+
for (const candidate of MODES) {
|
|
128
|
+
byMode[candidate] = describe(resolveShell(candidate, { resolve: probe, platform: process.platform }), candidate)
|
|
129
|
+
}
|
|
130
|
+
table.push({
|
|
131
|
+
kind: 'global',
|
|
132
|
+
name: SHELL_GLOBAL,
|
|
133
|
+
value: { active: byMode[mode], byMode },
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve one mode against this machine.
|
|
140
|
+
*
|
|
141
|
+
* THE HOST-SIDE ENTRY POINT. `mode.js` holds the decision table but is shared
|
|
142
|
+
* with the browser bundle, so it cannot import `node:child_process`; the
|
|
143
|
+
* executable probe lives in `resolve.js`. This is the one call that joins them,
|
|
144
|
+
* used by the tool and the injection alike so they can never disagree.
|
|
145
|
+
*
|
|
146
|
+
* The result is a VALUE, never a throw: `{ ok, mode, kind, path, source,
|
|
147
|
+
* reason }`. `source` explains WHICH branch ran — under `auto` that is the only
|
|
148
|
+
* evidence a fallback happened.
|
|
149
|
+
*
|
|
150
|
+
* @param {'auto' | 'pwsh' | 'gitbash'} [mode] the selected mode
|
|
151
|
+
* @param {object} [deps]
|
|
152
|
+
* @param {(kind: string) => object | null} [deps.resolve] a resolver, injected for tests
|
|
153
|
+
* @param {string} [deps.platform] `process.platform`, injected for tests
|
|
154
|
+
* @returns {{ ok: boolean, mode: string, kind: string | null, path: string | null, source: string, reason: string | null }}
|
|
155
|
+
*/
|
|
156
|
+
export function resolveShell(mode = DEFAULT_MODE, deps = {}) {
|
|
157
|
+
const platform = deps.platform ?? process.platform
|
|
158
|
+
const resolver = deps.resolve ?? memoized(createResolver({ platform }))
|
|
159
|
+
const decision = chooseShell(mode, { resolve: resolver, platform })
|
|
160
|
+
if (decision.ok !== true) {
|
|
161
|
+
return { ok: false, mode, kind: null, path: null, source: '', reason: decision.reason }
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
mode,
|
|
166
|
+
kind: decision.shell.kind,
|
|
167
|
+
path: decision.shell.path,
|
|
168
|
+
source: decision.shell.source,
|
|
169
|
+
reason: null,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Turn a resolution into the shape the page reads.
|
|
175
|
+
*
|
|
176
|
+
* A failure is not an empty value: the row must be able to say WHY nothing runs,
|
|
177
|
+
* and `reason` is the only place that survives the JSON injection. `source` and
|
|
178
|
+
* `kind` are what make `auto` readable — "git --exec-path (auto)" versus
|
|
179
|
+
* "known path (auto fallback)" is the whole difference between "the setting
|
|
180
|
+
* worked" and "the setting quietly did something else".
|
|
181
|
+
*
|
|
182
|
+
* @param {{ ok: boolean, path: string | null, kind: string | null, source: string, reason: string | null }} result
|
|
183
|
+
* the resolution
|
|
184
|
+
* @param {string} mode the mode it was made under
|
|
185
|
+
* @returns {object} the page payload
|
|
186
|
+
*/
|
|
187
|
+
export function describe(result, mode) {
|
|
188
|
+
if (result.ok !== true) {
|
|
189
|
+
return { mode, path: null, kind: null, source: null, reason: result.reason }
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
mode,
|
|
193
|
+
path: result.path,
|
|
194
|
+
kind: result.kind,
|
|
195
|
+
source: result.source,
|
|
196
|
+
reason: null,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The model-facing tool.
|
|
202
|
+
*
|
|
203
|
+
* `current` is a FUNCTION, not a value: the user can change the mode while a
|
|
204
|
+
* session is open, and the next call must use the new shell.
|
|
205
|
+
*
|
|
206
|
+
* @param {() => ({ ok: boolean, path: string | null, kind: string | null, reason: string | null })} current
|
|
207
|
+
* resolves the shell under the active mode
|
|
208
|
+
* @param {object} [services] optional host services, read lazily
|
|
209
|
+
* @param {() => object | undefined} [services.getShell] `ctx.shell`, the host executor
|
|
210
|
+
* @param {() => object | undefined} [services.getWorkingDirectory] `ctx.workingDirectory`
|
|
211
|
+
* @param {(path: string, args: string[], cwd?: string, signal?: AbortSignal) => Promise<object>} [services.run]
|
|
212
|
+
* the direct executor, injected for tests; defaults to the real spawn
|
|
213
|
+
* @returns the tool definition `ctx.tools.register` expects
|
|
214
|
+
*/
|
|
215
|
+
export function createRunShellTool(current, services = {}) {
|
|
216
|
+
return {
|
|
217
|
+
name: TOOL_NAME,
|
|
218
|
+
description:
|
|
219
|
+
'Run one shell command and return its output. '
|
|
220
|
+
+ 'The interpreter is whatever the user configured: Git Bash (POSIX), '
|
|
221
|
+
+ 'PowerShell, or automatic. The result names the executable that ran it, so '
|
|
222
|
+
+ 'check that line before assuming which quoting and variable syntax apply. '
|
|
223
|
+
+ 'Each call runs in a fresh shell in the working directory; pass an absolute '
|
|
224
|
+
+ 'path instead of relying on a previous `cd`.',
|
|
225
|
+
// RAW JSON SCHEMA. `ctx.tools.register()` stores this as the wire schema and
|
|
226
|
+
// hands it to the provider verbatim; the `defineTool` author dialect
|
|
227
|
+
// (`{ name: { type, required: true } }`) is rejected at request time with
|
|
228
|
+
// `is not of type "object"`. See docs/genui-audit.md for that incident.
|
|
229
|
+
parameters: {
|
|
230
|
+
type: 'object',
|
|
231
|
+
additionalProperties: false,
|
|
232
|
+
required: ['command'],
|
|
233
|
+
properties: {
|
|
234
|
+
command: {
|
|
235
|
+
type: 'string',
|
|
236
|
+
description: 'The command to run. Passed to the resolved shell as a single command string.',
|
|
237
|
+
},
|
|
238
|
+
workdir: {
|
|
239
|
+
type: 'string',
|
|
240
|
+
description: 'Absolute directory to run in. Defaults to the session working directory.',
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
output: {
|
|
245
|
+
schema: {
|
|
246
|
+
type: 'object',
|
|
247
|
+
additionalProperties: false,
|
|
248
|
+
required: ['shell', 'exitCode', 'stdout'],
|
|
249
|
+
properties: {
|
|
250
|
+
shell: { type: 'string' },
|
|
251
|
+
kind: { type: 'string' },
|
|
252
|
+
exitCode: { type: 'integer' },
|
|
253
|
+
stdout: { type: 'string' },
|
|
254
|
+
stderr: { type: 'string' },
|
|
255
|
+
// WHERE THE COMMAND RAN. 'host' crossed ctx.shell (sandbox, approval
|
|
256
|
+
// and cancellation semantics apply); 'direct' used the plugin's own
|
|
257
|
+
// spawn because the host cannot start this shell. The renderer always
|
|
258
|
+
// spells this out — a silently unconfined command would be the worst
|
|
259
|
+
// possible failure mode.
|
|
260
|
+
via: { type: 'string' },
|
|
261
|
+
note: { type: 'string' },
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
render(_args, value) {
|
|
265
|
+
// THE PATH IS ALWAYS STATED. "host" means the command crossed
|
|
266
|
+
// `ctx.shell` (sandbox / cancellation apply); "direct" means the plugin
|
|
267
|
+
// spawned it itself because the host cannot start this shell. Leaving
|
|
268
|
+
// the host case unmarked made the two indistinguishable in the result.
|
|
269
|
+
const route = value.via === 'host' ? ' · host' : value.via === 'direct' ? ' · direct' : ''
|
|
270
|
+
const parts = [`[${value.kind} · ${value.shell}] exit ${value.exitCode}${route}`]
|
|
271
|
+
if (typeof value.note === 'string' && value.note !== '') parts.push(value.note)
|
|
272
|
+
if (value.stdout !== '') parts.push(value.stdout)
|
|
273
|
+
if (value.stderr !== '') parts.push('stderr: ' + value.stderr)
|
|
274
|
+
return [{ type: 'text', text: parts.join('\n') }]
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
async execute(args, exec) {
|
|
278
|
+
const resolved = current()
|
|
279
|
+
if (resolved.ok !== true) {
|
|
280
|
+
throw new Error(messageFor(resolved.reason))
|
|
281
|
+
}
|
|
282
|
+
const workdir = await resolveWorkdir(args, exec, services.getWorkingDirectory)
|
|
283
|
+
const shell = {
|
|
284
|
+
kind: resolved.kind,
|
|
285
|
+
path: resolved.path,
|
|
286
|
+
...(workdir === undefined ? {} : { workdir }),
|
|
287
|
+
}
|
|
288
|
+
const platform = services.platform ?? process.platform
|
|
289
|
+
const hostShell = typeof services.getShell === 'function' ? services.getShell() : undefined
|
|
290
|
+
const hasHost = hostShell !== undefined
|
|
291
|
+
&& typeof hostShell.execute === 'function'
|
|
292
|
+
&& typeof hostShell.resolve === 'function'
|
|
293
|
+
|
|
294
|
+
// THE SESSION'S SANDBOX POLICY, RESOLVED BEFORE ANYTHING SPAWNS.
|
|
295
|
+
//
|
|
296
|
+
// This plugin used to ignore it entirely, and that is a fail-OPEN bug, not
|
|
297
|
+
// a missing nicety: on Windows the Git Bash path cannot delegate, so it
|
|
298
|
+
// spawned through `child_process` — outside the sandbox, outside approval,
|
|
299
|
+
// whatever mode the session was in. A `read-only` session could still
|
|
300
|
+
// write, and the old code only *said* so in the result, after the command
|
|
301
|
+
// had already run. A disclosure printed afterwards is not a control.
|
|
302
|
+
//
|
|
303
|
+
// The policy is resolved here, once, and it decides whether the direct
|
|
304
|
+
// path is allowed at all.
|
|
305
|
+
const decision = resolveSandboxDecision(exec, services)
|
|
306
|
+
if (decision.ok !== true) throw new Error(messageFor(decision.reason, decision.detail))
|
|
307
|
+
|
|
308
|
+
// WHICH MODES MAY CROSS THE HOST BOUNDARY.
|
|
309
|
+
//
|
|
310
|
+
// PowerShell does: the host executor IS pwsh on Windows, and nesting a
|
|
311
|
+
// pwsh child through it works. Git Bash on Windows does NOT: the sandboxed
|
|
312
|
+
// pwsh cannot create the MSYS process (NtCreateDirectoryObject,
|
|
313
|
+
// 0xC0000022 — reproduced live), so delegating would turn every command
|
|
314
|
+
// into a spawn failure. POSIX has one shell, so it always delegates.
|
|
315
|
+
const delegate = shouldDelegate(shell.kind, platform, hasHost)
|
|
316
|
+
// The resolved policy rides into the executor, so the session's actual mode
|
|
317
|
+
// is enforced there instead of the executor falling back to its own default
|
|
318
|
+
// — which was the second half of the same fail-open bug: the request used
|
|
319
|
+
// to omit `sandboxPolicy`, so even the delegated path ignored the session.
|
|
320
|
+
if (delegate) return runThroughHost(hostShell, shell, args, exec, decision.policy)
|
|
321
|
+
|
|
322
|
+
// FROM HERE ON THE COMMAND RUNS WITHOUT THE SANDBOX, so the ONLY admissible
|
|
323
|
+
// case is a session that explicitly grants full access. Anything else —
|
|
324
|
+
// read-only, workspace-write, an unrecognised mode, or a policy service
|
|
325
|
+
// that could not be consulted — refuses, and refuses BEFORE spawning.
|
|
326
|
+
if (decision.mode !== 'danger-full-access') {
|
|
327
|
+
throw new Error(messageFor('sandbox-denied', decision.mode))
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// THE SIGNAL CROSSES THIS PATH TOO. It used to be dropped here: the host
|
|
331
|
+
// path forwarded `exec.signal` to the executor, while the direct path
|
|
332
|
+
// called `runInShell` with only the arguments — so a cancelled tool call
|
|
333
|
+
// kept its shell (and whatever it had started) running to completion, and
|
|
334
|
+
// the late output was returned into a turn the user had already stopped.
|
|
335
|
+
// Measured before the fix: aborting a `sleep 3` call still resolved after
|
|
336
|
+
// 3.7 s with stdout "LATE". See test/tool.test.mjs.
|
|
337
|
+
//
|
|
338
|
+
// `services.run` is the executor seam: it defaults to the real spawn and
|
|
339
|
+
// exists so the cancellation guarantee above can be asserted without
|
|
340
|
+
// starting a process.
|
|
341
|
+
const direct = await runInShell(
|
|
342
|
+
{ kind: shell.kind, path: shell.path },
|
|
343
|
+
{ ...args, workdir: shell.workdir, signal: exec.signal },
|
|
344
|
+
services.run,
|
|
345
|
+
)
|
|
346
|
+
// POSITIVE, NOT RESIDUAL. The old note existed to admit that the call had
|
|
347
|
+
// bypassed the sandbox; now the call is only here because the session
|
|
348
|
+
// granted full access, so the note states the permission instead of
|
|
349
|
+
// excusing the bypass.
|
|
350
|
+
const note = `Ran directly: the session grants danger-full-access, so this command `
|
|
351
|
+
+ `did not cross the sandbox${hasHost ? '' : ' (this profile also has no ctx.shell executor)'}.`
|
|
352
|
+
return { ...direct, via: 'direct', note }
|
|
353
|
+
},
|
|
354
|
+
presentCall: (args) => ({
|
|
355
|
+
card: 'generic',
|
|
356
|
+
title: typeof args?.command === 'string' ? args.command.slice(0, 80) : 'Run shell command',
|
|
357
|
+
kind: 'execute',
|
|
358
|
+
rawInput: args?.command,
|
|
359
|
+
}),
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Resolve the sandbox policy for one call, and decide whether the direct path is
|
|
365
|
+
* admissible at all.
|
|
366
|
+
*
|
|
367
|
+
* FAIL CLOSED IN EVERY DIRECTION. The honest question is not "did the caller
|
|
368
|
+
* pass a policy" but "what is this session actually allowed to do". Three facts
|
|
369
|
+
* make that answerable, and each has a failure branch that must deny:
|
|
370
|
+
*
|
|
371
|
+
* 1. `ctx.sandboxPolicy.resolve({ session })` is the host's single owner of
|
|
372
|
+
* the per-call policy (`defaultMode` under the session's logged mode).
|
|
373
|
+
* Without that service the plugin cannot learn the mode, so it denies —
|
|
374
|
+
* "unconfined because I could not ask" is the same hazard as
|
|
375
|
+
* "unconfined because nobody checked".
|
|
376
|
+
* 2. A session is only available as `exec.agent`; `sessions.get(agent.id)`
|
|
377
|
+
* turns it into the `Session` the policy service expects. A throw, a
|
|
378
|
+
* missing store, or a session that is not live all deny.
|
|
379
|
+
* 3. An unrecognised `mode` denies. A future host mode is not evidence of
|
|
380
|
+
* permission.
|
|
381
|
+
*
|
|
382
|
+
* The resolved policy is RETURNED rather than re-derived downstream, so the
|
|
383
|
+
* delegated path enforces exactly what the direct path was judged against.
|
|
384
|
+
*
|
|
385
|
+
* @param {object} [exec] the tool execution context (`agent`, `signal`)
|
|
386
|
+
* @param {object} [services] the injected service readers
|
|
387
|
+
* @returns {{ ok: true, mode: string, policy: object } | { ok: false, reason: string, detail?: string }}
|
|
388
|
+
* the decision; a failure is a VALUE, so `execute` can name the fix
|
|
389
|
+
*/
|
|
390
|
+
export function resolveSandboxDecision(exec, services = {}) {
|
|
391
|
+
const getPolicy = typeof services.getSandboxPolicy === 'function'
|
|
392
|
+
? services.getSandboxPolicy
|
|
393
|
+
: () => undefined
|
|
394
|
+
const getSessions = typeof services.getSessions === 'function'
|
|
395
|
+
? services.getSessions
|
|
396
|
+
: () => undefined
|
|
397
|
+
|
|
398
|
+
let policyService
|
|
399
|
+
try {
|
|
400
|
+
policyService = getPolicy()
|
|
401
|
+
} catch {
|
|
402
|
+
return { ok: false, reason: 'sandbox-unverified', detail: 'the sandbox-policy service threw' }
|
|
403
|
+
}
|
|
404
|
+
if (policyService === undefined || policyService === null) {
|
|
405
|
+
return { ok: false, reason: 'sandbox-unverified', detail: 'this profile composes no ctx.sandboxPolicy' }
|
|
406
|
+
}
|
|
407
|
+
if (typeof policyService.resolve !== 'function') {
|
|
408
|
+
return { ok: false, reason: 'sandbox-unverified', detail: 'the sandbox-policy service has no resolve()' }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// THE SESSION IS THE UNIT THE MODE IS LOGGED AGAINST. Without one there is no
|
|
412
|
+
// mode to enforce, so the deployment default cannot be assumed to be
|
|
413
|
+
// permissive.
|
|
414
|
+
const agent = exec === undefined || exec === null ? undefined : exec.agent
|
|
415
|
+
if (agent === undefined || agent === null || typeof agent.id !== 'string') {
|
|
416
|
+
return { ok: false, reason: 'sandbox-unverified', detail: 'the call carries no session' }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
let sessions
|
|
420
|
+
try {
|
|
421
|
+
sessions = getSessions()
|
|
422
|
+
} catch {
|
|
423
|
+
return { ok: false, reason: 'sandbox-unverified', detail: 'the sessions service threw' }
|
|
424
|
+
}
|
|
425
|
+
if (sessions === undefined || sessions === null || typeof sessions.get !== 'function') {
|
|
426
|
+
return { ok: false, reason: 'sandbox-unverified', detail: 'this profile composes no ctx.sessions' }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
let session
|
|
430
|
+
try {
|
|
431
|
+
session = sessions.get(agent.id)
|
|
432
|
+
} catch {
|
|
433
|
+
return { ok: false, reason: 'sandbox-unverified', detail: 'looking up the session threw' }
|
|
434
|
+
}
|
|
435
|
+
if (session === undefined || session === null) {
|
|
436
|
+
return { ok: false, reason: 'sandbox-unverified', detail: 'the session is not live' }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
let policy
|
|
440
|
+
try {
|
|
441
|
+
policy = policyService.resolve({ session })
|
|
442
|
+
} catch (error) {
|
|
443
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
444
|
+
return { ok: false, reason: 'sandbox-unverified', detail: 'resolve() threw: ' + detail }
|
|
445
|
+
}
|
|
446
|
+
if (policy === null || typeof policy !== 'object' || typeof policy.mode !== 'string') {
|
|
447
|
+
return { ok: false, reason: 'sandbox-unverified', detail: 'resolve() returned no usable policy' }
|
|
448
|
+
}
|
|
449
|
+
if (!SANDBOX_MODES.includes(policy.mode)) {
|
|
450
|
+
return { ok: false, reason: 'sandbox-unverified', detail: 'unknown sandbox mode "' + policy.mode + '"' }
|
|
451
|
+
}
|
|
452
|
+
return { ok: true, mode: policy.mode, policy }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* The sandbox modes this host defines, mirroring `SandboxMode`.
|
|
457
|
+
*
|
|
458
|
+
* Listed rather than trusted, so a typo or a future value fails closed
|
|
459
|
+
* (`resolveSandboxDecision` denies anything outside this set).
|
|
460
|
+
*/
|
|
461
|
+
export const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access']
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The sentence a user reads when the chosen shell is missing.
|
|
465
|
+
*
|
|
466
|
+
* Each reason names the fix, because the failure is always one of three
|
|
467
|
+
* configuration facts and never the command's fault.
|
|
468
|
+
*
|
|
469
|
+
* @param {string} reason the failure code
|
|
470
|
+
* @param {string} [detail] the specific fact behind a refusal
|
|
471
|
+
* @returns {string} the message
|
|
472
|
+
*/
|
|
473
|
+
export function messageFor(reason, detail) {
|
|
474
|
+
switch (reason) {
|
|
475
|
+
case 'gitbash-not-found':
|
|
476
|
+
return 'Git Bash is selected but was not found. Install Git for Windows, or set '
|
|
477
|
+
+ 'DSH_GIT_BASH to its bash.exe. (The plugin will not silently switch to PowerShell: '
|
|
478
|
+
+ 'POSIX commands would fail in confusing ways under it.)'
|
|
479
|
+
case 'pwsh-not-found':
|
|
480
|
+
return 'PowerShell is selected but neither pwsh.exe nor Windows PowerShell was found.'
|
|
481
|
+
case 'no-shell-for-platform':
|
|
482
|
+
return 'The selected shell mode is not available on this platform.'
|
|
483
|
+
case 'no-shell-found':
|
|
484
|
+
return 'No usable shell was found. Install Git for Windows, or set DSH_GIT_BASH.'
|
|
485
|
+
// REFUSED, NOT FAILED. The command never started, which is the point: these
|
|
486
|
+
// two sentences have to say so, and name the way forward, or the model will
|
|
487
|
+
// retry the same call or assume the command ran and failed.
|
|
488
|
+
//
|
|
489
|
+
// THE SENTENCE MUST NOT OFFER TO ESCALATE. An earlier version ended with
|
|
490
|
+
// "or raise this session to danger-full-access", which reads as an
|
|
491
|
+
// instruction the model can follow — and a model that raises its own
|
|
492
|
+
// sandbox mode to get its command through has defeated the control this
|
|
493
|
+
// refusal exists to enforce. The refusal therefore names the ONE safe
|
|
494
|
+
// redirection (PowerShell, which runs inside the sandbox) and, when the
|
|
495
|
+
// command is genuinely a POSIX-only script, says plainly that the user has
|
|
496
|
+
// to choose. It never invites either the model or its reader to widen the
|
|
497
|
+
// session's permissions.
|
|
498
|
+
case 'sandbox-denied':
|
|
499
|
+
return 'run_shell refused to run in Git Bash: this session is in "'
|
|
500
|
+
+ String(detail === undefined ? 'an unverified' : detail) + '" mode, and Git Bash on Windows '
|
|
501
|
+
+ 'cannot run inside the host sandbox (the sandboxed pwsh cannot create the MSYS '
|
|
502
|
+
+ 'process, 0xC0000022). NOTHING WAS EXECUTED. Do not retry the same command here and do '
|
|
503
|
+
+ 'not change the sandbox mode to make it work. Choose one of these instead: run the '
|
|
504
|
+
+ 'command with the pwsh / run_shell tool in Windows PowerShell mode, which enforces the '
|
|
505
|
+
+ 'sandbox; rewrite it as an equivalent PowerShell command; or tell the user that this '
|
|
506
|
+
+ 'specific command needs a POSIX shell and let them decide.'
|
|
507
|
+
case 'sandbox-unverified':
|
|
508
|
+
return 'run_shell refused to run: the sandbox policy for this session could not be '
|
|
509
|
+
+ 'determined (' + String(detail === undefined ? 'no reason given' : detail) + '), and Git Bash '
|
|
510
|
+
+ 'on Windows cannot run inside the sandbox anyway. NOTHING WAS EXECUTED. Running it '
|
|
511
|
+
+ 'unconfined without knowing the session mode is not an acceptable fallback. Use the '
|
|
512
|
+
+ 'pwsh / run_shell tool in Windows PowerShell mode instead; if that is unavailable, this '
|
|
513
|
+
+ 'profile cannot safely run shell commands and the user has to fix the composition.'
|
|
514
|
+
default:
|
|
515
|
+
return 'The configured shell could not be started: ' + reason
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Run one command in a resolved shell.
|
|
521
|
+
*
|
|
522
|
+
* HOW THE COMMAND IS PASSED depends on the family, and getting it wrong is
|
|
523
|
+
* silent: `-lc` is bash's "login shell, run this string", while PowerShell's
|
|
524
|
+
* equivalent is `-NoProfile -Command`. Handing `-lc` to PowerShell makes it read
|
|
525
|
+
* `-l` as a switch and complain about an unknown argument.
|
|
526
|
+
*
|
|
527
|
+
* A NONZERO EXIT IS NOT AN ERROR: that is the command's own result, and the
|
|
528
|
+
* model needs to see it (`grep` that matched nothing exits 1).
|
|
529
|
+
*
|
|
530
|
+
* @param {{ kind: string, path: string }} shell the executable
|
|
531
|
+
* @param {{ command: string, workdir?: string, signal?: AbortSignal }} args the call arguments
|
|
532
|
+
* @param {(path: string, args: string[], cwd?: string, signal?: AbortSignal) => Promise<object>} [run]
|
|
533
|
+
* the executor, injected for tests
|
|
534
|
+
* @param {AbortSignal} [signal] cancellation, forwarded to the executor
|
|
535
|
+
* @returns {Promise<object>} the tool result
|
|
536
|
+
*/
|
|
537
|
+
export async function runInShell(shell, args, run = defaultRun, signal = args?.signal) {
|
|
538
|
+
const command = typeof args?.command === 'string' ? args.command : ''
|
|
539
|
+
if (command.trim() === '') throw new Error('run_shell needs a non-empty `command`')
|
|
540
|
+
// AN ALREADY-CANCELLED CALL MUST NOT SPAWN AT ALL. The host can cancel between
|
|
541
|
+
// dispatch and body start; spawning first and killing after would still start
|
|
542
|
+
// the command (a `rm`, a `git push`) for a turn that is already over.
|
|
543
|
+
if (signal?.aborted === true) throw abortError(signal)
|
|
544
|
+
|
|
545
|
+
const argv = shell.kind === 'gitbash' || shell.kind === 'posix-bash'
|
|
546
|
+
? ['-lc', command]
|
|
547
|
+
: ['-NoProfile', '-NonInteractive', '-Command', command]
|
|
548
|
+
|
|
549
|
+
const outcome = await run(shell.path, argv, args?.workdir, signal)
|
|
550
|
+
// A kill that lands while the child is exiting still resolves with a numeric
|
|
551
|
+
// exit code, so the abort is reported from the signal rather than inferred
|
|
552
|
+
// from the code: otherwise a cancelled call would look like a normal failure
|
|
553
|
+
// of the command the user never let finish.
|
|
554
|
+
if (signal?.aborted === true) throw abortError(signal)
|
|
555
|
+
return {
|
|
556
|
+
shell: shell.path,
|
|
557
|
+
kind: shell.kind,
|
|
558
|
+
exitCode: outcome.exitCode,
|
|
559
|
+
stdout: outcome.stdout,
|
|
560
|
+
stderr: outcome.stderr,
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The error a cancelled call reports.
|
|
566
|
+
*
|
|
567
|
+
* Named `AbortError` because that is the name the host's tool layer and the
|
|
568
|
+
* model both recognise as cancellation rather than as the command failing.
|
|
569
|
+
*
|
|
570
|
+
* IT ALWAYS CARRIES `stderr`. Two call sites cancel a call — before dispatch and
|
|
571
|
+
* during the run — and the host surfaces `stderr` while a plain `error.message`
|
|
572
|
+
* may never reach the user. Leaving it undefined on one of them made the cleanup
|
|
573
|
+
* limitation invisible on exactly the path that had no cleanup to do, which is
|
|
574
|
+
* the shape that later reads as "the kill worked".
|
|
575
|
+
*
|
|
576
|
+
* @param {AbortSignal} [signal] the signal that fired
|
|
577
|
+
* @param {string} [note] the cleanup note; defaults to the standard cancel marker
|
|
578
|
+
* @returns {Error} an error whose `name` is `AbortError`
|
|
579
|
+
*/
|
|
580
|
+
export function abortError(signal, note) {
|
|
581
|
+
const reason = signal?.reason
|
|
582
|
+
const detail = typeof note === 'string' && note !== '' ? note : terminationNote('abort', {})
|
|
583
|
+
const base = reason instanceof Error && reason.name !== 'AbortError' && reason.message !== ''
|
|
584
|
+
? 'run_shell was cancelled: ' + reason.message
|
|
585
|
+
: 'run_shell was cancelled before the command finished'
|
|
586
|
+
// THE NOTE GOES IN BOTH PLACES. `stderr` is what the host renders; `message` is
|
|
587
|
+
// what a log or a bare `catch` prints. A marker that reaches only one of them
|
|
588
|
+
// is invisible on the other path.
|
|
589
|
+
const error = new Error(base + '\n' + detail.trimEnd())
|
|
590
|
+
error.name = 'AbortError'
|
|
591
|
+
error.stderr = detail
|
|
592
|
+
return error
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Quote one string for PowerShell's `-Command` argument.
|
|
597
|
+
*
|
|
598
|
+
* A single-quoted PowerShell string is literal; the ONLY escape is doubling an
|
|
599
|
+
* inner quote. Newlines are legal inside it, so an arbitrary command string
|
|
600
|
+
* survives without the parser re-reading `$`, `;` or `&`.
|
|
601
|
+
*
|
|
602
|
+
* @param {string} value the literal to carry
|
|
603
|
+
* @returns {string} a single-quoted PowerShell string
|
|
604
|
+
*/
|
|
605
|
+
export function quotePowerShell(value) {
|
|
606
|
+
return "'" + String(value == null ? '' : value).replace(/'/g, "''") + "'"
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Quote one string for a POSIX shell's `-lc` argument.
|
|
611
|
+
*
|
|
612
|
+
* @param {string} value the literal to carry
|
|
613
|
+
* @returns {string} a single-quoted shell word
|
|
614
|
+
*/
|
|
615
|
+
export function quoteSh(value) {
|
|
616
|
+
return "'" + String(value == null ? '' : value).replace(/'/g, "'\\''") + "'"
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Whether one resolved shell can run through the host executor.
|
|
621
|
+
*
|
|
622
|
+
* Extracted so the decision is testable without spawning anything. See the
|
|
623
|
+
* call site for why Git Bash on Windows is excluded: the sandbox cannot create
|
|
624
|
+
* the MSYS process, so delegation would fail every command.
|
|
625
|
+
*
|
|
626
|
+
* @param {string} kind the resolved shell kind
|
|
627
|
+
* @param {string} platform `process.platform`
|
|
628
|
+
* @param {boolean} hasHost whether a usable `ctx.shell` is composed
|
|
629
|
+
* @returns {boolean} whether to call `runThroughHost`
|
|
630
|
+
*/
|
|
631
|
+
export function shouldDelegate(kind, platform, hasHost) {
|
|
632
|
+
if (hasHost !== true) return false
|
|
633
|
+
if (platform === 'win32') return kind === 'pwsh' || kind === 'windows-powershell'
|
|
634
|
+
return kind === 'posix-bash'
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The command a HOST EXECUTOR runs to start the SELECTED interpreter.
|
|
639
|
+
*
|
|
640
|
+
* Why not just hand the user's command to `ctx.shell`: on Windows the executor
|
|
641
|
+
* is always pwsh, and the mode may have chosen Git Bash. The delegation has to
|
|
642
|
+
* name the executable explicitly, or "Git Bash mode" would quietly run
|
|
643
|
+
* PowerShell. The quoting above is what makes an arbitrary command (quotes,
|
|
644
|
+
* `$`, `&&`, newlines) survive the extra shell layer.
|
|
645
|
+
*
|
|
646
|
+
* @param {{ kind: string, path: string }} shell the selected interpreter
|
|
647
|
+
* @param {string} command the user's command
|
|
648
|
+
* @param {string} [platform] `process.platform`
|
|
649
|
+
* @returns {string} the host-executor command string
|
|
650
|
+
*/
|
|
651
|
+
export function delegatedCommand(shell, command, platform = process.platform) {
|
|
652
|
+
const script = String(command == null ? '' : command)
|
|
653
|
+
if (platform === 'win32') {
|
|
654
|
+
const exe = quotePowerShell(shell.path)
|
|
655
|
+
if (shell.kind === 'gitbash' || shell.kind === 'posix-bash') {
|
|
656
|
+
return '& ' + exe + ' -lc ' + quotePowerShell(script)
|
|
657
|
+
}
|
|
658
|
+
return '& ' + exe + ' -NoProfile -NonInteractive -Command ' + quotePowerShell(script)
|
|
659
|
+
}
|
|
660
|
+
return quoteSh(shell.path) + ' -lc ' + quoteSh(script)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Run one command through the host's shell executor.
|
|
665
|
+
*
|
|
666
|
+
* The executor owns sandboxing, approval facts, cancellation and process
|
|
667
|
+
* lifetime; this function only translates the request and the result into
|
|
668
|
+
* `run_shell`'s shape. `result()` rejects on infrastructure failures, which
|
|
669
|
+
* propagate to the tool layer like any other tool error.
|
|
670
|
+
*
|
|
671
|
+
* THE POLICY TRAVELS WITH THE REQUEST. Omitting `sandboxPolicy` used to make the
|
|
672
|
+
* executor apply its own default, so a `read-only` session's command ran under
|
|
673
|
+
* whatever the deployment default happened to be — the session mode was ignored
|
|
674
|
+
* on the delegated path too, not just the direct one. The resolved policy is
|
|
675
|
+
* passed through verbatim.
|
|
676
|
+
*
|
|
677
|
+
* @param {object} hostShell the `ctx.shell` service
|
|
678
|
+
* @param {{ kind: string, path: string, workdir?: string }} shell the selected interpreter
|
|
679
|
+
* @param {{ command?: string, workdir?: string }} args the call arguments
|
|
680
|
+
* @param {{ signal?: AbortSignal }} [exec] the tool execution context
|
|
681
|
+
* @param {object} [policy] the resolved `SandboxExecutionPolicy` for this call
|
|
682
|
+
* @returns {Promise<object>} the tool result
|
|
683
|
+
*/
|
|
684
|
+
export async function runThroughHost(hostShell, shell, args, exec = {}, policy) {
|
|
685
|
+
const command = typeof args?.command === 'string' ? args.command : ''
|
|
686
|
+
if (command.trim() === '') throw new Error('run_shell needs a non-empty `command`')
|
|
687
|
+
// AN ALREADY-CANCELLED CALL MUST NOT REACH THE EXECUTOR. The executor would
|
|
688
|
+
// start the process and only then notice the signal, which is the same hazard
|
|
689
|
+
// the direct path guards against.
|
|
690
|
+
if (exec.signal?.aborted === true) throw abortError(exec.signal)
|
|
691
|
+
const workdir = typeof args?.workdir === 'string' && args.workdir !== ''
|
|
692
|
+
? args.workdir
|
|
693
|
+
: shell.workdir
|
|
694
|
+
const request = {
|
|
695
|
+
command: delegatedCommand(shell, command, process.platform),
|
|
696
|
+
...(workdir === undefined ? {} : { workdir }),
|
|
697
|
+
...(exec.signal === undefined ? {} : { signal: exec.signal }),
|
|
698
|
+
...(policy === undefined || policy === null ? {} : { sandboxPolicy: policy }),
|
|
699
|
+
}
|
|
700
|
+
const handle = await hostShell.execute(hostShell.resolve(request))
|
|
701
|
+
const result = await handle.result()
|
|
702
|
+
// A CANCELLED RUN IS NOT A RESULT. The executor's contract says an abort
|
|
703
|
+
// RESOLVES with `aborted: true` and no exit code; reporting that as
|
|
704
|
+
// `exitCode: -1` would hand the model a successful tool result for a command
|
|
705
|
+
// the user stopped. It becomes the same `AbortError` the direct path throws, so
|
|
706
|
+
// the host sees one cancellation shape from both routes.
|
|
707
|
+
if (result?.aborted === true || exec.signal?.aborted === true) {
|
|
708
|
+
// The executor owned the tree, so cleanup truth lives there: it reports
|
|
709
|
+
// whether the kill finished, and that is what the note says instead of a
|
|
710
|
+
// claim this layer cannot verify.
|
|
711
|
+
const note = typeof result?.stderr?.text === 'string' ? result.stderr.text : ''
|
|
712
|
+
throw abortError(exec.signal, appendNote(note, terminationNote('abort', {
|
|
713
|
+
treeConfirmed: null,
|
|
714
|
+
treeDetail: 'cleanup was the host executor\'s responsibility',
|
|
715
|
+
})))
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
shell: shell.path,
|
|
719
|
+
kind: shell.kind,
|
|
720
|
+
via: 'host',
|
|
721
|
+
// A signal kill has no exit code. Keep the integer contract the schema
|
|
722
|
+
// declares; the renderer states the signal separately when it matters.
|
|
723
|
+
exitCode: Number.isInteger(result?.exitCode) ? result.exitCode : -1,
|
|
724
|
+
stdout: typeof result?.stdout?.text === 'string' ? result.stdout.text : '',
|
|
725
|
+
stderr: typeof result?.stderr?.text === 'string' ? result.stderr.text : '',
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Resolve the working directory for one call.
|
|
731
|
+
*
|
|
732
|
+
* The description promises "the session working directory" when `workdir` is
|
|
733
|
+
* absent. The host's `workingDirectory.ensure()` is where that fact lives; it is
|
|
734
|
+
* optional (a minimal profile has no such service), in which case the executor
|
|
735
|
+
* keeps its own default.
|
|
736
|
+
*
|
|
737
|
+
* @param {{ workdir?: string }} args the call arguments
|
|
738
|
+
* @param {{ agent?: object, signal?: AbortSignal }} [exec] the tool execution context
|
|
739
|
+
* @param {() => object | undefined} [getService] reads `ctx.workingDirectory`
|
|
740
|
+
* @returns {Promise<string | undefined>} the directory, when one is known
|
|
741
|
+
*/
|
|
742
|
+
export async function resolveWorkdir(args, exec, getService) {
|
|
743
|
+
if (typeof args?.workdir === 'string' && args.workdir !== '') return args.workdir
|
|
744
|
+
const service = typeof getService === 'function' ? getService() : undefined
|
|
745
|
+
if (service === undefined || exec?.agent === undefined || typeof service.ensure !== 'function') return undefined
|
|
746
|
+
try {
|
|
747
|
+
return await service.ensure(exec.agent, exec.signal)
|
|
748
|
+
} catch {
|
|
749
|
+
return undefined
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* The deadline for one command, and how long a kill may take before we stop
|
|
755
|
+
* waiting for it. Both are constants rather than tool fields: widening the wire
|
|
756
|
+
* schema is a separate decision, and these two are policy, not caller input.
|
|
757
|
+
*/
|
|
758
|
+
export const DEFAULT_TIMEOUT_MS = 120_000
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* THE BOUND ON CLEANUP.
|
|
762
|
+
*
|
|
763
|
+
* A killed shell does not necessarily close its stdio: a grandchild that
|
|
764
|
+
* inherited the pipe keeps `close` from firing until IT exits — measured in the
|
|
765
|
+
* hours for a long-lived child. So cancellation gets a bounded window to do its
|
|
766
|
+
* work, after which the pipes are destroyed and the call returns anyway. The
|
|
767
|
+
* number is 3–5 s because that is long enough for `taskkill /T /F` to finish and
|
|
768
|
+
* short enough that a user pressing stop sees a result.
|
|
769
|
+
*/
|
|
770
|
+
export const TREE_KILL_GRACE_MS = 4_000
|
|
771
|
+
|
|
772
|
+
/** Marker prefixes appended to stderr, so the limit is stated where the model reads it. */
|
|
773
|
+
export const TIMEOUT_MARKER = '[dsh-git-bash] timed out'
|
|
774
|
+
export const ABORT_MARKER = '[dsh-git-bash] cancelled'
|
|
775
|
+
export const MAXBUFFER_MARKER = '[dsh-git-bash] output limit exceeded'
|
|
776
|
+
/** A death this layer did not cause, so `-1` is never mistaken for a timeout or a cap. */
|
|
777
|
+
export const KILLED_MARKER = '[dsh-git-bash] terminated'
|
|
778
|
+
/** The honest "I could not prove the tree died" marker. Never emitted as success. */
|
|
779
|
+
export const TREE_MARKER = '[dsh-git-bash] process tree may still be running'
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* The stderr note for one termination.
|
|
783
|
+
*
|
|
784
|
+
* @param {'timeout' | 'abort' | 'maxBuffer' | 'killed'} kind what stopped the command
|
|
785
|
+
* @param {{ timeoutMs?: number, treeConfirmed?: boolean | null, treeDetail?: string, runtimeEnforced?: boolean, signalName?: string }} facts
|
|
786
|
+
* `runtimeEnforced` marks the case where NODE reported the limit instead
|
|
787
|
+
* of us — then the sentence must not claim we stopped anything.
|
|
788
|
+
* `signalName` names the signal for a death this layer did not cause.
|
|
789
|
+
* @returns {string} the note, ending with a newline
|
|
790
|
+
*/
|
|
791
|
+
export function terminationNote(kind, facts = {}) {
|
|
792
|
+
const lines = []
|
|
793
|
+
const runtime = facts.runtimeEnforced === true
|
|
794
|
+
if (kind === 'timeout') {
|
|
795
|
+
lines.push(TIMEOUT_MARKER + ' after ' + String(facts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
796
|
+
+ 'ms; the command was stopped and the output above is what it produced first.')
|
|
797
|
+
} else if (kind === 'abort') {
|
|
798
|
+
lines.push(ABORT_MARKER + ': the user stopped this call; the command was stopped and the '
|
|
799
|
+
+ 'output above is what it produced first.')
|
|
800
|
+
} else if (kind === 'killed') {
|
|
801
|
+
// SOMEBODY ELSE KILLED IT. A signal death has no exit code, so `-1` alone would
|
|
802
|
+
// be indistinguishable from a timeout or a cap; naming the signal is the only
|
|
803
|
+
// thing that tells the model "this was terminated, do not just retry".
|
|
804
|
+
lines.push(KILLED_MARKER + ' by signal ' + String(facts.signalName ?? 'unknown')
|
|
805
|
+
+ '; the output above is what it produced before it was terminated.')
|
|
806
|
+
} else if (runtime) {
|
|
807
|
+
// HONEST VARIANT. The runtime killed the shell on its own, so this layer
|
|
808
|
+
// never got to run the tree kill while the root was still alive. Claiming
|
|
809
|
+
// "it was stopped" here would be a false statement about the grandchildren.
|
|
810
|
+
lines.push(MAXBUFFER_MARKER + ': the command produced more output than the limit allows. '
|
|
811
|
+
+ 'The runtime enforcing the limit stops the shell it started, and cannot be relied on to '
|
|
812
|
+
+ 'stop anything that shell started; treat the process tree as unverified.')
|
|
813
|
+
} else {
|
|
814
|
+
lines.push(MAXBUFFER_MARKER + ': the command produced more output than the limit allows; '
|
|
815
|
+
+ 'it was stopped and the output above is truncated.')
|
|
816
|
+
}
|
|
817
|
+
// ONLY WHEN IT IS TRUE. Reporting a clean stop we cannot prove is the failure
|
|
818
|
+
// mode this whole module exists to avoid, so an unconfirmed tree says so and
|
|
819
|
+
// names the reason.
|
|
820
|
+
if (facts.treeConfirmed === false) {
|
|
821
|
+
lines.push(TREE_MARKER + ' (' + String(facts.treeDetail ?? 'the tree kill did not complete')
|
|
822
|
+
+ '); a child process started by this command may still be alive.')
|
|
823
|
+
}
|
|
824
|
+
return lines.join('\n') + '\n'
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Append a note to captured stderr without losing what the command printed. */
|
|
828
|
+
function appendNote(stderr, note) {
|
|
829
|
+
const text = typeof stderr === 'string' ? stderr : ''
|
|
830
|
+
if (note === '') return text
|
|
831
|
+
return text === '' || text.endsWith('\n') ? text + note : text + '\n' + note
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Build a runner, optionally with a shorter deadline.
|
|
836
|
+
*
|
|
837
|
+
* The deadline is injected HERE rather than exposed on the tool schema: the
|
|
838
|
+
* production value is a policy constant, and the tests need a short one to prove
|
|
839
|
+
* the tree really dies without waiting two minutes. `services.run` receives the
|
|
840
|
+
* result, so the tool layer stays unchanged.
|
|
841
|
+
*
|
|
842
|
+
* @param {{ timeoutMs?: number, graceMs?: number, platform?: string, maxBuffer?: number, onChild?: (child: object) => void }} [options]
|
|
843
|
+
* `onChild` is a TEST SEAM, in the same spirit as `services.run`: it hands
|
|
844
|
+
* the live child to a caller that needs to inspect the real streams (the
|
|
845
|
+
* regression that proves no runtime-owned collector shares them).
|
|
846
|
+
* @returns {(command: string, args: string[], cwd?: string, signal?: AbortSignal) => Promise<{ exitCode: number, stdout: string, stderr: string }>}
|
|
847
|
+
*/
|
|
848
|
+
export function createRunner(options = {}) {
|
|
849
|
+
const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : DEFAULT_TIMEOUT_MS
|
|
850
|
+
const graceMs = typeof options.graceMs === 'number' ? options.graceMs : TREE_KILL_GRACE_MS
|
|
851
|
+
const platform = options.platform ?? process.platform
|
|
852
|
+
const maxBuffer = typeof options.maxBuffer === 'number' ? options.maxBuffer : 8 * 1024 * 1024
|
|
853
|
+
const onChild = typeof options.onChild === 'function' ? options.onChild : undefined
|
|
854
|
+
return (command, args, cwd, signal) => runOnce({ command, args, cwd, signal, timeoutMs, graceMs, platform, maxBuffer, onChild })
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Run an argv and resolve the outcome instead of rejecting on a nonzero exit.
|
|
859
|
+
*
|
|
860
|
+
* WHY THE BUILT-IN `timeout` IS NOT USED. `child_process`'s own timeout kills the
|
|
861
|
+
* process it spawned and nothing else. Measured on this machine: a shell that had
|
|
862
|
+
* started a grandchild was killed, the grandchild kept running (1/1/1 alive), and
|
|
863
|
+
* — worse — the `close` event never fired while that grandchild held the stdio
|
|
864
|
+
* pipes, so the call could hang far past its deadline. The deadline is therefore
|
|
865
|
+
* OUR timer, and it goes through exactly the same kill path as cancellation.
|
|
866
|
+
*
|
|
867
|
+
* THE STOP PATH, in order:
|
|
868
|
+
*
|
|
869
|
+
* 1. `beginStop(kind)` runs once. It marks the reason, clears the deadline
|
|
870
|
+
* timer, and asks `killTree` to kill the whole tree.
|
|
871
|
+
* 2. `killTree` uses `taskkill /T /F` on Windows — the only thing that reaches
|
|
872
|
+
* grandchildren of a shell — and falls back to `child.kill()` if that
|
|
873
|
+
* fails, recording WHY it is unconfirmed.
|
|
874
|
+
* 3. A grace timer bounds the wait. If the child has not closed when it fires,
|
|
875
|
+
* the stdio pipes are destroyed (a grandchild holding them must not keep the
|
|
876
|
+
* handle open) and the call settles from the output captured so far.
|
|
877
|
+
*
|
|
878
|
+
* The output is accumulated from `data` events rather than trusted from the
|
|
879
|
+
* callback, because on the grace path the callback may never deliver the bytes
|
|
880
|
+
* the command already printed.
|
|
881
|
+
*
|
|
882
|
+
* @param {{ command: string, args: string[], cwd?: string, signal?: AbortSignal, timeoutMs: number, graceMs: number, platform: string, maxBuffer: number }} spec
|
|
883
|
+
* @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
|
|
884
|
+
*/
|
|
885
|
+
function runOnce(spec) {
|
|
886
|
+
const { command, args, cwd, signal, timeoutMs, graceMs, platform, maxBuffer, onChild } = spec
|
|
887
|
+
return new Promise((resolve, reject) => {
|
|
888
|
+
let settled = false
|
|
889
|
+
/** What stopped the command: null while it is still legitimately running. */
|
|
890
|
+
let stopped = null
|
|
891
|
+
/** true = the tree was confirmed dead; false = it was not; null = not attempted. */
|
|
892
|
+
let treeConfirmed = null
|
|
893
|
+
let treeDetail = ''
|
|
894
|
+
let deadlineTimer = null
|
|
895
|
+
let graceTimer = null
|
|
896
|
+
const out = []
|
|
897
|
+
const err = []
|
|
898
|
+
|
|
899
|
+
const stdoutText = () => out.join('')
|
|
900
|
+
const stderrText = () => err.join('')
|
|
901
|
+
|
|
902
|
+
const clearTimers = () => {
|
|
903
|
+
if (deadlineTimer !== null) { clearTimeout(deadlineTimer); deadlineTimer = null }
|
|
904
|
+
if (graceTimer !== null) { clearTimeout(graceTimer); graceTimer = null }
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Settle exactly once, whichever of close / grace / spawn failure arrives first.
|
|
909
|
+
*
|
|
910
|
+
* With `spawn` there is no `(error, stdout, stderr)` callback: the exit facts
|
|
911
|
+
* arrive as `(code, signal)` on `close`, and a spawn failure arrives as an
|
|
912
|
+
* `error` event. Both route here, and the output is always the text this layer
|
|
913
|
+
* collected — never a second copy held by the runtime.
|
|
914
|
+
*
|
|
915
|
+
* @param {Error | null} error a spawn failure, or null for a normal close
|
|
916
|
+
* @param {number | null} [code] the exit code from `close`
|
|
917
|
+
* @param {string | null} [signalName] the terminating signal from `close`
|
|
918
|
+
*/
|
|
919
|
+
const deliver = (error, code, signalName) => {
|
|
920
|
+
if (settled) return
|
|
921
|
+
settled = true
|
|
922
|
+
clearTimers()
|
|
923
|
+
if (signal !== undefined && onAbort !== null) signal.removeEventListener('abort', onAbort)
|
|
924
|
+
|
|
925
|
+
// CANCELLATION IS NOT A RESULT. The host has to be able to tell "the user
|
|
926
|
+
// stopped this" apart from "the command exited nonzero", so it rejects with
|
|
927
|
+
// a named error that also carries the cleanup limitation on `stderr`.
|
|
928
|
+
if (stopped === 'abort') {
|
|
929
|
+
reject(abortError(signal, appendNote(stderrText(), terminationNote('abort', { treeConfirmed, treeDetail }))))
|
|
930
|
+
return
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (stopped === 'timeout') {
|
|
934
|
+
resolve({
|
|
935
|
+
exitCode: -1,
|
|
936
|
+
stdout: stdoutText(),
|
|
937
|
+
stderr: appendNote(stderrText(), terminationNote('timeout', { timeoutMs, treeConfirmed, treeDetail })),
|
|
938
|
+
})
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (stopped === 'maxBuffer') {
|
|
943
|
+
resolve({
|
|
944
|
+
exitCode: -1,
|
|
945
|
+
stdout: stdoutText(),
|
|
946
|
+
stderr: appendNote(stderrText(), terminationNote('maxBuffer', { treeConfirmed, treeDetail })),
|
|
947
|
+
})
|
|
948
|
+
return
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (error !== null && error !== undefined) {
|
|
952
|
+
// A genuine spawn failure (ENOENT/EACCES) has no exit facts and no output.
|
|
953
|
+
// With `spawn` there is no internal limit to mistake for one: the cap is
|
|
954
|
+
// ours, enforced in `onData` while the root is still alive.
|
|
955
|
+
reject(error)
|
|
956
|
+
return
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// A signal death MAY have no exit code — POSIX reports `code === null` with a
|
|
960
|
+
// signal name, while Windows still delivers a number (measured: bash
|
|
961
|
+
// self-SIGTERM exits 3840). The number is passed through untouched, because
|
|
962
|
+
// it is the fact that distinguishes a signal from a command failure. Only a
|
|
963
|
+
// null code falls back to `-1`, and then the note names the signal so the
|
|
964
|
+
// `-1` is never an unexplained status.
|
|
965
|
+
if (typeof code === 'number') {
|
|
966
|
+
resolve({ exitCode: code, stdout: stdoutText(), stderr: stderrText() })
|
|
967
|
+
return
|
|
968
|
+
}
|
|
969
|
+
const note = signalName === undefined || signalName === null
|
|
970
|
+
? ''
|
|
971
|
+
: appendNote(stderrText(), terminationNote('killed', { signalName }))
|
|
972
|
+
resolve({ exitCode: -1, stdout: stdoutText(), stderr: note })
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Kill the whole tree, once.
|
|
977
|
+
*
|
|
978
|
+
* `child.killed` is deliberately not consulted: Node sets it when `kill()` is
|
|
979
|
+
* called, which says nothing about a grandchild — and on Windows the tree kill
|
|
980
|
+
* is a separate `taskkill` process, so its own exit is the only evidence.
|
|
981
|
+
*/
|
|
982
|
+
const killTree = () => {
|
|
983
|
+
if (child === null || child === undefined) { treeConfirmed = false; treeDetail = 'no child process'; return }
|
|
984
|
+
if (platform === 'win32' && typeof child.pid === 'number') {
|
|
985
|
+
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }, (error) => {
|
|
986
|
+
if (error === null || error === undefined) { treeConfirmed = true; return }
|
|
987
|
+
treeConfirmed = false
|
|
988
|
+
treeDetail = 'taskkill failed: ' + String(error.message || error.code || 'unknown error')
|
|
989
|
+
// Fall back to the process we do own. That does not make the tree
|
|
990
|
+
// confirmed, so the note above still reaches the user.
|
|
991
|
+
try { child.kill() } catch { /* already gone */ }
|
|
992
|
+
})
|
|
993
|
+
return
|
|
994
|
+
}
|
|
995
|
+
try {
|
|
996
|
+
child.kill('SIGTERM')
|
|
997
|
+
treeConfirmed = true
|
|
998
|
+
} catch (error) {
|
|
999
|
+
treeConfirmed = false
|
|
1000
|
+
treeDetail = 'kill failed: ' + String(error instanceof Error ? error.message : error)
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** Mark why the command is being stopped and start the bounded cleanup. */
|
|
1005
|
+
const beginStop = (kind) => {
|
|
1006
|
+
if (settled || stopped !== null) return
|
|
1007
|
+
stopped = kind
|
|
1008
|
+
if (deadlineTimer !== null) { clearTimeout(deadlineTimer); deadlineTimer = null }
|
|
1009
|
+
// EVERY STOP GOES THROUGH THE TREE KILL. There is no longer a runtime-owned
|
|
1010
|
+
// limit that pre-empts this layer: the cap is enforced in `onData` while the
|
|
1011
|
+
// root is alive, so this is always the one place that can reach grandchildren.
|
|
1012
|
+
killTree()
|
|
1013
|
+
graceTimer = setTimeout(() => {
|
|
1014
|
+
graceTimer = null
|
|
1015
|
+
// THE PIPES MUST GO. A surviving grandchild holding them is exactly what
|
|
1016
|
+
// keeps `close` from firing; destroying them lets this call return, and
|
|
1017
|
+
// the settle below reports the partial output we already captured.
|
|
1018
|
+
try { child?.stdout?.destroy() } catch { /* already closed */ }
|
|
1019
|
+
try { child?.stderr?.destroy() } catch { /* already closed */ }
|
|
1020
|
+
if (treeConfirmed === null) {
|
|
1021
|
+
treeConfirmed = false
|
|
1022
|
+
treeDetail = 'the tree kill did not complete within ' + String(graceMs) + 'ms'
|
|
1023
|
+
}
|
|
1024
|
+
deliver(null, null, null)
|
|
1025
|
+
}, graceMs)
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Accumulate one chunk under the cap, and enforce the cap OURSELVES.
|
|
1030
|
+
*
|
|
1031
|
+
* WHY OUR OWN CHECK. Two things made the runtime's limit the wrong owner:
|
|
1032
|
+
*
|
|
1033
|
+
* 1. Node's enforcement kills the shell IT spawned and nothing else, so the
|
|
1034
|
+
* grandchildren survived — while the stderr note said the command "was
|
|
1035
|
+
* stopped". A false statement about cleanup.
|
|
1036
|
+
* 2. Node's limit counts bytes in its own internal copy, and that copy is
|
|
1037
|
+
* not something a second listener can bound (see the `spawn` note below).
|
|
1038
|
+
* Counting here means our array is the ONLY copy, and it is capped.
|
|
1039
|
+
*
|
|
1040
|
+
* MEMORY STAYS BOUNDED, which is the whole point of a cap: chunks are dropped
|
|
1041
|
+
* once the budget is spent, so the grace window cannot accumulate unbounded
|
|
1042
|
+
* output. Nothing else accumulates it either, because `spawn` installs no
|
|
1043
|
+
* collector of its own.
|
|
1044
|
+
*/
|
|
1045
|
+
let budget = Math.max(0, Number(maxBuffer) || 0)
|
|
1046
|
+
const onData = (bucket) => (chunk) => {
|
|
1047
|
+
if (settled) return
|
|
1048
|
+
const text = String(chunk)
|
|
1049
|
+
if (budget <= 0) {
|
|
1050
|
+
// Once the cap is exceeded the command is stopped on this tick; keep
|
|
1051
|
+
// discarding so a fast writer cannot fill memory in the meantime.
|
|
1052
|
+
return
|
|
1053
|
+
}
|
|
1054
|
+
if (text.length > budget) {
|
|
1055
|
+
bucket.push(text.slice(0, budget))
|
|
1056
|
+
budget = 0
|
|
1057
|
+
// THE ROOT IS STILL ALIVE HERE, so this is the one place the tree kill
|
|
1058
|
+
// can actually reach the grandchildren.
|
|
1059
|
+
beginStop('maxBuffer')
|
|
1060
|
+
return
|
|
1061
|
+
}
|
|
1062
|
+
budget -= text.length
|
|
1063
|
+
bucket.push(text)
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// `spawn`, NOT `execFile`, and that is a memory-safety decision rather than a
|
|
1067
|
+
// stylistic one.
|
|
1068
|
+
//
|
|
1069
|
+
// `execFile` installs its OWN `data` listener on both pipes and accumulates
|
|
1070
|
+
// into an internal array, unconditionally: with `maxBuffer: Infinity` its
|
|
1071
|
+
// handler is literally `ArrayPrototypePush(_stdout, chunk)` and returns. A
|
|
1072
|
+
// second listener cannot prevent that — listeners are additive, both receive
|
|
1073
|
+
// every chunk. So raising `maxBuffer` to Infinity to keep Node from killing
|
|
1074
|
+
// the root first had a price the code below did not pay: the internal copy
|
|
1075
|
+
// grew without bound, which is exactly what a cap exists to prevent.
|
|
1076
|
+
//
|
|
1077
|
+
// `spawn` has no such accumulator: the streams are handed to this layer and
|
|
1078
|
+
// nothing else reads them. Our `onData` budget (above) is therefore the only
|
|
1079
|
+
// copy, and it is the bound the caller asked for.
|
|
1080
|
+
//
|
|
1081
|
+
// NO `timeout` OPTION, on purpose: `child_process`'s own deadline kills the
|
|
1082
|
+
// process it spawned and nothing else. Ours is the deadline (see above).
|
|
1083
|
+
const child = spawn(command, args, {
|
|
1084
|
+
cwd: cwd === undefined ? undefined : cwd,
|
|
1085
|
+
windowsHide: true,
|
|
1086
|
+
// stdin is not part of this contract; closing it stops a command that would
|
|
1087
|
+
// otherwise wait forever for input the model never sends.
|
|
1088
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1089
|
+
})
|
|
1090
|
+
|
|
1091
|
+
// Capture the bytes as they arrive: on the grace path `close` may fire after
|
|
1092
|
+
// the pipes are destroyed, and only what was already collected survives.
|
|
1093
|
+
child.stdout?.on('data', onData(out))
|
|
1094
|
+
child.stderr?.on('data', onData(err))
|
|
1095
|
+
|
|
1096
|
+
// TEST SEAM: hand the live child to a caller that needs to inspect the real
|
|
1097
|
+
// streams. Production never passes it, so this changes no behaviour.
|
|
1098
|
+
if (onChild !== undefined) {
|
|
1099
|
+
try { onChild(child) } catch { /* a test observer must not break the run */ }
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// A SPAWN FAILURE HAS ITS OWN EVENT on this API; there is no callback. Without
|
|
1103
|
+
// a listener it would be an unhandled 'error' and take the process down.
|
|
1104
|
+
child.on('error', (error) => { deliver(error) })
|
|
1105
|
+
child.on('close', (code, signal) => { deliver(null, code, signal) })
|
|
1106
|
+
|
|
1107
|
+
const onAbort = signal === undefined ? null : () => { beginStop('abort') }
|
|
1108
|
+
|
|
1109
|
+
deadlineTimer = setTimeout(() => { beginStop('timeout') }, timeoutMs)
|
|
1110
|
+
|
|
1111
|
+
if (onAbort !== null) {
|
|
1112
|
+
if (signal.aborted === true) {
|
|
1113
|
+
beginStop('abort')
|
|
1114
|
+
} else {
|
|
1115
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
})
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** The production runner: the real spawn with the real deadline. */
|
|
1122
|
+
const defaultRun = createRunner()
|
|
1123
|
+
|
|
1124
|
+
export { execAsync, readMode }
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Memoize a resolver for one resolution pass.
|
|
1128
|
+
*
|
|
1129
|
+
* The injection asks for all three modes, and `auto` itself asks for two kinds.
|
|
1130
|
+
* Without this, one page load would spawn `git --exec-path` three times and
|
|
1131
|
+
* `where pwsh` twice — the probe is the expensive part (~77 ms), not the choice.
|
|
1132
|
+
* A FRESH memo per pass, not a module-level one: a cache that outlives the call
|
|
1133
|
+
* would answer tomorrow's question with today's machine.
|
|
1134
|
+
*
|
|
1135
|
+
* @param {(kind: string) => object | null} resolve
|
|
1136
|
+
* @returns {(kind: string) => object | null}
|
|
1137
|
+
*/
|
|
1138
|
+
function memoized(resolve) {
|
|
1139
|
+
const seen = new Map()
|
|
1140
|
+
return (kind) => {
|
|
1141
|
+
if (!seen.has(kind)) seen.set(kind, resolve(kind))
|
|
1142
|
+
return seen.get(kind)
|
|
1143
|
+
}
|
|
1144
|
+
}
|