From 4bf728aee50005401c8aff7da564fe771b29bcc9 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 28 Aug 2026 15:41:39 +0900 Subject: [PATCH 01/12] feat(core): add a host-process sandbox backend that needs no daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@pleaseai/core/sandbox/local` implements the sandbox contract by running real processes on the host, as the sibling of the Docker backend and its opposite trade: no daemon, no image pull, no container — and no isolation. The contract's durability requirement is what shapes it. `getProcess(id)` and `logs({ replay: true })` are read after a process exits, frequently by a host process that never started it, so commands are journalled to disk rather than held as child handles. Two pieces of the Docker wrapper could not come along: macOS ships no `setsid` and its `tail` has no `--pid`. Measured replacements — `Bun.spawn({ detached: true })` already makes the wrapper a process-group leader, and a followed read polls file offsets instead of shelling out. The wrapper is also a constant: the journal directory, the timeout and the argv travel beside it as positional parameters, so no quoting step stands between a caller's `SandboxCommand` and `execve`. Two policy decisions, both stated where they are made: - the environment is allowlisted down from `process.env` (flue's `DEFAULT_LOCAL_ENV_ALLOWLIST`), so the agent's bash tool does not inherit every credential the developer is carrying; - `IS_SANDBOX` is deliberately *not* declared, the inverse of the Docker backend. There the claim is true; here it would be false, and the root check it defeats is the last thing between a bypassed permission prompt and the developer's own home directory. A sandbox id resolves to a backend-owned directory (`work/` + `journal/`) under a caller-named root, which is what makes `destroy()` safe to write at all — it only ever deletes a path this backend derived, never one a caller handed in. --- README.ko.md | 2 + README.md | 2 + docs/project-layout.md | 6 + packages/core/package.json | 5 + packages/core/src/sandbox/local/env.ts | 80 +++ packages/core/src/sandbox/local/files.ts | 129 +++++ packages/core/src/sandbox/local/index.ts | 26 + packages/core/src/sandbox/local/journal.ts | 235 ++++++++ .../core/src/sandbox/local/process-logs.ts | 181 +++++++ .../core/src/sandbox/local/process-state.ts | 162 ++++++ packages/core/src/sandbox/local/process.ts | 204 +++++++ packages/core/src/sandbox/local/provider.ts | 117 ++++ packages/core/src/sandbox/local/root.ts | 143 +++++ packages/core/src/sandbox/local/session.ts | 252 +++++++++ .../core/test/sandbox/local/backend.test.ts | 509 ++++++++++++++++++ .../core/test/sandbox/local/dir-name.test.ts | 39 ++ packages/core/test/sandbox/local/env.test.ts | 62 +++ .../sandbox/local/harness-integration.test.ts | 133 +++++ .../core/test/sandbox/local/journal.test.ts | 81 +++ packages/core/tsup.config.ts | 1 + 20 files changed, 2369 insertions(+) create mode 100644 packages/core/src/sandbox/local/env.ts create mode 100644 packages/core/src/sandbox/local/files.ts create mode 100644 packages/core/src/sandbox/local/index.ts create mode 100644 packages/core/src/sandbox/local/journal.ts create mode 100644 packages/core/src/sandbox/local/process-logs.ts create mode 100644 packages/core/src/sandbox/local/process-state.ts create mode 100644 packages/core/src/sandbox/local/process.ts create mode 100644 packages/core/src/sandbox/local/provider.ts create mode 100644 packages/core/src/sandbox/local/root.ts create mode 100644 packages/core/src/sandbox/local/session.ts create mode 100644 packages/core/test/sandbox/local/backend.test.ts create mode 100644 packages/core/test/sandbox/local/dir-name.test.ts create mode 100644 packages/core/test/sandbox/local/env.test.ts create mode 100644 packages/core/test/sandbox/local/harness-integration.test.ts create mode 100644 packages/core/test/sandbox/local/journal.test.ts diff --git a/README.ko.md b/README.ko.md index b3fd732..adc3b02 100644 --- a/README.ko.md +++ b/README.ko.md @@ -81,6 +81,7 @@ | `@pleaseai/core/sandbox` | 백엔드 계약 — 벤더 중립 타입 | | `@pleaseai/core/sandbox/harness` | 그 계약을 AI SDK `HarnessV1SandboxProvider`로 옮긴 것. 모든 백엔드를 위해 한 번만 작성한다 | | `@pleaseai/core/sandbox/docker` | 로컬 Docker 백엔드. **호스트 전용** — `docker` CLI를 실행하므로 Worker 번들에 들어가면 안 된다 | +| `@pleaseai/core/sandbox/local` | 호스트 프로세스 백엔드 — 데몬도 이미지도, **격리도 없다**. 같은 이유로 호스트 전용 | 하네스 변환을 백엔드에서 떼어 둔 덕분에 두 번째 백엔드가 그것을 다시 만들 필요가 없고, 서브패스는 호스트 전용 코드가 그것을 실행할 수 없는 타깃으로 새어 들어가지 않게 막는다. @@ -127,6 +128,7 @@ packages/ contract/ # 백엔드 계약 harness/ # 그 계약 위의 HarnessV1SandboxProvider docker/ # 로컬 Docker 백엔드 (호스트 전용) + local/ # 호스트 프로세스 백엔드 (호스트 전용, 격리 없음) scripts/ # 런타임을 가정하지 않고 측정하는 프로브 docs/ prior-art.md # eve, flue, AI SDK 하네스가 이미 하고 있는 것 diff --git a/README.md b/README.md index 8f4e91d..c0dd832 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ One layer does exist, because it was the layer the open questions could not be a | `@pleaseai/core/sandbox` | the backend contract — vendor-neutral types | | `@pleaseai/core/sandbox/harness` | the contract rendered as AI SDK `HarnessV1SandboxProvider`, written once for every backend | | `@pleaseai/core/sandbox/docker` | a local Docker backend. **Host-only** — it spawns the `docker` CLI, so it must never reach a Worker bundle | +| `@pleaseai/core/sandbox/local` | a host-process backend — no daemon, no image, **and no isolation**. Host-only for the same reason | Splitting the harness translation from the backends is what keeps a second backend from re-deriving it, and the subpaths are what keep host-only code out of a target that cannot run it. @@ -137,6 +138,7 @@ packages/ contract/ # the backend contract harness/ # HarnessV1SandboxProvider over that contract docker/ # local Docker backend (host-only) + local/ # host-process backend (host-only, unisolated) scripts/ # probes that measure the runtime rather than assume it docs/ prior-art.md # what eve, flue and the AI SDK harnesses already do diff --git a/docs/project-layout.md b/docs/project-layout.md index d798953..fbe1d5e 100644 --- a/docs/project-layout.md +++ b/docs/project-layout.md @@ -194,6 +194,12 @@ every container it creates — `containerEnv`, which the caller's own `env` can *is* a deliberate sandbox, so the claim is true rather than a way around the check, and the probe no longer sets it: the run is now also the check that the backend does. +The same constraint read from the other side is why `@pleaseai/core/sandbox/local` — a host-process +backend added since, for the cases where no daemon is reachable — deliberately does **not** declare +it. There the claim would be false, and the root check it defeats is the last thing standing between +a bypassed permission prompt and the developer's own home directory. Isolation is what makes the +declaration honest, so only the backend that provides isolation makes it. + **3. How does `host-tools/` behave across both targets?** A Worker has a per-invocation CPU limit; a Node deployment has a real filesystem and owns its own restart reconciliation. The README says this project absorbs that asymmetry rather than leaking it, and this is the first place that has to be diff --git a/packages/core/package.json b/packages/core/package.json index 7e6b9b0..d064b1d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,6 +24,11 @@ "types": "./dist/sandbox/harness/index.d.ts", "import": "./dist/sandbox/harness/index.js" }, + "./sandbox/local": { + "bun": "./src/sandbox/local/index.ts", + "types": "./dist/sandbox/local/index.d.ts", + "import": "./dist/sandbox/local/index.js" + }, "./sandbox/docker": { "bun": "./src/sandbox/docker/index.ts", "types": "./dist/sandbox/docker/index.d.ts", diff --git a/packages/core/src/sandbox/local/env.ts b/packages/core/src/sandbox/local/env.ts new file mode 100644 index 0000000..60ee668 --- /dev/null +++ b/packages/core/src/sandbox/local/env.ts @@ -0,0 +1,80 @@ +/** + * What a process in a local sandbox inherits from the host, and what it does not. + * + * The Docker backend never needed this file: a container starts from the image's own + * environment, so the only way a host variable reaches a sandboxed command is if the caller + * put it there. On the host there is no such boundary — a naive backend hands `process.env` + * straight to the agent's `bash` tool, and with it every AWS key, `GH_TOKEN` and + * `SSH_AUTH_SOCK` the developer happens to be carrying. + * + * So the default is an allowlist, borrowed from flue's `local()` + * (`packages/runtime/src/node/local-env.ts`, `DEFAULT_LOCAL_ENV_ALLOWLIST`) for the same + * reason it exists there. Anything else is opt-in through {@link LocalEnvOptions.env}. + */ +import process from 'node:process' + +/** + * Host variables a shell needs to behave like a shell. + * + * **Adding an entry here is a security decision, not a convenience one.** Nothing on this + * list should be sensitive on a typical developer machine: no tokens, no cloud credentials, + * no agent sockets. A caller that wants one of those in the sandbox says so by name. + */ +export const DEFAULT_ENV_ALLOWLIST: readonly string[] = [ + 'PATH', + 'HOME', + 'USER', + 'LOGNAME', + 'HOSTNAME', + 'SHELL', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'TZ', + 'TERM', + 'TMPDIR', + 'TMP', + 'TEMP', +] + +export interface LocalEnvOptions { + /** + * Layered on top of {@link DEFAULT_ENV_ALLOWLIST}. A key set to `undefined` drops the + * inherited default rather than adding an empty one, so a caller can subtract as well as add. + * + * Pass-through is deliberately spelled out rather than offered as a flag: + * + * ```ts + * createLocalSandbox({ root, env: { GH_TOKEN: process.env.GH_TOKEN } }) // one variable + * createLocalSandbox({ root, env: { ...process.env } }) // everything + * ``` + */ + env?: Readonly> +} + +/** + * Snapshot the host environment through the allowlist, then layer the caller's overrides. + * + * Taken once per sandbox and closed over, so every process it starts sees the same + * environment for the sandbox's lifetime. Host mutations to `process.env` after that point + * are deliberately not picked up: a sandbox whose environment changes underneath it makes + * two runs of the same command incomparable. + */ +export function resolveBaseEnv(overrides?: LocalEnvOptions['env']): Record { + const base: Record = {} + for (const key of DEFAULT_ENV_ALLOWLIST) { + const value = process.env[key] + if (value !== undefined) { + base[key] = value + } + } + for (const [key, value] of Object.entries(overrides ?? {})) { + if (value === undefined) { + delete base[key] + } + else { + base[key] = value + } + } + return base +} diff --git a/packages/core/src/sandbox/local/files.ts b/packages/core/src/sandbox/local/files.ts new file mode 100644 index 0000000..50744d4 --- /dev/null +++ b/packages/core/src/sandbox/local/files.ts @@ -0,0 +1,129 @@ +/** + * File access inside a local sandbox. + * + * Declared in the contract's Cloudflare-shaped form — positional path, encoding-selected + * overloads — rather than the AI SDK harness's options-object form, for the reason + * `SandboxFiles` documents: keeping the contract assignable from `@cloudflare/sandbox`'s own + * client is what lets the incumbent backend cost nothing, and the harness's shape is a further + * translation that `../harness/files.ts` already performs for every backend at once. + * + * **Paths are resolved, not confined.** A relative path resolves against the sandbox's working + * directory; an absolute path is used as given, and so is a relative one that climbs out with + * `..`. That is not an oversight to be fixed with a prefix check — it is the honest shape of + * this backend. `exec` runs real host processes with the caller's own uid, so a command can + * read anything the user can read no matter what this file does, and a guard here would buy a + * feeling of containment rather than containment. Isolation is what the Docker backend is for. + */ +import type { + SandboxFileContent, + SandboxFileEncoding, + SandboxFiles, + SandboxFileStream, +} from '../contract' +import { Buffer } from 'node:buffer' +import { mkdir, stat } from 'node:fs/promises' +import { dirname, isAbsolute, resolve } from 'node:path' + +/** + * Raised when a read names a path the sandbox does not have. + * + * Deliberately this backend's own identity rather than a shared one. `@pleaseai/core/sandbox` + * exports the two error classes the contract argues callers must be able to match across + * backends — an expired wait and a missing exit record — and a missing file is not yet among + * them. Promoting it is a contract change, and this backend is not the place to make one + * unilaterally; a caller that needs to match across `docker` and `local` today matches on the + * `name`, which is the same string in both. + */ +export class SandboxFileNotFoundError extends Error { + readonly path: string + + constructor(path: string) { + super(`file '${path}' does not exist in the sandbox`) + this.name = 'SandboxFileNotFoundError' + this.path = path + } +} + +function toBytes(content: string, encoding: SandboxFileEncoding | undefined): Uint8Array { + return encoding === 'base64' + ? new Uint8Array(Buffer.from(content, 'base64')) + : new TextEncoder().encode(content) +} + +async function readStream(path: string): Promise { + // Existence is checked first: a streamed read cannot report a missing file through its body, + // and a caller handed an empty stream would read it as an empty file. + if (!await isFile(path)) { + throw new SandboxFileNotFoundError(path) + } + return { content: Bun.file(path).stream() } +} + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile() + } + catch { + return false + } +} + +async function readDecoded( + path: string, + encoding: SandboxFileEncoding | undefined, +): Promise { + if (!await isFile(path)) { + throw new SandboxFileNotFoundError(path) + } + const bytes = await Bun.file(path).bytes() + + if (encoding === 'base64') { + return { content: Buffer.from(bytes).toString('base64'), encoding: 'base64' } + } + return { content: new TextDecoder().decode(bytes), encoding: 'utf-8' } +} + +async function collect(stream: ReadableStream): Promise { + return new Uint8Array(await new Response(stream).arrayBuffer()) +} + +async function write( + path: string, + content: string | ReadableStream, + options?: { encoding?: SandboxFileEncoding }, +): Promise { + const bytes = typeof content === 'string' + ? toBytes(content, options?.encoding) + : await collect(content) + + // The parent is created first, so a write to a path whose directory does not exist yet + // succeeds instead of needing the caller to sequence two calls — which is what the Docker + // backend's single `mkdir -p … && cat >` does, in the form the host offers. + await mkdir(dirname(path), { recursive: true }) + await Bun.write(path, bytes) +} + +export interface LocalFilesOptions { + /** What a relative path resolves against. */ + workDir: string +} + +/** The contract's file surface over one sandbox directory. */ +export function createLocalFiles(options: LocalFilesOptions): SandboxFiles { + const resolvePath = (path: string): string => + isAbsolute(path) ? path : resolve(options.workDir, path) + + const readFile = ((path: string, fileOptions?: { encoding?: SandboxFileEncoding | 'none' }) => ( + fileOptions?.encoding === 'none' + ? readStream(resolvePath(path)) + : readDecoded(resolvePath(path), fileOptions?.encoding) + )) as SandboxFiles['readFile'] + + return { + readFile, + writeFile: (path, content, fileOptions) => write(resolvePath(path), content, fileOptions), + mkdir: async (path, fileOptions) => { + await mkdir(resolvePath(path), { recursive: fileOptions?.recursive ?? false }) + }, + } +} diff --git a/packages/core/src/sandbox/local/index.ts b/packages/core/src/sandbox/local/index.ts new file mode 100644 index 0000000..2921233 --- /dev/null +++ b/packages/core/src/sandbox/local/index.ts @@ -0,0 +1,26 @@ +/** + * `@pleaseai/core/sandbox/local` — a host-process backend for the sandbox contract. + * + * **Host-only, and unisolated.** Everything here spawns real processes with the caller's own + * uid on the caller's own filesystem, so this subpath must never be reached from a Cloudflare + * Worker bundle — and must never be chosen for untrusted code. It is a separate entry point + * for the first reason: importing `@pleaseai/core` or `@pleaseai/core/sandbox` pulls none of + * it in. The second is a decision for whoever wires it; see `./provider.ts`. + */ + +export { DEFAULT_ENV_ALLOWLIST, resolveBaseEnv } from './env' +export type { LocalEnvOptions } from './env' + +export { SandboxFileNotFoundError } from './files' + +export { journalPaths, timeoutSeconds, wrapperArgv } from './journal' +export type { JournalMeta, JournalPaths } from './journal' + +export { createLocalSandbox } from './provider' +export type { LocalSandboxOptions } from './provider' + +export { createSandboxRoot, sandboxDirName } from './root' +export type { RootOptions, SandboxRoot } from './root' + +export { createLocalSession } from './session' +export type { LocalSessionOptions } from './session' diff --git a/packages/core/src/sandbox/local/journal.ts b/packages/core/src/sandbox/local/journal.ts new file mode 100644 index 0000000..ef662a4 --- /dev/null +++ b/packages/core/src/sandbox/local/journal.ts @@ -0,0 +1,235 @@ +/** + * The process journal — what a host process forgets when it exits, written to disk. + * + * The contract requires a process to outlive the call that started it: `getProcess(id)` and + * `logs({ replay: true })` are read *after* it exits, and `SandboxProvider.session` is called + * per use rather than held, so the reader is frequently not the process that spawned the + * command. An in-memory registry of child handles answers none of that across a restart, so + * each process is instead wrapped in a shell that redirects both streams to files and records + * its own exit status beside them — the same shape `../docker/journal.ts` arrived at, for the + * same reason. + * + * stdout and stderr stay in separate files rather than one interleaved log because + * `ProcessLogEvent` is tagged per stream. + * + * **Why a process ended is journalled separately from what it exited with.** `$?` is one + * integer and every reading of it is ambiguous — `128 + n` is a signalled child but also an + * ordinary exit code in the 129..255 range — so the wrapper writes a `signal` record and the + * watchdog writes a `timeout` marker, and the exit code is never asked to carry either fact. + * + * Two things differ from the Docker wrapper, and both are portability rather than taste: + * + * - **No `setsid`.** macOS ships no such binary. It is not needed here because the wrapper is + * spawned with `detached: true`, which makes it a process-group leader in its own right — + * which is the property `setsid` was bought for, so a group kill still reaches everything + * the command spawned. + * - **Nothing reads `/proc`.** The Docker escalation walks it to SIGKILL the group's survivors + * while sparing itself; there is no `/proc` on macOS. The escalation here writes the exit + * record *first* and then kills the whole group, itself included — which needs no process + * listing at all, and leaves a reader the same facts. + * + * A third difference is not portability: **nothing in the script is string-interpolated.** The + * journal directory, the timeout and the argv all arrive as positional parameters, so the + * script is a constant and no quoting function stands between a caller's argv and `execve`. + * The Docker backend needs `shell-quote.ts` because its wrapper travels as one `sh -c` string + * through `docker exec`; here the arguments travel beside the script instead of inside it. + */ +import type { SandboxCommand } from '../contract' +import { join } from 'node:path' + +export interface JournalPaths { + dir: string + meta: string + pid: string + stdout: string + stderr: string + exit: string + /** Number of the signal the wrapper saw reach the group, when one did. */ + signal: string + /** Touched by the watchdog when the process outran its timeout. Presence is the fact. */ + timeout: string + /** Written by a caller that gave up before the wrapper launched. Presence cancels it. */ + abandon: string +} + +/** The paths one process's journal is made of. */ +export function journalPaths(journalRoot: string, processId: string): JournalPaths { + const dir = join(journalRoot, processId) + return { + dir, + meta: join(dir, 'meta'), + pid: join(dir, 'pid'), + stdout: join(dir, 'out'), + stderr: join(dir, 'err'), + exit: join(dir, 'exit'), + signal: join(dir, 'signal'), + timeout: join(dir, 'timeout'), + abandon: join(dir, 'abandon'), + } +} + +/** What the journal records so `status()` can answer without the process. */ +export interface JournalMeta { + id: string + command: SandboxCommand + cwd?: string + startedAt: string + /** Present when the process was wrapped in a timeout watchdog. */ + timeoutMs?: number +} + +/** + * Catchable signals the wrapper survives, with the number a POSIX shell reports for them. + * + * `SIGKILL` is deliberately absent: it cannot be trapped, so a `kill -9` still produces the + * no-exit-record state — correctly, because nothing observed the process finishing. + */ +const RECORDED_SIGNALS: ReadonlyArray = [ + ['HUP', 1], + ['INT', 2], + ['QUIT', 3], + ['USR1', 10], + ['USR2', 12], + ['TERM', 15], +] + +/** + * Seconds the escalation waits after a timeout's `SIGTERM` before forcing the group down. + * + * A command is free to trap or ignore `SIGTERM`, and one that does would otherwise outlive + * the timeout it was given. + */ +const KILL_GRACE_SECONDS = '3' + +/** What the journal records for a group SIGKILLed by the escalation: the shell's `128 + 9`. */ +const SIGKILL_EXIT_CODE = '137' + +const SIGNAL_NAMES = RECORDED_SIGNALS.map(([name]) => name).join(' ') + +/** + * The wrapper, as a constant POSIX shell script. + * + * `$1` is the journal directory, `$2` the timeout in seconds (empty for none), and everything + * after them is the command — so `exec "$@"` runs the caller's argv with no quoting anywhere + * in the path from `SandboxCommand` to `execve`. + * + * **The wrapper records the catchable signals and the command does not.** A group kill reaches + * every member, wrapper included, and a wrapper that died with its child would never reach the + * line that writes the exit record — so an ordinary termination would be indistinguishable + * from a process that vanished. Trapping in the wrapper and clearing those dispositions inside + * the child (trap settings are otherwise inherited across `fork`) leaves the kill doing exactly + * what the caller asked while the exit still gets written. + * + * A trapped signal interrupts `wait`, which is why the wait is a loop: the shell returns early + * to run the handler, and the child is still there to be waited on again. + */ +export const WRAPPER_SCRIPT: string = [ + 'dir=$1', + 'budget=$2', + 'shift 2', + // The wrapper leads its own process group, courtesy of the spawn's `detached`. That is what + // makes `kill -TERM -$wrapper` reach the command's whole tree and not the caller's. + 'wrapper=$$', + 'echo "$wrapper" > "$dir/pid"', + // A caller whose start never confirmed leaves a marker rather than a kill, because there was + // no pid to kill yet. Launching anyway would strand the command with nothing able to reap it. + 'if [ -e "$dir/abandon" ] ; then exit 0 ; fi', + '', + // Born from the TERM handler rather than started up front, and deliberately: a process + // created *after* a group signal never receives it, so this survives the SIGTERM it is + // escalating from without having to ignore it. + 'escalate() {', + ` sleep ${KILL_GRACE_SECONDS}`, + // The exit record, not a pid check: a reaped pid can be reused by then, and this file is the + // journal's own answer to "is it over". + ' if [ -e "$dir/exit" ] ; then return 0 ; fi', + ' printf %s 9 > "$dir/signal"', + // Written *before* the kill, which is what lets the kill be a plain group kill. The wrapper + // dies with the group and never reaches its own `echo`, so this line is the exit record. + ` echo ${SIGKILL_EXIT_CODE} > "$dir/exit"`, + ' kill -9 "-$wrapper" 2>/dev/null', + '}', + '', + 'on_signal() {', + ' printf %s "$1" > "$dir/signal"', + ' if [ "$1" = 15 ] && [ -e "$dir/timeout" ] ; then', + ' escalate &', + ' escalator=$!', + ' fi', + '}', + '', + ...RECORDED_SIGNALS.map(([name, number]) => `trap 'on_signal ${number}' ${name}`), + '', + `{ trap - ${SIGNAL_NAMES} ; exec "$@" ; } > "$dir/out" 2> "$dir/err" &`, + 'child=$!', + '', + // Records *why* it fired before it fires, so a reader never has to infer a timeout from an + // exit code, and signals the whole group so grandchildren go with it. It then dies of its + // own signal; the wrapper's TERM handler is what carries the escalation on from here. + 'if [ -n "$budget" ] ; then', + ' ( sleep "$budget" ; : > "$dir/timeout" ; kill -TERM "-$wrapper" 2>/dev/null ) &', + ' watchdog=$!', + 'fi', + '', + 'wait "$child"', + 'status=$?', + 'while kill -0 "$child" 2>/dev/null ; do', + ' wait "$child"', + ' status=$?', + 'done', + '', + 'if [ -n "$watchdog" ] ; then kill -9 "$watchdog" 2>/dev/null || true ; fi', + // Best effort only: an escalation that outlives this finds the exit record below and returns + // without touching anything. + 'if [ -n "$escalator" ] ; then kill -9 "$escalator" 2>/dev/null || true ; fi', + 'echo "$status" > "$dir/exit"', +].join('\n') + +/** Seconds, as a literal `sleep` accepts on both GNU and BSD — sub-second budgets included. */ +export function timeoutSeconds(timeoutMs: number): string { + return (Math.max(1, timeoutMs) / 1000).toFixed(3) +} + +/** + * The argv that runs one journalled command. + * + * `sh -c