agentfootprint 11.4.0 → 12.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/CAPABILITIES.md +3 -1
  2. package/dist/adapters/code/agentcore.js +295 -214
  3. package/dist/adapters/code/agentcore.js.map +1 -1
  4. package/dist/adapters/code/agentcoreClient.js +178 -0
  5. package/dist/adapters/code/agentcoreClient.js.map +1 -0
  6. package/dist/adapters/code/agentcoreWire.js +348 -0
  7. package/dist/adapters/code/agentcoreWire.js.map +1 -0
  8. package/dist/adapters/code/fileName.js +100 -0
  9. package/dist/adapters/code/fileName.js.map +1 -0
  10. package/dist/adapters/code/local.js +529 -183
  11. package/dist/adapters/code/local.js.map +1 -1
  12. package/dist/adapters/code/output.js +140 -0
  13. package/dist/adapters/code/output.js.map +1 -0
  14. package/dist/adapters/mcp/agentcoreRuntime/invoke.js +7 -6
  15. package/dist/adapters/mcp/agentcoreRuntime/invoke.js.map +1 -1
  16. package/dist/adapters/mcp/agentcoreRuntime/sessionBusy.js +36 -2
  17. package/dist/adapters/mcp/agentcoreRuntime/sessionBusy.js.map +1 -1
  18. package/dist/adapters/mcp/agentcoreRuntime/transport.js +3 -1
  19. package/dist/adapters/mcp/agentcoreRuntime/transport.js.map +1 -1
  20. package/dist/core/codeRunnerTool.js +52 -7
  21. package/dist/core/codeRunnerTool.js.map +1 -1
  22. package/dist/doors/providers.js.map +1 -1
  23. package/dist/esm/adapters/code/agentcore.d.ts +65 -68
  24. package/dist/esm/adapters/code/agentcore.js +293 -212
  25. package/dist/esm/adapters/code/agentcore.js.map +1 -1
  26. package/dist/esm/adapters/code/agentcoreClient.d.ts +193 -0
  27. package/dist/esm/adapters/code/agentcoreClient.js +173 -0
  28. package/dist/esm/adapters/code/agentcoreClient.js.map +1 -0
  29. package/dist/esm/adapters/code/agentcoreWire.d.ts +119 -0
  30. package/dist/esm/adapters/code/agentcoreWire.js +334 -0
  31. package/dist/esm/adapters/code/agentcoreWire.js.map +1 -0
  32. package/dist/esm/adapters/code/fileName.d.ts +43 -0
  33. package/dist/esm/adapters/code/fileName.js +96 -0
  34. package/dist/esm/adapters/code/fileName.js.map +1 -0
  35. package/dist/esm/adapters/code/local.d.ts +91 -12
  36. package/dist/esm/adapters/code/local.js +528 -182
  37. package/dist/esm/adapters/code/local.js.map +1 -1
  38. package/dist/esm/adapters/code/output.d.ts +70 -0
  39. package/dist/esm/adapters/code/output.js +129 -0
  40. package/dist/esm/adapters/code/output.js.map +1 -0
  41. package/dist/esm/adapters/mcp/agentcoreRuntime/invoke.d.ts +7 -6
  42. package/dist/esm/adapters/mcp/agentcoreRuntime/invoke.js +7 -6
  43. package/dist/esm/adapters/mcp/agentcoreRuntime/invoke.js.map +1 -1
  44. package/dist/esm/adapters/mcp/agentcoreRuntime/sessionBusy.d.ts +32 -1
  45. package/dist/esm/adapters/mcp/agentcoreRuntime/sessionBusy.js +35 -1
  46. package/dist/esm/adapters/mcp/agentcoreRuntime/sessionBusy.js.map +1 -1
  47. package/dist/esm/adapters/mcp/agentcoreRuntime/transport.js +4 -2
  48. package/dist/esm/adapters/mcp/agentcoreRuntime/transport.js.map +1 -1
  49. package/dist/esm/core/codeRunnerTool.d.ts +15 -3
  50. package/dist/esm/core/codeRunnerTool.js +52 -7
  51. package/dist/esm/core/codeRunnerTool.js.map +1 -1
  52. package/dist/esm/doors/providers.d.ts +1 -1
  53. package/dist/esm/doors/providers.js.map +1 -1
  54. package/dist/esm/events/payloads.d.ts +144 -29
  55. package/dist/esm/index.d.ts +2 -0
  56. package/dist/esm/index.js +5 -0
  57. package/dist/esm/index.js.map +1 -1
  58. package/dist/esm/mcp/callLifetime.d.ts +57 -14
  59. package/dist/esm/mcp/callLifetime.js +67 -19
  60. package/dist/esm/mcp/callLifetime.js.map +1 -1
  61. package/dist/esm/mcp/callMarker.d.ts +43 -0
  62. package/dist/esm/mcp/callMarker.js +69 -0
  63. package/dist/esm/mcp/callMarker.js.map +1 -0
  64. package/dist/esm/mcp/connectionRefusals.js +7 -0
  65. package/dist/esm/mcp/connectionRefusals.js.map +1 -1
  66. package/dist/esm/mcp/index.d.ts +2 -0
  67. package/dist/esm/mcp/index.js +3 -0
  68. package/dist/esm/mcp/index.js.map +1 -1
  69. package/dist/esm/mcp/mcpClient.js +93 -26
  70. package/dist/esm/mcp/mcpClient.js.map +1 -1
  71. package/dist/esm/mcp/replay.d.ts +291 -0
  72. package/dist/esm/mcp/replay.js +405 -0
  73. package/dist/esm/mcp/replay.js.map +1 -0
  74. package/dist/esm/mcp/replayUnavailable.d.ts +68 -0
  75. package/dist/esm/mcp/replayUnavailable.js +87 -0
  76. package/dist/esm/mcp/replayUnavailable.js.map +1 -0
  77. package/dist/esm/mcp/requestLedger.d.ts +115 -32
  78. package/dist/esm/mcp/requestLedger.js +193 -52
  79. package/dist/esm/mcp/requestLedger.js.map +1 -1
  80. package/dist/esm/mcp/seriesEvidence.d.ts +73 -15
  81. package/dist/esm/mcp/seriesEvidence.js +114 -21
  82. package/dist/esm/mcp/seriesEvidence.js.map +1 -1
  83. package/dist/esm/mcp/throttleRetry.d.ts +86 -42
  84. package/dist/esm/mcp/throttleRetry.js +158 -50
  85. package/dist/esm/mcp/throttleRetry.js.map +1 -1
  86. package/dist/esm/mcp/toolResult.d.ts +6 -0
  87. package/dist/esm/mcp/toolResult.js +15 -0
  88. package/dist/esm/mcp/toolResult.js.map +1 -1
  89. package/dist/esm/mcp/types.d.ts +73 -8
  90. package/dist/esm/provider-contract/codeSandbox.d.ts +89 -0
  91. package/dist/esm/provider-contract/codeSandbox.js +119 -0
  92. package/dist/esm/provider-contract/codeSandbox.js.map +1 -0
  93. package/dist/esm/provider-contract/sessionExpired.d.ts +31 -0
  94. package/dist/esm/provider-contract/sessionExpired.js +40 -0
  95. package/dist/esm/provider-contract/sessionExpired.js.map +1 -0
  96. package/dist/esm/provider-contract/types.d.ts +166 -17
  97. package/dist/esm/provider-contract/types.js +9 -0
  98. package/dist/esm/provider-contract/types.js.map +1 -1
  99. package/dist/esm/redaction/knownStrings.js +16 -0
  100. package/dist/esm/redaction/knownStrings.js.map +1 -1
  101. package/dist/esm/tool-providers/index.d.ts +2 -2
  102. package/dist/esm/tool-providers/index.js +4 -0
  103. package/dist/esm/tool-providers/index.js.map +1 -1
  104. package/dist/esm/tools/requestEvidence.js +60 -0
  105. package/dist/esm/tools/requestEvidence.js.map +1 -1
  106. package/dist/index.js +12 -4
  107. package/dist/index.js.map +1 -1
  108. package/dist/mcp/callLifetime.js +66 -18
  109. package/dist/mcp/callLifetime.js.map +1 -1
  110. package/dist/mcp/callMarker.js +74 -0
  111. package/dist/mcp/callMarker.js.map +1 -0
  112. package/dist/mcp/connectionRefusals.js +7 -0
  113. package/dist/mcp/connectionRefusals.js.map +1 -1
  114. package/dist/mcp/index.js +5 -1
  115. package/dist/mcp/index.js.map +1 -1
  116. package/dist/mcp/mcpClient.js +91 -24
  117. package/dist/mcp/mcpClient.js.map +1 -1
  118. package/dist/mcp/replay.js +415 -0
  119. package/dist/mcp/replay.js.map +1 -0
  120. package/dist/mcp/replayUnavailable.js +92 -0
  121. package/dist/mcp/replayUnavailable.js.map +1 -0
  122. package/dist/mcp/requestLedger.js +193 -52
  123. package/dist/mcp/requestLedger.js.map +1 -1
  124. package/dist/mcp/seriesEvidence.js +116 -22
  125. package/dist/mcp/seriesEvidence.js.map +1 -1
  126. package/dist/mcp/throttleRetry.js +160 -51
  127. package/dist/mcp/throttleRetry.js.map +1 -1
  128. package/dist/mcp/toolResult.js +17 -1
  129. package/dist/mcp/toolResult.js.map +1 -1
  130. package/dist/provider-contract/codeSandbox.js +125 -0
  131. package/dist/provider-contract/codeSandbox.js.map +1 -0
  132. package/dist/provider-contract/sessionExpired.js +44 -0
  133. package/dist/provider-contract/sessionExpired.js.map +1 -0
  134. package/dist/provider-contract/types.js +12 -1
  135. package/dist/provider-contract/types.js.map +1 -1
  136. package/dist/redaction/knownStrings.js +16 -0
  137. package/dist/redaction/knownStrings.js.map +1 -1
  138. package/dist/tool-providers/index.js +5 -1
  139. package/dist/tool-providers/index.js.map +1 -1
  140. package/dist/tools/requestEvidence.js +60 -0
  141. package/dist/tools/requestEvidence.js.map +1 -1
  142. package/dist/types/adapters/code/agentcore.d.ts +65 -68
  143. package/dist/types/adapters/code/agentcore.d.ts.map +1 -1
  144. package/dist/types/adapters/code/agentcoreClient.d.ts +194 -0
  145. package/dist/types/adapters/code/agentcoreClient.d.ts.map +1 -0
  146. package/dist/types/adapters/code/agentcoreWire.d.ts +120 -0
  147. package/dist/types/adapters/code/agentcoreWire.d.ts.map +1 -0
  148. package/dist/types/adapters/code/fileName.d.ts +44 -0
  149. package/dist/types/adapters/code/fileName.d.ts.map +1 -0
  150. package/dist/types/adapters/code/local.d.ts +91 -12
  151. package/dist/types/adapters/code/local.d.ts.map +1 -1
  152. package/dist/types/adapters/code/output.d.ts +71 -0
  153. package/dist/types/adapters/code/output.d.ts.map +1 -0
  154. package/dist/types/adapters/mcp/agentcoreRuntime/invoke.d.ts +7 -6
  155. package/dist/types/adapters/mcp/agentcoreRuntime/invoke.d.ts.map +1 -1
  156. package/dist/types/adapters/mcp/agentcoreRuntime/sessionBusy.d.ts +32 -1
  157. package/dist/types/adapters/mcp/agentcoreRuntime/sessionBusy.d.ts.map +1 -1
  158. package/dist/types/adapters/mcp/agentcoreRuntime/transport.d.ts.map +1 -1
  159. package/dist/types/core/codeRunnerTool.d.ts +15 -3
  160. package/dist/types/core/codeRunnerTool.d.ts.map +1 -1
  161. package/dist/types/doors/providers.d.ts +1 -1
  162. package/dist/types/doors/providers.d.ts.map +1 -1
  163. package/dist/types/events/payloads.d.ts +144 -29
  164. package/dist/types/events/payloads.d.ts.map +1 -1
  165. package/dist/types/index.d.ts +2 -0
  166. package/dist/types/index.d.ts.map +1 -1
  167. package/dist/types/mcp/callLifetime.d.ts +57 -14
  168. package/dist/types/mcp/callLifetime.d.ts.map +1 -1
  169. package/dist/types/mcp/callMarker.d.ts +44 -0
  170. package/dist/types/mcp/callMarker.d.ts.map +1 -0
  171. package/dist/types/mcp/index.d.ts +2 -0
  172. package/dist/types/mcp/index.d.ts.map +1 -1
  173. package/dist/types/mcp/mcpClient.d.ts.map +1 -1
  174. package/dist/types/mcp/replay.d.ts +292 -0
  175. package/dist/types/mcp/replay.d.ts.map +1 -0
  176. package/dist/types/mcp/replayUnavailable.d.ts +69 -0
  177. package/dist/types/mcp/replayUnavailable.d.ts.map +1 -0
  178. package/dist/types/mcp/requestLedger.d.ts +115 -32
  179. package/dist/types/mcp/requestLedger.d.ts.map +1 -1
  180. package/dist/types/mcp/seriesEvidence.d.ts +73 -15
  181. package/dist/types/mcp/seriesEvidence.d.ts.map +1 -1
  182. package/dist/types/mcp/throttleRetry.d.ts +86 -42
  183. package/dist/types/mcp/throttleRetry.d.ts.map +1 -1
  184. package/dist/types/mcp/toolResult.d.ts +6 -0
  185. package/dist/types/mcp/toolResult.d.ts.map +1 -1
  186. package/dist/types/mcp/types.d.ts +73 -8
  187. package/dist/types/mcp/types.d.ts.map +1 -1
  188. package/dist/types/provider-contract/codeSandbox.d.ts +90 -0
  189. package/dist/types/provider-contract/codeSandbox.d.ts.map +1 -0
  190. package/dist/types/provider-contract/sessionExpired.d.ts +32 -0
  191. package/dist/types/provider-contract/sessionExpired.d.ts.map +1 -0
  192. package/dist/types/provider-contract/types.d.ts +166 -17
  193. package/dist/types/provider-contract/types.d.ts.map +1 -1
  194. package/dist/types/redaction/knownStrings.d.ts.map +1 -1
  195. package/dist/types/tool-providers/index.d.ts +2 -2
  196. package/dist/types/tool-providers/index.d.ts.map +1 -1
  197. package/dist/types/tools/requestEvidence.d.ts.map +1 -1
  198. package/package.json +1 -1
@@ -12,7 +12,13 @@
12
12
  *
13
13
  * What a child process DOES give you:
14
14
  * • a separate process and heap — a crash or an OOM takes the child, not you;
15
- * • kill on timeout, so runaway code has a ceiling;
15
+ * • kill on timeout, so runaway code has a ceiling — on POSIX the child
16
+ * runs in a process group of its own, and at the timeout, on an abort or
17
+ * at the output ceiling the runner stops that OWNED group (SIGKILL). That
18
+ * is cleanup, not containment: a descendant that starts its own session
19
+ * or group escapes it, and a host killed before its exit handlers run
20
+ * stops nothing. On Windows only the child itself can be stopped, and the
21
+ * timeout error says so;
16
22
  * • no inherited stdin, so nothing can block waiting for a terminal;
17
23
  * • an environment ALLOWLIST — `process.env` is not inherited (only `PATH`,
18
24
  * so the interpreter can be found), so an `AWS_SECRET_ACCESS_KEY` in your
@@ -34,11 +40,21 @@
34
40
  * is not a set this adapter can identify without guessing which of a developer's
35
41
  * files were meant, and a dev-loop runner that quietly uploaded whatever
36
42
  * appeared beside your source would be the worse failure. Producing outputs
37
- * needs three things this adapter does not have yet: a declared output
38
- * directory the model is told about, a bounded read-back of what landed in it,
39
- * and a size policy for what is too big to carry in-band. A runner that has
40
- * them (a managed sandbox that returns file contents) fills the field, and
41
- * `codeRunnerTool` mints every data-carrying entry with no further wiring.
43
+ * needs a declared output directory the model is told about and a size policy
44
+ * for what is too big to carry in-band; `readFile` below is the bounded
45
+ * read-back, for a caller that names the file. A runner that has all three
46
+ * fills the field, and `codeRunnerTool` mints every data-carrying entry with
47
+ * no further wiring.
48
+ *
49
+ * ── Files and commands ─────────────────────────────────────────────────────
50
+ * `readFile(path, { maxBytes })` reads ONE file back from the working
51
+ * directory — the path checked by the port's one rule (`sandboxPath`), then
52
+ * by its REAL location, so a symbolic link cannot lead it outside; a file over
53
+ * `maxBytes` is refused, never cut; a file on disk is bytes, so bytes come
54
+ * back and no encoding is guessed. `runCommand(command)` runs ONE `sh -c`
55
+ * command with the same environment allowlist as the code, and exists only on
56
+ * a runner built with `commands: true`. Neither is a network or filesystem
57
+ * jail: a command reaches whatever this machine reaches.
42
58
  *
43
59
  * So: a development loop, a trusted-input pipeline, a machine you would be
44
60
  * relaxed about a shell script running on. NOT arbitrary model-written code
@@ -61,7 +77,11 @@
61
77
  * .build();
62
78
  */
63
79
  import { STAGED_INPUTS_ENV } from '../../provider-contract/types.js';
80
+ import { CodeSandboxError, readBound, sandboxPath } from '../../provider-contract/codeSandbox.js';
81
+ import { abortErrorOf, armDeadline, linkedController, throwIfAborted } from '../../lib/abort.js';
64
82
  import { lazyRequire } from '../../lib/lazyRequire.js';
83
+ import { safeFileName } from './fileName.js';
84
+ import { boundOutput, commandBounds, commandResult, payloadBytes } from './output.js';
65
85
  const DEFAULT_TIMEOUT_MS = 30_000;
66
86
  const DEFAULT_MAX_OUTPUT_CHARS = 8_000;
67
87
  /** How each supported language is invoked when no `command` was given. */
@@ -96,12 +116,30 @@ export function localCodeRunner(options = {}) {
96
116
  let stagingDir;
97
117
  /** name → path, exactly as the manifest carries it. */
98
118
  const staged = new Map();
99
- return Promise.resolve({
119
+ /** The child's environment, for code and commands alike. */
120
+ const childEnv = () => ({
121
+ // ALLOWLIST. `process.env` is deliberately NOT spread in — only
122
+ // PATH, so the OS can find the interpreter, and it is overridable.
123
+ PATH: process.env.PATH ?? '',
124
+ ...(options.env ?? {}),
125
+ // The staged-input manifest joins it LAST and is not overridable:
126
+ // it is not configuration, it is a fact about this execution, and
127
+ // an operator `env` that happened to define the same name would
128
+ // otherwise hand the model paths to files that are not there.
129
+ // Absent entirely when nothing was staged.
130
+ ...(staged.size > 0 && {
131
+ [STAGED_INPUTS_ENV]: JSON.stringify(Object.fromEntries(staged)),
132
+ }),
133
+ });
134
+ const refuseIfStopped = (toDo) => {
135
+ if (stopped) {
136
+ throw new Error(`localCodeRunner: session ${sessionId} was stopped; open a new one to ${toDo}.`);
137
+ }
138
+ };
139
+ const session = {
100
140
  id: sessionId,
101
141
  async execute(exec) {
102
- if (stopped) {
103
- throw new Error(`localCodeRunner: session ${sessionId} was stopped; open a new one to run more code.`);
104
- }
142
+ refuseIfStopped('run more code');
105
143
  const language = exec.language ?? req.language ?? 'javascript';
106
144
  const command = options.command ?? DEFAULT_COMMANDS[language.toLowerCase()];
107
145
  if (!command || command.length === 0) {
@@ -112,49 +150,36 @@ export function localCodeRunner(options = {}) {
112
150
  const [file, ...args] = command;
113
151
  const raw = await runChild(resolveChildProcess(options), file, [...args, exec.code], {
114
152
  cwd: options.cwd ?? process.cwd(),
115
- // ALLOWLIST. `process.env` is deliberately NOT spread in — only
116
- // PATH, so the OS can find the interpreter, and it is overridable.
117
- //
118
- // The staged-input manifest joins it LAST and is not overridable:
119
- // it is not configuration, it is a fact about this execution, and
120
- // an operator `env` that happened to define the same name would
121
- // otherwise hand the model paths to files that are not there.
122
- // Absent entirely when nothing was staged.
123
- env: {
124
- PATH: process.env.PATH ?? '',
125
- ...(options.env ?? {}),
126
- ...(staged.size > 0 && {
127
- [STAGED_INPUTS_ENV]: JSON.stringify(Object.fromEntries(staged)),
128
- }),
129
- },
153
+ env: childEnv(),
130
154
  timeoutMs: exec.timeoutMs ?? timeoutMs,
131
155
  ...(exec.signal && { signal: exec.signal }),
156
+ }, {
157
+ timedOut: (ms) => new Error(`localCodeRunner: the code did not finish within ${ms}ms and ${STOPPED}. ` +
158
+ 'Raise `timeoutMs`, or have the code do less per call.'),
159
+ notRunnable: (code) => new Error(`localCodeRunner: could not run '${file}' (${code}). ` +
160
+ 'Install the interpreter, or pass `command` pointing at one.'),
161
+ failedAfterStart: (reason) => erroredAfterStart('the code', reason),
132
162
  });
133
- const stdout = cut(raw.stdout, maxOutputChars);
134
- const stderr = cut(raw.stderr, maxOutputChars);
135
- const truncated = {
136
- ...(stdout.cut && { stdout: true }),
137
- ...(stderr.cut && { stderr: true }),
138
- ...((stdout.cut || stderr.cut) && {
139
- ofChars: Math.max(raw.stdout.length, raw.stderr.length),
140
- }),
141
- };
163
+ // The child's own streams only. The runtime's message for a failed
164
+ // child ("Command failed: <the command line>") quotes the code, so
165
+ // it is not passed off as the code's stderr.
166
+ const bounded = boundOutput(raw.stdout, raw.stderr, maxOutputChars, raw.overflowed);
142
167
  return {
168
+ // Known either way: an exit status, or a process that was ended
169
+ // before it exited (no exit status at all — never a made-up one;
170
+ // the signal that ended it, when known, says how).
143
171
  ok: raw.exitCode === 0,
144
- stdout: stdout.text,
145
- stderr: stderr.text,
172
+ stdout: bounded.stdout,
173
+ stderr: bounded.stderr,
146
174
  ...(raw.exitCode !== undefined && { exitCode: raw.exitCode }),
147
- // Present IFF something was actually cut — an empty object here
148
- // would read as "truncation happened, magnitude unknown".
149
- ...(Object.keys(truncated).length > 0 && { truncated }),
175
+ ...(raw.signal !== undefined && { terminatedBy: raw.signal }),
176
+ ...(bounded.truncated !== undefined && { truncated: bounded.truncated }),
150
177
  };
151
178
  },
152
179
  // eslint-disable-next-line @typescript-eslint/require-await
153
180
  async stageInputs(inputs) {
154
- if (stopped) {
155
- throw new Error(`localCodeRunner: session ${sessionId} was stopped; open a new one to stage inputs.`);
156
- }
157
- const fs = resolveFs(options);
181
+ refuseIfStopped('stage inputs');
182
+ const fs = resolveFs(options, 'staging inputs');
158
183
  const landed = [];
159
184
  stagingDir ??= makeStagingDir(fs, options._stagingRoot);
160
185
  for (const input of inputs) {
@@ -165,15 +190,48 @@ export function localCodeRunner(options = {}) {
165
190
  // keys it by what was asked for.
166
191
  const fileName = safeFileName(input.fileName ?? input.name);
167
192
  const path = `${stagingDir}/${fileName}`;
168
- const bytes = typeof input.data === 'string'
169
- ? Buffer.byteLength(input.data, 'utf8')
170
- : input.data.byteLength;
193
+ const bytes = payloadBytes(input.data);
171
194
  writeFile(fs, path, input.data);
172
195
  staged.set(input.name, path);
173
196
  landed.push({ name: input.name, path, bytes });
174
197
  }
175
198
  return landed;
176
199
  },
200
+ // eslint-disable-next-line @typescript-eslint/require-await
201
+ async readFile(path, readOptions) {
202
+ refuseIfStopped('read files');
203
+ const maxBytes = readBound(readOptions);
204
+ throwIfAborted(readOptions.signal);
205
+ const relative = sandboxPath(path);
206
+ return readWithin(resolveFs(options, 'reading files'), options.cwd ?? process.cwd(), relative, maxBytes);
207
+ },
208
+ ...(options.commands === true && {
209
+ async runCommand(command, commandOptions) {
210
+ refuseIfStopped('run commands');
211
+ const bounds = commandBounds(command, commandOptions, maxOutputChars);
212
+ const raw = await runChild(resolveChildProcess(options), 'sh', ['-c', command], {
213
+ cwd: options.cwd ?? process.cwd(),
214
+ env: childEnv(),
215
+ timeoutMs: bounds.timeoutMs ?? timeoutMs,
216
+ ...(bounds.signal && { signal: bounds.signal }),
217
+ }, {
218
+ timedOut: (ms) => new CodeSandboxError('timed-out', `localCodeRunner: the command did not finish within ${ms}ms and ${STOPPED}.`),
219
+ notRunnable: (code) => new Error(`localCodeRunner: could not run 'sh' (${code}); runCommand needs a POSIX ` +
220
+ 'shell on PATH.'),
221
+ failedAfterStart: (reason) => erroredAfterStart('the command', reason),
222
+ });
223
+ // The child's own streams and exit status, nothing added: a
224
+ // process the operating system ended has no exit status, and the
225
+ // result says so by leaving it out.
226
+ return commandResult({
227
+ stdout: raw.stdout,
228
+ stderr: raw.stderr,
229
+ ...(raw.exitCode !== undefined && { exitCode: raw.exitCode }),
230
+ ...(raw.overflowed !== undefined && { overflowed: raw.overflowed }),
231
+ ...(raw.signal !== undefined && { terminatedBy: raw.signal }),
232
+ }, bounds);
233
+ },
234
+ }),
177
235
  stop() {
178
236
  // Idempotent, and it refuses later executions rather than silently
179
237
  // starting a fresh process under a session the caller closed.
@@ -184,7 +242,10 @@ export function localCodeRunner(options = {}) {
184
242
  // teardown would fail a turn that already succeeded.
185
243
  if (stagingDir !== undefined) {
186
244
  try {
187
- resolveFs(options).rmSync?.(stagingDir, { recursive: true, force: true });
245
+ resolveFs(options, 'staging inputs').rmSync?.(stagingDir, {
246
+ recursive: true,
247
+ force: true,
248
+ });
188
249
  }
189
250
  catch {
190
251
  // See above.
@@ -194,23 +255,28 @@ export function localCodeRunner(options = {}) {
194
255
  }
195
256
  return Promise.resolve();
196
257
  },
197
- });
258
+ };
259
+ return Promise.resolve(session);
198
260
  },
199
261
  };
200
262
  }
201
- /** `node:fs`, or a refusal naming what staging needs. Lazy for the same reason
202
- * `node:child_process` is: a browser bundle that never stages must not pay
203
- * for a module it cannot have. */
204
- function resolveFs(options) {
263
+ /** `node:fs`, or a refusal naming what `purpose` needs. Lazy for the same
264
+ * reason `node:child_process` is: a browser bundle that never stages or
265
+ * reads must not pay for a module it cannot have. */
266
+ function resolveFs(options, purpose) {
205
267
  if (options._fs)
206
268
  return options._fs;
207
269
  try {
208
270
  return lazyRequire('node:fs');
209
271
  }
210
272
  catch {
211
- throw new Error('localCodeRunner: staging inputs needs `node:fs`, which this runtime does not provide ' +
212
- '(a browser bundle, or a restricted worker). Use a runner whose backend can write ' +
213
- 'into its own session, or drop the tool’s `wants` declaration.');
273
+ throw new Error(purpose === 'staging inputs'
274
+ ? 'localCodeRunner: staging inputs needs `node:fs`, which this runtime does not provide ' +
275
+ '(a browser bundle, or a restricted worker). Use a runner whose backend can write ' +
276
+ 'into its own session, or drop the tool’s `wants` declaration.'
277
+ : 'localCodeRunner: reading files needs `node:fs`, which this runtime does not provide ' +
278
+ '(a browser bundle, or a restricted worker); a runner whose backend reads its own ' +
279
+ 'session can.');
214
280
  }
215
281
  }
216
282
  /** One private directory per session, under the OS temp root. */
@@ -229,100 +295,120 @@ function writeFile(fs, path, data) {
229
295
  }
230
296
  writeFileSync(path, data);
231
297
  }
232
- /** Longest file name this adapter will write. Well inside every POSIX
233
- * `NAME_MAX`, and the cap this adapter has always used. */
234
- const FILE_NAME_MAX = 120;
235
- /** Marks a name this adapter had to rewrite. Nothing arm A returns may start
236
- * with it. */
237
- const ENCODED_FILE_PREFIX = '_enc_';
238
- /** Already one inert segment: no separator, no escape-looking bytes. */
239
- const FILE_NAME_ALREADY_LEGAL = /^[A-Za-z0-9._-]+$/;
240
- /** `.`, `..`, `...` — a name that is nothing but dots. Not a hop once the
241
- * separators are gone, but not a name either, so it goes to arm B. */
242
- const DOTS_ONLY = /^\.+$/;
243
- /** Legal AND not the escape character — the set {@link encodeFileName} passes
244
- * through untouched. */
245
- const FILE_CHAR_PASSES_THROUGH = /[A-Za-z0-9.-]/;
298
+ /** How much of a file one `readSync` asks for. */
299
+ const READ_CHUNK_BYTES = 64 * 1024;
246
300
  /**
247
- * One caller-supplied name → one inert file-name segment, **without ever
248
- * folding two names into one file**.
249
- *
250
- * Separators cannot survive as a parent hop and `a/b` cannot become two
251
- * segments — the same law `artifacts/scopePath` states for scope tuples,
252
- * applied to the one other place caller strings become file names in this
253
- * package. But the old version got there by REPLACING every illegal character
254
- * with `_`, which meant `a/b.csv` and `a_b.csv` became the same file: the
255
- * second write clobbered the first's bytes, `stageInputs` answered two manifest
256
- * entries pointing at one path, and the model's code opened `a/b.csv` and read
257
- * whatever `a_b.csv` contained. Nothing failed. The computation was simply run
258
- * against the wrong dataset — which is the worst shape a data bug can take,
259
- * because the answer still looks like an answer. `.slice(0, 120)` folded the
260
- * same way for any two names agreeing on their first 120 characters.
301
+ * Read ONE file under `root`, whole or not at all.
261
302
  *
262
- * So this ENCODES instead of sanitising, in two arms whose outputs cannot meet
263
- * (the shape `safeSessionId` uses in `adapters/hosting/agentcore.ts`):
303
+ * `relative` has already passed the port's lexical rule; this adds the one
304
+ * check only the caller's own machine can make — the file's REAL location,
305
+ * after every symbolic link, must still be inside the real working directory.
306
+ * (A sandbox has no such check to make: its whole disk is its own.) The check
307
+ * and the open are two calls, not one atomic step; this is a development-loop
308
+ * runner, and its module header says what it is not.
264
309
  *
265
- * A. already one legal segment, not claiming to be an encoded name, and not
266
- * dots-only → returned BYTE FOR BYTE. This is what keeps the fix from
267
- * renaming anything that works today: `dataset.json`, `a_b.json`, every
268
- * name `codeRunnerTool` derives from a declared argument, all land at
269
- * exactly the path they landed at before.
270
- * B. anything else → `_enc_` + an escaped form, where `_` is the escape
271
- * character (`_` → `__`, any other illegal UTF-16 unit → `_uXXXX`). A
272
- * decoder is a left inverse of that, which is what makes it injective.
273
- *
274
- * Arm A never starts with `_enc_` (excluded by construction) and every arm B
275
- * output does, so no name from one arm can collide with a name from the other.
276
- * Both arms produce ASCII with no `/`, no `\` and no NUL, so the path-hop law
277
- * the old version enforced is unchanged.
310
+ * Bounded twice: the size the file reports is checked before a byte is read,
311
+ * and the read itself stops one chunk past `maxBytes` — a file that grew in
312
+ * between is refused too, never handed back cut.
278
313
  */
279
- function safeFileName(raw) {
280
- if (raw.length <= FILE_NAME_MAX &&
281
- FILE_NAME_ALREADY_LEGAL.test(raw) &&
282
- !raw.startsWith(ENCODED_FILE_PREFIX) &&
283
- !DOTS_ONLY.test(raw)) {
284
- return raw;
314
+ function readWithin(fs, root, relative, maxBytes) {
315
+ const realpath = fsMember(fs, 'realpathSync');
316
+ const open = fsMember(fs, 'openSync');
317
+ const fstat = fsMember(fs, 'fstatSync');
318
+ const read = fsMember(fs, 'readSync');
319
+ const close = fsMember(fs, 'closeSync');
320
+ let real;
321
+ let realRoot;
322
+ try {
323
+ realRoot = realpath(root);
324
+ real = realpath(`${root}/${relative}`);
285
325
  }
286
- const encoded = ENCODED_FILE_PREFIX + encodeFileName(raw);
287
- // Strictly less, so the two shapes below are told apart by LENGTH alone: a
288
- // digested name is always exactly FILE_NAME_MAX and an escaped one never is.
289
- if (encoded.length < FILE_NAME_MAX)
290
- return encoded;
291
- // Past the cap no mapping can stay injective — there are more names than
292
- // there are file names — so the tail becomes a digest of the WHOLE raw name.
293
- // SHA-256 rather than the package's 32-bit FNV-1a: a name reaching this
294
- // adapter is caller data, and a 32-bit digest is a collision somebody can go
295
- // and find, which for a file the model then reads as data is the same defect
296
- // this function exists to close.
297
- const digest = sha256Hex(raw).slice(0, 32);
298
- return `${encoded.slice(0, FILE_NAME_MAX - digest.length - 2)}_z${digest}`;
299
- }
300
- /** `_` → `__`, anything else illegal → `_uXXXX`. Injective by construction. */
301
- function encodeFileName(raw) {
302
- let out = '';
303
- for (let i = 0; i < raw.length; i++) {
304
- const ch = raw[i];
305
- if (ch === '_')
306
- out += '__';
307
- else if (FILE_CHAR_PASSES_THROUGH.test(ch))
308
- out += ch;
309
- else
310
- out += `_u${raw.charCodeAt(i).toString(16).padStart(4, '0')}`;
326
+ catch (err) {
327
+ throw notReturned(relative, errnoOf(err));
328
+ }
329
+ if (!isBelow(realRoot, real)) {
330
+ throw new CodeSandboxError('path-refused', `localCodeRunner: '${relative}' leads outside the working directory through a link, ` +
331
+ 'so it is not read.', { path: relative });
332
+ }
333
+ let fd;
334
+ try {
335
+ fd = open(real, readFlags(fs));
336
+ }
337
+ catch (err) {
338
+ throw notReturned(relative, errnoOf(err));
339
+ }
340
+ try {
341
+ const stat = fstat(fd);
342
+ if (!stat.isFile())
343
+ throw notReturned(relative, 'not a regular file');
344
+ if (stat.size > maxBytes)
345
+ throw tooLarge(relative, stat.size, maxBytes);
346
+ const chunks = [];
347
+ const chunk = new Uint8Array(Math.min(READ_CHUNK_BYTES, maxBytes + 1));
348
+ let total = 0;
349
+ for (;;) {
350
+ const n = read(fd, chunk, 0, chunk.length, null);
351
+ if (n === 0)
352
+ break;
353
+ total += n;
354
+ if (total > maxBytes)
355
+ throw tooLarge(relative, total, maxBytes);
356
+ chunks.push(chunk.slice(0, n));
357
+ }
358
+ const data = new Uint8Array(total);
359
+ let at = 0;
360
+ for (const part of chunks) {
361
+ data.set(part, at);
362
+ at += part.byteLength;
363
+ }
364
+ return { path: relative, data, bytes: total };
365
+ }
366
+ finally {
367
+ close(fd);
311
368
  }
312
- return out;
313
369
  }
314
370
  /**
315
- * SHA-256, hex.
316
- *
317
- * `node:crypto` rather than a fallback: this is only ever reached from
318
- * `stageInputs`, which has already required `node:fs` and written to a
319
- * temp directory. A runtime with a filesystem but no crypto is not a shape
320
- * this adapter can be in, and pretending otherwise with a weaker digest would
321
- * quietly widen the very collision the digest is here to close.
371
+ * How a read opens its file: read-only, and — where the platform has the flags
372
+ * — NON-BLOCKING, so a named pipe the session's code made cannot freeze this
373
+ * process waiting for a writer (the `isFile` check after the open refuses it),
374
+ * and NO-FOLLOW, so a link swapped in after the real-path check is not
375
+ * followed. Plain `'r'` where the platform has neither.
376
+ */
377
+ function readFlags(fs) {
378
+ const c = fs.constants;
379
+ if (c?.O_NONBLOCK === undefined)
380
+ return 'r';
381
+ return (c.O_RDONLY ?? 0) | c.O_NONBLOCK | (c.O_NOFOLLOW ?? 0);
382
+ }
383
+ /**
384
+ * Is `candidate` strictly below `root`? Both are real paths from the same
385
+ * `realpathSync`, so they share one spelling of the root and one separator
386
+ * style. String work rather than `node:path`, so reading files adds no Node
387
+ * module to the browser-safe door's call-time graph.
322
388
  */
323
- function sha256Hex(text) {
324
- const crypto = lazyRequire('node:crypto');
325
- return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
389
+ function isBelow(root, candidate) {
390
+ const separator = root.includes('\\') && !root.includes('/') ? '\\' : '/';
391
+ const base = root.endsWith(separator) ? root : `${root}${separator}`;
392
+ return candidate.startsWith(base) && candidate.length > base.length;
393
+ }
394
+ function fsMember(fs, name) {
395
+ const member = fs[name];
396
+ if (typeof member !== 'function') {
397
+ throw new Error(`localCodeRunner: \`node:fs\` resolved but \`${name}\` is missing.`);
398
+ }
399
+ return member;
400
+ }
401
+ function errnoOf(err) {
402
+ const code = err?.code;
403
+ return typeof code === 'string' ? code : 'unreadable';
404
+ }
405
+ function notReturned(relative, why) {
406
+ return new CodeSandboxError('file-not-returned', `localCodeRunner: no file could be read at '${relative}' (${why}).`, { path: relative });
407
+ }
408
+ function tooLarge(relative, bytes, maxBytes) {
409
+ return new CodeSandboxError('file-too-large', `localCodeRunner: '${relative}' is at least ${bytes} bytes and this read allows ` +
410
+ `${maxBytes}. Nothing was returned — a file is never handed back cut. Raise \`maxBytes\`, ` +
411
+ 'or have the code write something smaller.', { path: relative, bytes, limit: maxBytes });
326
412
  }
327
413
  function resolveChildProcess(options) {
328
414
  if (options._childProcess)
@@ -336,60 +422,320 @@ function resolveChildProcess(options) {
336
422
  '`agentCoreCodeRunner` — or supply your own `CodeRunner`.');
337
423
  }
338
424
  }
425
+ /**
426
+ * The answer for a process that started and then had an error reported for
427
+ * it: stopped, its outcome unknown. Never "could not run" — that says nothing
428
+ * ran, and this one may have done some or all of its work. The reason is the
429
+ * runtime's error code, a name; never its message, which can quote the
430
+ * command line.
431
+ */
432
+ function erroredAfterStart(what, reason) {
433
+ return new CodeSandboxError('error-after-start', `localCodeRunner: ${what} started, then the runtime reported an error for its process ` +
434
+ `(${reason}), and ${STOPPED}. Its outcome is unknown: it may have done some or all of ` +
435
+ 'its work.');
436
+ }
437
+ /** How much one child's stdout or stderr may hold before it is ended. The CUT
438
+ * is the adapter's to make and report (`maxOutputChars`); this only bounds
439
+ * memory. */
440
+ const CHILD_BUFFER_BYTES = 32 * 1024 * 1024;
441
+ /** The runtime's code for an output that passed `maxBuffer`. */
442
+ const BUFFER_OVERFLOW = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER';
443
+ /**
444
+ * Whether a child gets a process group of its own, so that the runner can stop
445
+ * the group it owns. POSIX only: Windows has no process group to signal, and
446
+ * there only the child itself can be stopped.
447
+ */
448
+ const OWN_PROCESS_GROUP = process.platform !== 'win32';
449
+ /** What ending a child at its deadline did, in words that are true of it. */
450
+ const STOPPED = OWN_PROCESS_GROUP
451
+ ? 'its process group was killed'
452
+ : 'its process was stopped; work it started may still be running';
453
+ /**
454
+ * Process groups this runner started and has not seen end. A group lives in
455
+ * its own session, so a signal sent to the host's group — Ctrl-C in a
456
+ * terminal — never reaches it. These are killed when the host exits.
457
+ */
458
+ const liveGroups = new Set();
459
+ let exitHookArmed = false;
460
+ function trackGroup(pid) {
461
+ liveGroups.add(pid);
462
+ if (exitHookArmed)
463
+ return;
464
+ exitHookArmed = true;
465
+ process.once('exit', () => {
466
+ for (const group of liveGroups)
467
+ killGroup(group);
468
+ });
469
+ }
470
+ function killGroup(pid) {
471
+ try {
472
+ process.kill(-pid, 'SIGKILL');
473
+ }
474
+ catch {
475
+ // Already gone: nothing left to stop.
476
+ }
477
+ }
478
+ /**
479
+ * Stop a child's OWNED PROCESS GROUP, then release its pipes — every path on
480
+ * which the runner ends a run goes through here: the deadline, an abort, the
481
+ * output ceiling, an error after a start.
482
+ *
483
+ * Signalling only the direct child (the shell, or the interpreter) leaves its
484
+ * own children running: a `sh -c` command that started a Node script would
485
+ * keep writing files after the caller was told it had been killed. The group
486
+ * goes, with SIGKILL, which none of its members can ignore, and the pipes are
487
+ * closed so that nothing still holding them keeps the caller waiting.
488
+ *
489
+ * Cleanup, not containment: a descendant that starts a session or a group of
490
+ * its own has left the owned group and is not stopped, and a host that is
491
+ * killed without running its JavaScript exit handlers cannot stop anything.
492
+ * This runner is not a sandbox (see the module header).
493
+ */
494
+ function endChild(child) {
495
+ if (child === undefined)
496
+ return;
497
+ if (OWN_PROCESS_GROUP && typeof child.pid === 'number') {
498
+ killGroup(child.pid);
499
+ liveGroups.delete(child.pid);
500
+ }
501
+ else {
502
+ try {
503
+ child.kill?.('SIGKILL');
504
+ }
505
+ catch {
506
+ // Already gone.
507
+ }
508
+ }
509
+ try {
510
+ child.stdout?.destroy?.();
511
+ child.stderr?.destroy?.();
512
+ }
513
+ catch {
514
+ // A stream the runtime already closed.
515
+ }
516
+ }
517
+ /**
518
+ * A child `error` EVENT as the callback is handed it: its string code when it
519
+ * has one, and nothing else. Never a numeric code or a signal — those are what
520
+ * an EXIT reports (`childFailure`), so the two cannot be taken for each other —
521
+ * and never the runtime's own error, which carries the arguments
522
+ * (`spawnargs`), and so the code itself.
523
+ */
524
+ function childEventError(error) {
525
+ return Object.assign(new Error('the runtime reported an error for the child process'), {
526
+ ...(typeof error.code === 'string' && { code: error.code }),
527
+ });
528
+ }
529
+ /** The error a child that exited non-zero, or was ended by a signal, reports:
530
+ * the fields `execFile` carries, and a message that never quotes the
531
+ * command line (the runtime's own does — it is the code). */
532
+ function childFailure(code, signal) {
533
+ return Object.assign(new Error('the child process did not exit cleanly'), {
534
+ ...(code !== null && { code }),
535
+ signal,
536
+ killed: signal !== null,
537
+ });
538
+ }
539
+ /**
540
+ * `execFile`'s callback contract, built on `spawn` — because only `spawn`
541
+ * passes `detached` on, and a child in a process group of its own is what
542
+ * lets the runner stop the group it owns. Same results: stdout and stderr as
543
+ * text, an exit failure carrying the exit `code` or the `signal`, a child
544
+ * `error` event carrying its string code at most (`childEventError`), and
545
+ * `ERR_CHILD_PROCESS_STDIO_MAXBUFFER` when a stream passes `maxBuffer` bytes,
546
+ * with what was collected up to the ceiling. At that ceiling, and on any child
547
+ * `error` event, the owned group is stopped AT ONCE and the call answered then
548
+ * (`endChild`), not when the pipes close. stdin is not connected: nothing can
549
+ * block waiting for input that never comes.
550
+ */
551
+ function execFileOn(spawn) {
552
+ return (file, args, options, callback) => {
553
+ const max = options.maxBuffer ?? CHILD_BUFFER_BYTES;
554
+ const chunks = { stdout: [], stderr: [] };
555
+ const sizes = { stdout: 0, stderr: 0 };
556
+ let overflow;
557
+ let done = false;
558
+ const text = (stream) => Buffer.concat(chunks[stream]).toString('utf8');
559
+ const reply = (error) => {
560
+ if (done)
561
+ return;
562
+ done = true;
563
+ callback(error, text('stdout'), text('stderr'));
564
+ };
565
+ const child = spawn(file, args, {
566
+ ...(options.cwd !== undefined && { cwd: options.cwd }),
567
+ ...(options.env !== undefined && { env: options.env }),
568
+ ...(options.detached === true && { detached: true }),
569
+ stdio: ['ignore', 'pipe', 'pipe'],
570
+ });
571
+ const collect = (stream) => (chunk) => {
572
+ if (overflow !== undefined)
573
+ return;
574
+ const room = max - sizes[stream];
575
+ if (chunk.byteLength > room) {
576
+ chunks[stream].push(chunk.subarray(0, Math.max(room, 0)));
577
+ sizes[stream] = max;
578
+ overflow = Object.assign(new RangeError(`${stream} maxBuffer length exceeded`), {
579
+ code: BUFFER_OVERFLOW,
580
+ });
581
+ // Stop the owned process GROUP here, and release the pipes, then
582
+ // answer: waiting for `close` would wait for any descendant that
583
+ // inherited the pipes — and it keeps working until it lets go.
584
+ endChild(child);
585
+ reply(overflow);
586
+ return;
587
+ }
588
+ chunks[stream].push(chunk);
589
+ sizes[stream] += chunk.byteLength;
590
+ };
591
+ child.stdout?.on('data', collect('stdout'));
592
+ child.stderr?.on('data', collect('stderr'));
593
+ // A child `error` EVENT is not an exit, and is handed on apart from one.
594
+ // Whatever it carries — a code or none — the runner stops what it owns
595
+ // NOW: a child that started may still be running, and nothing more will be
596
+ // heard from it here. (Before a start there is no group; this is a no-op.)
597
+ child.on('error', (error) => {
598
+ endChild(child);
599
+ reply(childEventError(error));
600
+ });
601
+ child.on('close', (code, signal) => {
602
+ if (overflow !== undefined)
603
+ reply(overflow);
604
+ else if (code === 0 && signal === null)
605
+ reply(null);
606
+ else
607
+ reply(childFailure(code, signal));
608
+ });
609
+ return child;
610
+ };
611
+ }
339
612
  /**
340
613
  * One child process, promisified.
341
614
  *
342
615
  * A NON-ZERO EXIT IS NOT A REJECTION. Code that fails is a normal answer here —
343
616
  * the model wrote it, it needs to read the traceback, and turning that into a
344
617
  * thrown tool error would replace the one thing that teaches the next attempt
345
- * with `error: true`. What DOES reject is the runner failing to run at all
346
- * (interpreter missing, timeout, abort), because that is not the code's answer.
618
+ * with `error: true`. What DOES reject is the runner failing to run it at all
619
+ * (interpreter missing), stopping it (timeout, abort), or losing it — the
620
+ * runtime reported an error for a process that had started, which is stopped
621
+ * and whose outcome is unknown — because none of those is the code's answer.
622
+ *
623
+ * THE DEADLINE IS OURS (`lib/abort.ts`), not the runtime's `timeout`: that one
624
+ * sends SIGTERM and then waits for the child to exit, so a child that ignored
625
+ * SIGTERM ran on, and if it then exited 0 the call came back as a SUCCESS with
626
+ * its output cut at the deadline. Here the deadline and the caller's signal
627
+ * abort one controller; the child's owned PROCESS GROUP is killed with
628
+ * SIGKILL (`endChild`), and the call rejects at once, whatever the child does
629
+ * next.
630
+ *
631
+ * An abort rejects with the caller's own reason, never read as a missing
632
+ * interpreter. So did an output past the buffer ceiling
633
+ * (`ERR_CHILD_PROCESS_STDIO_MAXBUFFER`); that is now a result, its cut stated,
634
+ * with no exit status and `signal: 'SIGKILL'`.
635
+ *
636
+ * Nothing of the runtime's own error text is passed on: for a failed child it
637
+ * is "Command failed: <the whole command line>", which is the code itself.
347
638
  */
348
- async function runChild(mod, file, args, opts) {
349
- const execFile = mod.execFile;
639
+ async function runChild(mod, file, args, opts, refusals) {
640
+ const execFile = typeof mod.spawn === 'function' ? execFileOn(mod.spawn.bind(mod)) : mod.execFile;
350
641
  if (typeof execFile !== 'function') {
351
642
  throw new Error('localCodeRunner: `node:child_process` resolved but `execFile` is missing.');
352
643
  }
644
+ throwIfAborted(opts.signal);
645
+ const { controller, unlink } = linkedController(opts.signal);
646
+ const disarm = armDeadline(controller, opts.timeoutMs, () => refusals.timedOut(opts.timeoutMs));
353
647
  return new Promise((resolve, reject) => {
354
- execFile(file, args, {
355
- cwd: opts.cwd,
356
- env: opts.env,
357
- timeout: opts.timeoutMs,
358
- // Generous, because the CUT is ours to make and to report — letting the
359
- // runtime's own buffer overflow would surface as an error whose message
360
- // says nothing about how much was lost.
361
- maxBuffer: 32 * 1024 * 1024,
362
- ...(opts.signal && { signal: opts.signal }),
363
- }, (error, stdout, stderr) => {
364
- if (!error) {
365
- resolve({ stdout: String(stdout ?? ''), stderr: String(stderr ?? ''), exitCode: 0 });
366
- return;
367
- }
368
- if (error.killed === true) {
369
- reject(new Error(`localCodeRunner: the code did not finish within ${opts.timeoutMs}ms and the ` +
370
- 'process was killed. Raise `timeoutMs`, or have the code do less per call.'));
371
- return;
372
- }
373
- if (typeof error.code === 'string') {
374
- // ENOENT and friends — the interpreter itself is not here. Naming it
375
- // beats handing the model a traceback it cannot act on.
376
- reject(new Error(`localCodeRunner: could not run '${file}' (${error.code}). ` +
377
- 'Install the interpreter, or pass `command` pointing at one.'));
378
- return;
379
- }
380
- // A real non-zero exit: the code ran and failed. That IS the result.
381
- resolve({
382
- stdout: String(stdout ?? ''),
383
- stderr: String(stderr ?? '') || error.message,
384
- exitCode: typeof error.code === 'number' ? error.code : 1,
648
+ let child;
649
+ let settled = false;
650
+ const settle = () => {
651
+ if (settled)
652
+ return false;
653
+ settled = true;
654
+ disarm();
655
+ unlink();
656
+ controller.signal.removeEventListener('abort', onAbort);
657
+ if (OWN_PROCESS_GROUP && typeof child?.pid === 'number')
658
+ liveGroups.delete(child.pid);
659
+ return true;
660
+ };
661
+ // The deadline, or the caller: stop the owned group and answer NOW. The
662
+ // child's own callback, whenever it comes, finds the call settled.
663
+ const onAbort = () => {
664
+ endChild(child);
665
+ if (settle())
666
+ reject(abortErrorOf(controller.signal));
667
+ };
668
+ try {
669
+ child = execFile(file, args, {
670
+ cwd: opts.cwd,
671
+ env: opts.env,
672
+ maxBuffer: CHILD_BUFFER_BYTES,
673
+ killSignal: 'SIGKILL',
674
+ ...(OWN_PROCESS_GROUP && { detached: true }),
675
+ }, (error, stdout, stderr) => {
676
+ if (controller.signal.aborted) {
677
+ onAbort();
678
+ return;
679
+ }
680
+ if (!settle())
681
+ return;
682
+ const out = String(stdout ?? '');
683
+ const err = String(stderr ?? '');
684
+ if (!error) {
685
+ resolve({ stdout: out, stderr: err, exitCode: 0 });
686
+ return;
687
+ }
688
+ if (error.code === BUFFER_OVERFLOW) {
689
+ // On the `spawn` path the group was stopped where the overflow was
690
+ // seen; this covers an injected `execFile`, which ends only the
691
+ // direct child. A second stop of a stopped group is a no-op.
692
+ endChild(child);
693
+ resolve({
694
+ stdout: out,
695
+ stderr: err,
696
+ signal: 'SIGKILL',
697
+ overflowed: /^stderr/.test(error.message) ? 'stderr' : 'stdout',
698
+ });
699
+ return;
700
+ }
701
+ if (typeof error.code === 'number' || typeof error.signal === 'string') {
702
+ // An EXIT — a real non-zero one, or a child the operating system
703
+ // ended: the code ran and failed. That IS the result. The exit
704
+ // status is the child's own, or absent — never a stand-in; the
705
+ // signal says how a child without one was ended.
706
+ resolve({
707
+ stdout: out,
708
+ stderr: err,
709
+ ...(typeof error.code === 'number' && { exitCode: error.code }),
710
+ ...(typeof error.signal === 'string' && { signal: error.signal }),
711
+ });
712
+ return;
713
+ }
714
+ // Not an exit: the runtime's `error` event, with a code or without.
715
+ // What the runner owns is stopped whatever it carries (a no-op when
716
+ // `execFileOn` already did), and the answer says whether anything
717
+ // ran. A child the runtime gave a process id STARTED, and may have
718
+ // done some or all of its work: its outcome is unknown. One it did
719
+ // not never ran — ENOENT and friends, the interpreter is not here;
720
+ // naming it beats a traceback the model cannot act on.
721
+ endChild(child);
722
+ const reason = typeof error.code === 'string' && error.code !== '' ? error.code : 'no error code';
723
+ reject(typeof child?.pid === 'number'
724
+ ? refusals.failedAfterStart(reason)
725
+ : refusals.notRunnable(reason));
385
726
  });
386
- });
727
+ }
728
+ catch (err) {
729
+ if (settle())
730
+ reject(err);
731
+ return;
732
+ }
733
+ if (OWN_PROCESS_GROUP && typeof child?.pid === 'number' && !settled)
734
+ trackGroup(child.pid);
735
+ if (controller.signal.aborted)
736
+ onAbort();
737
+ else if (!settled)
738
+ controller.signal.addEventListener('abort', onAbort, { once: true });
387
739
  });
388
740
  }
389
- /** Cut to `max` characters, saying whether it cut. */
390
- function cut(text, max) {
391
- if (text.length <= max)
392
- return { text, cut: false };
393
- return { text: text.slice(0, max), cut: true };
394
- }
395
741
  //# sourceMappingURL=local.js.map