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