@hwfee/dsh-git-bash 0.1.0 → 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 CHANGED
@@ -19,7 +19,7 @@
19
19
  * @module @hwfee/dsh-git-bash
20
20
  */
21
21
 
22
- import { execFile } from 'node:child_process'
22
+ import { execFile, spawn } from 'node:child_process'
23
23
  import { DEFAULT_MODE, MODES, chooseShell, readMode } from './mode.js'
24
24
  import { createResolver, execAsync } from './resolve.js'
25
25
  import { createModeRoute } from './mode-route.js'
@@ -86,10 +86,16 @@ export function apply(ctx) {
86
86
  () => ctx.tools.register(createRunShellTool(current, {
87
87
  // OPTIONAL ON PURPOSE. The host composes exactly one `ctx.shell` executor
88
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` falls back to direct execution.
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`.
91
92
  getShell: () => (typeof ctx.get === 'function' ? ctx.get('shell') : undefined),
92
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),
93
99
  })),
94
100
  'dsh-git-bash: run_shell tool',
95
101
  )
@@ -202,6 +208,8 @@ export function describe(result, mode) {
202
208
  * @param {object} [services] optional host services, read lazily
203
209
  * @param {() => object | undefined} [services.getShell] `ctx.shell`, the host executor
204
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
205
213
  * @returns the tool definition `ctx.tools.register` expects
206
214
  */
207
215
  export function createRunShellTool(current, services = {}) {
@@ -283,6 +291,20 @@ export function createRunShellTool(current, services = {}) {
283
291
  && typeof hostShell.execute === 'function'
284
292
  && typeof hostShell.resolve === 'function'
285
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
+
286
308
  // WHICH MODES MAY CROSS THE HOST BOUNDARY.
287
309
  //
288
310
  // PowerShell does: the host executor IS pwsh on Windows, and nesting a
@@ -290,17 +312,43 @@ export function createRunShellTool(current, services = {}) {
290
312
  // pwsh cannot create the MSYS process (NtCreateDirectoryObject,
291
313
  // 0xC0000022 — reproduced live), so delegating would turn every command
292
314
  // into a spawn failure. POSIX has one shell, so it always delegates.
293
- //
294
- // The direct path is the pre-delegation behaviour. It is REPORTED (via
295
- // 'direct' + a note), never silent: a user reading the tool result must be
296
- // able to tell that this call did not cross the sandbox.
297
315
  const delegate = shouldDelegate(shell.kind, platform, hasHost)
298
- if (delegate) return runThroughHost(hostShell, shell, args, exec)
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)
299
321
 
300
- const direct = await runInShell({ kind: shell.kind, path: shell.path }, { ...args, workdir: shell.workdir })
301
- const note = !hasHost
302
- ? 'Ran directly: this profile has no ctx.shell executor.'
303
- : 'Ran directly: the Windows host sandbox cannot start Git Bash (nested MSYS process creation is denied), so this command did not cross the sandbox.'
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)'}.`
304
352
  return { ...direct, via: 'direct', note }
305
353
  },
306
354
  presentCall: (args) => ({
@@ -312,6 +360,106 @@ export function createRunShellTool(current, services = {}) {
312
360
  }
313
361
  }
314
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
+
315
463
  /**
316
464
  * The sentence a user reads when the chosen shell is missing.
317
465
  *
@@ -319,9 +467,10 @@ export function createRunShellTool(current, services = {}) {
319
467
  * configuration facts and never the command's fault.
320
468
  *
321
469
  * @param {string} reason the failure code
470
+ * @param {string} [detail] the specific fact behind a refusal
322
471
  * @returns {string} the message
323
472
  */
324
- export function messageFor(reason) {
473
+ export function messageFor(reason, detail) {
325
474
  switch (reason) {
326
475
  case 'gitbash-not-found':
327
476
  return 'Git Bash is selected but was not found. Install Git for Windows, or set '
@@ -333,6 +482,35 @@ export function messageFor(reason) {
333
482
  return 'The selected shell mode is not available on this platform.'
334
483
  case 'no-shell-found':
335
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.'
336
514
  default:
337
515
  return 'The configured shell could not be started: ' + reason
338
516
  }
@@ -350,20 +528,30 @@ export function messageFor(reason) {
350
528
  * model needs to see it (`grep` that matched nothing exits 1).
351
529
  *
352
530
  * @param {{ kind: string, path: string }} shell the executable
353
- * @param {{ command: string, workdir?: string }} args the call arguments
354
- * @param {(path: string, args: string[], cwd?: string) => Promise<object>} [run]
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]
355
533
  * the executor, injected for tests
534
+ * @param {AbortSignal} [signal] cancellation, forwarded to the executor
356
535
  * @returns {Promise<object>} the tool result
357
536
  */
358
- export async function runInShell(shell, args, run = defaultRun) {
537
+ export async function runInShell(shell, args, run = defaultRun, signal = args?.signal) {
359
538
  const command = typeof args?.command === 'string' ? args.command : ''
360
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)
361
544
 
362
545
  const argv = shell.kind === 'gitbash' || shell.kind === 'posix-bash'
363
546
  ? ['-lc', command]
364
547
  : ['-NoProfile', '-NonInteractive', '-Command', command]
365
548
 
366
- const outcome = await run(shell.path, argv, args?.workdir)
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)
367
555
  return {
368
556
  shell: shell.path,
369
557
  kind: shell.kind,
@@ -373,6 +561,37 @@ export async function runInShell(shell, args, run = defaultRun) {
373
561
  }
374
562
  }
375
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
+
376
595
  /**
377
596
  * Quote one string for PowerShell's `-Command` argument.
378
597
  *
@@ -449,15 +668,26 @@ export function delegatedCommand(shell, command, platform = process.platform) {
449
668
  * `run_shell`'s shape. `result()` rejects on infrastructure failures, which
450
669
  * propagate to the tool layer like any other tool error.
451
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
+ *
452
677
  * @param {object} hostShell the `ctx.shell` service
453
678
  * @param {{ kind: string, path: string, workdir?: string }} shell the selected interpreter
454
679
  * @param {{ command?: string, workdir?: string }} args the call arguments
455
680
  * @param {{ signal?: AbortSignal }} [exec] the tool execution context
681
+ * @param {object} [policy] the resolved `SandboxExecutionPolicy` for this call
456
682
  * @returns {Promise<object>} the tool result
457
683
  */
458
- export async function runThroughHost(hostShell, shell, args, exec = {}) {
684
+ export async function runThroughHost(hostShell, shell, args, exec = {}, policy) {
459
685
  const command = typeof args?.command === 'string' ? args.command : ''
460
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)
461
691
  const workdir = typeof args?.workdir === 'string' && args.workdir !== ''
462
692
  ? args.workdir
463
693
  : shell.workdir
@@ -465,9 +695,25 @@ export async function runThroughHost(hostShell, shell, args, exec = {}) {
465
695
  command: delegatedCommand(shell, command, process.platform),
466
696
  ...(workdir === undefined ? {} : { workdir }),
467
697
  ...(exec.signal === undefined ? {} : { signal: exec.signal }),
698
+ ...(policy === undefined || policy === null ? {} : { sandboxPolicy: policy }),
468
699
  }
469
700
  const handle = await hostShell.execute(hostShell.resolve(request))
470
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
+ }
471
717
  return {
472
718
  shell: shell.path,
473
719
  kind: shell.kind,
@@ -504,37 +750,377 @@ export async function resolveWorkdir(args, exec, getService) {
504
750
  }
505
751
  }
506
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
+
507
857
  /**
508
858
  * Run an argv and resolve the outcome instead of rejecting on a nonzero exit.
509
859
  *
510
- * @param {string} command the executable
511
- * @param {string[]} args its arguments
512
- * @param {string} [cwd] working directory
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
513
883
  * @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
514
884
  */
515
- function defaultRun(command, args, cwd) {
885
+ function runOnce(spec) {
886
+ const { command, args, cwd, signal, timeoutMs, graceMs, platform, maxBuffer, onChild } = spec
516
887
  return new Promise((resolve, reject) => {
517
- execFile(command, args, {
518
- cwd: cwd === undefined ? undefined : cwd,
519
- windowsHide: true,
520
- timeout: 120_000,
521
- maxBuffer: 8 * 1024 * 1024,
522
- }, (error, stdout, stderr) => {
523
- // A spawn failure has no exit code and no output; that is the only case
524
- // worth rejecting.
525
- if (error !== null && typeof error.code !== 'number') {
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.
526
955
  reject(error)
527
956
  return
528
957
  }
529
- resolve({
530
- exitCode: error === null ? 0 : error.code,
531
- stdout: String(stdout ?? ''),
532
- stderr: String(stderr ?? ''),
533
- })
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'],
534
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
+ }
535
1118
  })
536
1119
  }
537
1120
 
1121
+ /** The production runner: the real spawn with the real deadline. */
1122
+ const defaultRun = createRunner()
1123
+
538
1124
  export { execAsync, readMode }
539
1125
 
540
1126
  /**