@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/README.md +59 -18
- package/client/client.js +633 -2
- package/lib/index.js +622 -36
- package/package.json +2 -2
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`
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
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
|
|
885
|
+
function runOnce(spec) {
|
|
886
|
+
const { command, args, cwd, signal, timeoutMs, graceMs, platform, maxBuffer, onChild } = spec
|
|
516
887
|
return new Promise((resolve, reject) => {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
/**
|