UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

741 lines • 36.6 kB
/** * localCodeRunner — run code in a child process, on this machine. * * Pattern: Adapter (GoF) over `node:child_process`. * Role: the dev default behind the {@link CodeRunner} port. `agentCoreCodeRunner` * is the production swap; the tool code does not change. * Emits: nothing. The tool that owns the session emits. * * ── ISOLATION, NOT A SANDBOX. Read this before deploying it. ──────────────── * The name is `localCodeRunner`, never `sandboxedCodeRunner`, and the * difference is not modesty — it is the whole security posture. * * What a child process DOES give you: * • a separate process and heap — a crash or an OOM takes the child, not you; * • kill on timeout, so runaway code has a ceiling — on POSIX the child * runs in a process group of its own, and at the timeout, on an abort or * at the output ceiling the runner stops that OWNED group (SIGKILL). That * is cleanup, not containment: a descendant that starts its own session * or group escapes it, and a host killed before its exit handlers run * stops nothing. On Windows only the child itself can be stopped, and the * timeout error says so; * • no inherited stdin, so nothing can block waiting for a terminal; * • an environment ALLOWLIST — `process.env` is not inherited (only `PATH`, * so the interpreter can be found), so an `AWS_SECRET_ACCESS_KEY` in your * shell is not in the model's reach; * • a working directory, by convention. * * What it does NOT give you, at all: * • a filesystem jail — the code can read and write anywhere this process can; * • a network jail — it can call out; * • CPU or memory limits beyond the timeout; * • any protection from code that deletes something outside `cwd`. * * ── It stages inputs; it does NOT report outputs (a decision, not a gap) ──── * `stageInputs` puts declared artifact payloads INTO the session, and the code * reads them from the manifest. Nothing comes back the same way: this runner * leaves `CodeResult.artifacts` ABSENT — never `[]`, which would claim the code * produced nothing — because it has no output location to collect from. The * child's working directory is the CALLER'S OWN cwd, so "files the code wrote" * is not a set this adapter can identify without guessing which of a developer's * files were meant, and a dev-loop runner that quietly uploaded whatever * appeared beside your source would be the worse failure. Producing outputs * needs a declared output directory the model is told about and a size policy * for what is too big to carry in-band; `readFile` below is the bounded * read-back, for a caller that names the file. A runner that has all three * fills the field, and `codeRunnerTool` mints every data-carrying entry with * no further wiring. * * ── Files and commands ───────────────────────────────────────────────────── * `readFile(path, { maxBytes })` reads ONE file back from the working * directory — the path checked by the port's one rule (`sandboxPath`), then * by its REAL location, so a symbolic link cannot lead it outside; a file over * `maxBytes` is refused, never cut; a file on disk is bytes, so bytes come * back and no encoding is guessed. `runCommand(command)` runs ONE `sh -c` * command with the same environment allowlist as the code, and exists only on * a runner built with `commands: true`. Neither is a network or filesystem * jail: a command reaches whatever this machine reaches. * * So: a development loop, a trusted-input pipeline, a machine you would be * relaxed about a shell script running on. NOT arbitrary model-written code * from an untrusted user, on a host with anything on it. For that, run a real * sandbox behind the same port — `agentCoreCodeRunner`, a gVisor container, a * Firecracker VM — and keep the tool identical. * * **In-process `eval` / `node:vm` is refused outright.** Node's own * documentation says `vm` is not a security mechanism ("do not use it to run * untrusted code"), so shipping it as one would be theater: the same code * reaching the same globals, wearing a word that makes a reader stop checking. * A subprocess is genuinely a boundary; it is just a smaller one than the word * "sandbox" implies. The library teaches refusals for things that are WRONG and * honest names for things that are merely LIMITED — running local code in a dev * loop is not wrong, and calling it a sandbox is. * * @example * const agent = Agent.create({ provider }) * .tool(codeRunnerTool({ runner: localCodeRunner() })) * .build(); */ import { STAGED_INPUTS_ENV } from '../../provider-contract/types.js'; import { CodeSandboxError, readBound, sandboxPath } from '../../provider-contract/codeSandbox.js'; import { abortErrorOf, armDeadline, linkedController, throwIfAborted } from '../../lib/abort.js'; import { lazyRequire } from '../../lib/lazyRequire.js'; import { safeFileName } from './fileName.js'; import { boundOutput, commandBounds, commandResult, payloadBytes } from './output.js'; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_CHARS = 8_000; /** How each supported language is invoked when no `command` was given. */ const DEFAULT_COMMANDS = { javascript: ['node', '-e'], js: ['node', '-e'], node: ['node', '-e'], typescript: ['npx', 'tsx', '-e'], ts: ['npx', 'tsx', '-e'], python: ['python3', '-c'], python3: ['python3', '-c'], py: ['python3', '-c'], }; export function localCodeRunner(options = {}) { const id = options.id ?? 'local-code-runner'; const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS; return { id, start(req) { // A local session is a NAME and a working directory, not a live handle: // each execution is its own process. Saying so matters — a consumer who // assumed variables carry between calls (they do on AgentCore, whose // session is a live kernel) would silently get different semantics. // `codeRunnerTool` states this in the tool description it hands the model. const sessionId = `local:${req.key}`; let stopped = false; /** The session's staging directory, created on the FIRST `stageInputs` * and released by `stop()`. Absent until then, which is what keeps a * session that never stages byte-identical to every earlier release — * no directory, no environment variable, no filesystem module loaded. */ let stagingDir; /** name → path, exactly as the manifest carries it. */ const staged = new Map(); /** The child's environment, for code and commands alike. */ const childEnv = () => ({ // ALLOWLIST. `process.env` is deliberately NOT spread in — only // PATH, so the OS can find the interpreter, and it is overridable. PATH: process.env.PATH ?? '', ...(options.env ?? {}), // The staged-input manifest joins it LAST and is not overridable: // it is not configuration, it is a fact about this execution, and // an operator `env` that happened to define the same name would // otherwise hand the model paths to files that are not there. // Absent entirely when nothing was staged. ...(staged.size > 0 && { [STAGED_INPUTS_ENV]: JSON.stringify(Object.fromEntries(staged)), }), }); const refuseIfStopped = (toDo) => { if (stopped) { throw new Error(`localCodeRunner: session ${sessionId} was stopped; open a new one to ${toDo}.`); } }; const session = { id: sessionId, async execute(exec) { refuseIfStopped('run more code'); const language = exec.language ?? req.language ?? 'javascript'; const command = options.command ?? DEFAULT_COMMANDS[language.toLowerCase()]; if (!command || command.length === 0) { throw new Error(`localCodeRunner: no command for language '${language}'. ` + `Known: ${Object.keys(DEFAULT_COMMANDS).join(', ')}. ` + "Pass `command: ['your-interpreter', '-c']` for anything else."); } const [file, ...args] = command; const raw = await runChild(resolveChildProcess(options), file, [...args, exec.code], { cwd: options.cwd ?? process.cwd(), env: childEnv(), timeoutMs: exec.timeoutMs ?? timeoutMs, ...(exec.signal && { signal: exec.signal }), }, { timedOut: (ms) => new Error(`localCodeRunner: the code did not finish within ${ms}ms and ${STOPPED}. ` + 'Raise `timeoutMs`, or have the code do less per call.'), notRunnable: (code) => new Error(`localCodeRunner: could not run '${file}' (${code}). ` + 'Install the interpreter, or pass `command` pointing at one.'), failedAfterStart: (reason) => erroredAfterStart('the code', reason), }); // The child's own streams only. The runtime's message for a failed // child ("Command failed: <the command line>") quotes the code, so // it is not passed off as the code's stderr. const bounded = boundOutput(raw.stdout, raw.stderr, maxOutputChars, raw.overflowed); return { // Known either way: an exit status, or a process that was ended // before it exited (no exit status at all — never a made-up one; // the signal that ended it, when known, says how). ok: raw.exitCode === 0, stdout: bounded.stdout, stderr: bounded.stderr, ...(raw.exitCode !== undefined && { exitCode: raw.exitCode }), ...(raw.signal !== undefined && { terminatedBy: raw.signal }), ...(bounded.truncated !== undefined && { truncated: bounded.truncated }), }; }, // eslint-disable-next-line @typescript-eslint/require-await async stageInputs(inputs) { refuseIfStopped('stage inputs'); const fs = resolveFs(options, 'staging inputs'); const landed = []; stagingDir ??= makeStagingDir(fs, options._stagingRoot); for (const input of inputs) { // The name is CALLER data landing in a filesystem, so it is // ENCODED into a single inert segment here rather than trusted: a // name of '../../etc/passwd' becomes a literal file name, two // distinct names never become one file, and the manifest still // keys it by what was asked for. const fileName = safeFileName(input.fileName ?? input.name); const path = `${stagingDir}/${fileName}`; const bytes = payloadBytes(input.data); writeFile(fs, path, input.data); staged.set(input.name, path); landed.push({ name: input.name, path, bytes }); } return landed; }, // eslint-disable-next-line @typescript-eslint/require-await async readFile(path, readOptions) { refuseIfStopped('read files'); const maxBytes = readBound(readOptions); throwIfAborted(readOptions.signal); const relative = sandboxPath(path); return readWithin(resolveFs(options, 'reading files'), options.cwd ?? process.cwd(), relative, maxBytes); }, ...(options.commands === true && { async runCommand(command, commandOptions) { refuseIfStopped('run commands'); const bounds = commandBounds(command, commandOptions, maxOutputChars); const raw = await runChild(resolveChildProcess(options), 'sh', ['-c', command], { cwd: options.cwd ?? process.cwd(), env: childEnv(), timeoutMs: bounds.timeoutMs ?? timeoutMs, ...(bounds.signal && { signal: bounds.signal }), }, { timedOut: (ms) => new CodeSandboxError('timed-out', `localCodeRunner: the command did not finish within ${ms}ms and ${STOPPED}.`), notRunnable: (code) => new Error(`localCodeRunner: could not run 'sh' (${code}); runCommand needs a POSIX ` + 'shell on PATH.'), failedAfterStart: (reason) => erroredAfterStart('the command', reason), }); // The child's own streams and exit status, nothing added: a // process the operating system ended has no exit status, and the // result says so by leaving it out. return commandResult({ stdout: raw.stdout, stderr: raw.stderr, ...(raw.exitCode !== undefined && { exitCode: raw.exitCode }), ...(raw.overflowed !== undefined && { overflowed: raw.overflowed }), ...(raw.signal !== undefined && { terminatedBy: raw.signal }), }, bounds); }, }), stop() { // Idempotent, and it refuses later executions rather than silently // starting a fresh process under a session the caller closed. stopped = true; // Staged inputs live as long as the session, and this is the end of // it. Failure is swallowed: a temp directory that could not be // removed is a tidiness problem, and turning it into a thrown // teardown would fail a turn that already succeeded. if (stagingDir !== undefined) { try { resolveFs(options, 'staging inputs').rmSync?.(stagingDir, { recursive: true, force: true, }); } catch { // See above. } stagingDir = undefined; staged.clear(); } return Promise.resolve(); }, }; return Promise.resolve(session); }, }; } /** `node:fs`, or a refusal naming what `purpose` needs. Lazy for the same * reason `node:child_process` is: a browser bundle that never stages or * reads must not pay for a module it cannot have. */ function resolveFs(options, purpose) { if (options._fs) return options._fs; try { return lazyRequire('node:fs'); } catch { throw new Error(purpose === 'staging inputs' ? 'localCodeRunner: staging inputs needs `node:fs`, which this runtime does not provide ' + '(a browser bundle, or a restricted worker). Use a runner whose backend can write ' + 'into its own session, or drop the tool’s `wants` declaration.' : 'localCodeRunner: reading files needs `node:fs`, which this runtime does not provide ' + '(a browser bundle, or a restricted worker); a runner whose backend reads its own ' + 'session can.'); } } /** One private directory per session, under the OS temp root. */ function makeStagingDir(fs, root) { const mkdtempSync = fs.mkdtempSync; if (typeof mkdtempSync !== 'function') { throw new Error('localCodeRunner: `node:fs` resolved but `mkdtempSync` is missing.'); } const base = root ?? lazyRequire('node:os').tmpdir(); return mkdtempSync(`${base}/af-code-`); } function writeFile(fs, path, data) { const writeFileSync = fs.writeFileSync; if (typeof writeFileSync !== 'function') { throw new Error('localCodeRunner: `node:fs` resolved but `writeFileSync` is missing.'); } writeFileSync(path, data); } /** How much of a file one `readSync` asks for. */ const READ_CHUNK_BYTES = 64 * 1024; /** * Read ONE file under `root`, whole or not at all. * * `relative` has already passed the port's lexical rule; this adds the one * check only the caller's own machine can make — the file's REAL location, * after every symbolic link, must still be inside the real working directory. * (A sandbox has no such check to make: its whole disk is its own.) The check * and the open are two calls, not one atomic step; this is a development-loop * runner, and its module header says what it is not. * * Bounded twice: the size the file reports is checked before a byte is read, * and the read itself stops one chunk past `maxBytes` — a file that grew in * between is refused too, never handed back cut. */ function readWithin(fs, root, relative, maxBytes) { const realpath = fsMember(fs, 'realpathSync'); const open = fsMember(fs, 'openSync'); const fstat = fsMember(fs, 'fstatSync'); const read = fsMember(fs, 'readSync'); const close = fsMember(fs, 'closeSync'); let real; let realRoot; try { realRoot = realpath(root); real = realpath(`${root}/${relative}`); } catch (err) { throw notReturned(relative, errnoOf(err)); } if (!isBelow(realRoot, real)) { throw new CodeSandboxError('path-refused', `localCodeRunner: '${relative}' leads outside the working directory through a link, ` + 'so it is not read.', { path: relative }); } let fd; try { fd = open(real, readFlags(fs)); } catch (err) { throw notReturned(relative, errnoOf(err)); } try { const stat = fstat(fd); if (!stat.isFile()) throw notReturned(relative, 'not a regular file'); if (stat.size > maxBytes) throw tooLarge(relative, stat.size, maxBytes); const chunks = []; const chunk = new Uint8Array(Math.min(READ_CHUNK_BYTES, maxBytes + 1)); let total = 0; for (;;) { const n = read(fd, chunk, 0, chunk.length, null); if (n === 0) break; total += n; if (total > maxBytes) throw tooLarge(relative, total, maxBytes); chunks.push(chunk.slice(0, n)); } const data = new Uint8Array(total); let at = 0; for (const part of chunks) { data.set(part, at); at += part.byteLength; } return { path: relative, data, bytes: total }; } finally { close(fd); } } /** * How a read opens its file: read-only, and — where the platform has the flags * — NON-BLOCKING, so a named pipe the session's code made cannot freeze this * process waiting for a writer (the `isFile` check after the open refuses it), * and NO-FOLLOW, so a link swapped in after the real-path check is not * followed. Plain `'r'` where the platform has neither. */ function readFlags(fs) { const c = fs.constants; if (c?.O_NONBLOCK === undefined) return 'r'; return (c.O_RDONLY ?? 0) | c.O_NONBLOCK | (c.O_NOFOLLOW ?? 0); } /** * Is `candidate` strictly below `root`? Both are real paths from the same * `realpathSync`, so they share one spelling of the root and one separator * style. String work rather than `node:path`, so reading files adds no Node * module to the browser-safe door's call-time graph. */ function isBelow(root, candidate) { const separator = root.includes('\\') && !root.includes('/') ? '\\' : '/'; const base = root.endsWith(separator) ? root : `${root}${separator}`; return candidate.startsWith(base) && candidate.length > base.length; } function fsMember(fs, name) { const member = fs[name]; if (typeof member !== 'function') { throw new Error(`localCodeRunner: \`node:fs\` resolved but \`${name}\` is missing.`); } return member; } function errnoOf(err) { const code = err?.code; return typeof code === 'string' ? code : 'unreadable'; } function notReturned(relative, why) { return new CodeSandboxError('file-not-returned', `localCodeRunner: no file could be read at '${relative}' (${why}).`, { path: relative }); } function tooLarge(relative, bytes, maxBytes) { return new CodeSandboxError('file-too-large', `localCodeRunner: '${relative}' is at least ${bytes} bytes and this read allows ` + `${maxBytes}. Nothing was returned — a file is never handed back cut. Raise \`maxBytes\`, ` + 'or have the code write something smaller.', { path: relative, bytes, limit: maxBytes }); } function resolveChildProcess(options) { if (options._childProcess) return options._childProcess; try { return lazyRequire('node:child_process'); } catch { throw new Error('localCodeRunner needs `node:child_process`, which this runtime does not provide ' + '(a browser bundle, or a restricted worker). Use a remote runner — ' + '`agentCoreCodeRunner` — or supply your own `CodeRunner`.'); } } /** * The answer for a process that started and then had an error reported for * it: stopped, its outcome unknown. Never "could not run" — that says nothing * ran, and this one may have done some or all of its work. The reason is the * runtime's error code, a name; never its message, which can quote the * command line. */ function erroredAfterStart(what, reason) { return new CodeSandboxError('error-after-start', `localCodeRunner: ${what} started, then the runtime reported an error for its process ` + `(${reason}), and ${STOPPED}. Its outcome is unknown: it may have done some or all of ` + 'its work.'); } /** How much one child's stdout or stderr may hold before it is ended. The CUT * is the adapter's to make and report (`maxOutputChars`); this only bounds * memory. */ const CHILD_BUFFER_BYTES = 32 * 1024 * 1024; /** The runtime's code for an output that passed `maxBuffer`. */ const BUFFER_OVERFLOW = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'; /** * Whether a child gets a process group of its own, so that the runner can stop * the group it owns. POSIX only: Windows has no process group to signal, and * there only the child itself can be stopped. */ const OWN_PROCESS_GROUP = process.platform !== 'win32'; /** What ending a child at its deadline did, in words that are true of it. */ const STOPPED = OWN_PROCESS_GROUP ? 'its process group was killed' : 'its process was stopped; work it started may still be running'; /** * Process groups this runner started and has not seen end. A group lives in * its own session, so a signal sent to the host's group — Ctrl-C in a * terminal — never reaches it. These are killed when the host exits. */ const liveGroups = new Set(); let exitHookArmed = false; function trackGroup(pid) { liveGroups.add(pid); if (exitHookArmed) return; exitHookArmed = true; process.once('exit', () => { for (const group of liveGroups) killGroup(group); }); } function killGroup(pid) { try { process.kill(-pid, 'SIGKILL'); } catch { // Already gone: nothing left to stop. } } /** * Stop a child's OWNED PROCESS GROUP, then release its pipes — every path on * which the runner ends a run goes through here: the deadline, an abort, the * output ceiling, an error after a start. * * Signalling only the direct child (the shell, or the interpreter) leaves its * own children running: a `sh -c` command that started a Node script would * keep writing files after the caller was told it had been killed. The group * goes, with SIGKILL, which none of its members can ignore, and the pipes are * closed so that nothing still holding them keeps the caller waiting. * * Cleanup, not containment: a descendant that starts a session or a group of * its own has left the owned group and is not stopped, and a host that is * killed without running its JavaScript exit handlers cannot stop anything. * This runner is not a sandbox (see the module header). */ function endChild(child) { if (child === undefined) return; if (OWN_PROCESS_GROUP && typeof child.pid === 'number') { killGroup(child.pid); liveGroups.delete(child.pid); } else { try { child.kill?.('SIGKILL'); } catch { // Already gone. } } try { child.stdout?.destroy?.(); child.stderr?.destroy?.(); } catch { // A stream the runtime already closed. } } /** * A child `error` EVENT as the callback is handed it: its string code when it * has one, and nothing else. Never a numeric code or a signal — those are what * an EXIT reports (`childFailure`), so the two cannot be taken for each other — * and never the runtime's own error, which carries the arguments * (`spawnargs`), and so the code itself. */ function childEventError(error) { return Object.assign(new Error('the runtime reported an error for the child process'), { ...(typeof error.code === 'string' && { code: error.code }), }); } /** The error a child that exited non-zero, or was ended by a signal, reports: * the fields `execFile` carries, and a message that never quotes the * command line (the runtime's own does — it is the code). */ function childFailure(code, signal) { return Object.assign(new Error('the child process did not exit cleanly'), { ...(code !== null && { code }), signal, killed: signal !== null, }); } /** * `execFile`'s callback contract, built on `spawn` — because only `spawn` * passes `detached` on, and a child in a process group of its own is what * lets the runner stop the group it owns. Same results: stdout and stderr as * text, an exit failure carrying the exit `code` or the `signal`, a child * `error` event carrying its string code at most (`childEventError`), and * `ERR_CHILD_PROCESS_STDIO_MAXBUFFER` when a stream passes `maxBuffer` bytes, * with what was collected up to the ceiling. At that ceiling, and on any child * `error` event, the owned group is stopped AT ONCE and the call answered then * (`endChild`), not when the pipes close. stdin is not connected: nothing can * block waiting for input that never comes. */ function execFileOn(spawn) { return (file, args, options, callback) => { const max = options.maxBuffer ?? CHILD_BUFFER_BYTES; const chunks = { stdout: [], stderr: [] }; const sizes = { stdout: 0, stderr: 0 }; let overflow; let done = false; const text = (stream) => Buffer.concat(chunks[stream]).toString('utf8'); const reply = (error) => { if (done) return; done = true; callback(error, text('stdout'), text('stderr')); }; const child = spawn(file, args, { ...(options.cwd !== undefined && { cwd: options.cwd }), ...(options.env !== undefined && { env: options.env }), ...(options.detached === true && { detached: true }), stdio: ['ignore', 'pipe', 'pipe'], }); const collect = (stream) => (chunk) => { if (overflow !== undefined) return; const room = max - sizes[stream]; if (chunk.byteLength > room) { chunks[stream].push(chunk.subarray(0, Math.max(room, 0))); sizes[stream] = max; overflow = Object.assign(new RangeError(`${stream} maxBuffer length exceeded`), { code: BUFFER_OVERFLOW, }); // Stop the owned process GROUP here, and release the pipes, then // answer: waiting for `close` would wait for any descendant that // inherited the pipes — and it keeps working until it lets go. endChild(child); reply(overflow); return; } chunks[stream].push(chunk); sizes[stream] += chunk.byteLength; }; child.stdout?.on('data', collect('stdout')); child.stderr?.on('data', collect('stderr')); // A child `error` EVENT is not an exit, and is handed on apart from one. // Whatever it carries — a code or none — the runner stops what it owns // NOW: a child that started may still be running, and nothing more will be // heard from it here. (Before a start there is no group; this is a no-op.) child.on('error', (error) => { endChild(child); reply(childEventError(error)); }); child.on('close', (code, signal) => { if (overflow !== undefined) reply(overflow); else if (code === 0 && signal === null) reply(null); else reply(childFailure(code, signal)); }); return child; }; } /** * One child process, promisified. * * A NON-ZERO EXIT IS NOT A REJECTION. Code that fails is a normal answer here — * the model wrote it, it needs to read the traceback, and turning that into a * thrown tool error would replace the one thing that teaches the next attempt * with `error: true`. What DOES reject is the runner failing to run it at all * (interpreter missing), stopping it (timeout, abort), or losing it — the * runtime reported an error for a process that had started, which is stopped * and whose outcome is unknown — because none of those is the code's answer. * * THE DEADLINE IS OURS (`lib/abort.ts`), not the runtime's `timeout`: that one * sends SIGTERM and then waits for the child to exit, so a child that ignored * SIGTERM ran on, and if it then exited 0 the call came back as a SUCCESS with * its output cut at the deadline. Here the deadline and the caller's signal * abort one controller; the child's owned PROCESS GROUP is killed with * SIGKILL (`endChild`), and the call rejects at once, whatever the child does * next. * * An abort rejects with the caller's own reason, never read as a missing * interpreter. So did an output past the buffer ceiling * (`ERR_CHILD_PROCESS_STDIO_MAXBUFFER`); that is now a result, its cut stated, * with no exit status and `signal: 'SIGKILL'`. * * Nothing of the runtime's own error text is passed on: for a failed child it * is "Command failed: <the whole command line>", which is the code itself. */ async function runChild(mod, file, args, opts, refusals) { const execFile = typeof mod.spawn === 'function' ? execFileOn(mod.spawn.bind(mod)) : mod.execFile; if (typeof execFile !== 'function') { throw new Error('localCodeRunner: `node:child_process` resolved but `execFile` is missing.'); } throwIfAborted(opts.signal); const { controller, unlink } = linkedController(opts.signal); const disarm = armDeadline(controller, opts.timeoutMs, () => refusals.timedOut(opts.timeoutMs)); return new Promise((resolve, reject) => { let child; let settled = false; const settle = () => { if (settled) return false; settled = true; disarm(); unlink(); controller.signal.removeEventListener('abort', onAbort); if (OWN_PROCESS_GROUP && typeof child?.pid === 'number') liveGroups.delete(child.pid); return true; }; // The deadline, or the caller: stop the owned group and answer NOW. The // child's own callback, whenever it comes, finds the call settled. const onAbort = () => { endChild(child); if (settle()) reject(abortErrorOf(controller.signal)); }; try { child = execFile(file, args, { cwd: opts.cwd, env: opts.env, maxBuffer: CHILD_BUFFER_BYTES, killSignal: 'SIGKILL', ...(OWN_PROCESS_GROUP && { detached: true }), }, (error, stdout, stderr) => { if (controller.signal.aborted) { onAbort(); return; } if (!settle()) return; const out = String(stdout ?? ''); const err = String(stderr ?? ''); if (!error) { resolve({ stdout: out, stderr: err, exitCode: 0 }); return; } if (error.code === BUFFER_OVERFLOW) { // On the `spawn` path the group was stopped where the overflow was // seen; this covers an injected `execFile`, which ends only the // direct child. A second stop of a stopped group is a no-op. endChild(child); resolve({ stdout: out, stderr: err, signal: 'SIGKILL', overflowed: /^stderr/.test(error.message) ? 'stderr' : 'stdout', }); return; } if (typeof error.code === 'number' || typeof error.signal === 'string') { // An EXIT — a real non-zero one, or a child the operating system // ended: the code ran and failed. That IS the result. The exit // status is the child's own, or absent — never a stand-in; the // signal says how a child without one was ended. resolve({ stdout: out, stderr: err, ...(typeof error.code === 'number' && { exitCode: error.code }), ...(typeof error.signal === 'string' && { signal: error.signal }), }); return; } // Not an exit: the runtime's `error` event, with a code or without. // What the runner owns is stopped whatever it carries (a no-op when // `execFileOn` already did), and the answer says whether anything // ran. A child the runtime gave a process id STARTED, and may have // done some or all of its work: its outcome is unknown. One it did // not never ran — ENOENT and friends, the interpreter is not here; // naming it beats a traceback the model cannot act on. endChild(child); const reason = typeof error.code === 'string' && error.code !== '' ? error.code : 'no error code'; reject(typeof child?.pid === 'number' ? refusals.failedAfterStart(reason) : refusals.notRunnable(reason)); }); } catch (err) { if (settle()) reject(err); return; } if (OWN_PROCESS_GROUP && typeof child?.pid === 'number' && !settled) trackGroup(child.pid); if (controller.signal.aborted) onAbort(); else if (!settled) controller.signal.addEventListener('abort', onAbort, { once: true }); }); } //# sourceMappingURL=local.js.map