@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/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
+ }