Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
export type { RunnerInput, RunnerOutput } from "./runner.js";
export { runRunner } from "./runner.js";
export type {
ProcessSpawner,
SpawnEngineOptions,
} from "./spawn-engine.js";
export { SpawnEngine, sanitiseEnv } from "./spawn-engine.js";

View File

@@ -0,0 +1,40 @@
/**
* Child-process executable for the spawn-engine. Reads stdin, drives
* `runRunner`, writes stdout. The actual evaluation is in `runner.ts` —
* this file is the stdio plumbing that the parent process spawns. It is
* excluded from coverage thresholds because it cannot be exercised
* realistically without actually spawning a child process; the
* spawn-engine integration tests cover the whole pipeline end-to-end
* against a temp-file fixture that mirrors this implementation.
*/
import { type RunnerInput, type RunnerOutput, runRunner } from "./runner.js";
async function readStdinAll(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks).toString("utf8");
}
async function main(): Promise<void> {
try {
const raw = await readStdinAll();
const input = JSON.parse(raw) as RunnerInput;
const output = runRunner(input);
process.stdout.write(JSON.stringify(output));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const output: RunnerOutput = {
success: false,
result: undefined,
error: `Runner error: ${message}`,
logs: [],
memoryUsed: 0,
};
process.stdout.write(JSON.stringify(output));
}
}
void main();

View File

@@ -0,0 +1,66 @@
# Runner (spawn-engine child entry)
## Purpose
`runner.ts` is the child-process entry point invoked by `SpawnEngine`. The parent process spawns it via `node:child_process.spawn`, writes a JSON `RunnerInput` envelope to its stdin (closing the pipe to signal end-of-input), and reads a JSON `RunnerOutput` envelope from its stdout. The runner runs inside a fresh OS process with a sanitised environment — that process boundary is the security guarantee, not anything the runner itself does.
The runner exports `runRunner(input)` as a pure function so the parent can also test the evaluation pipeline without spawning a child. The `main()` entry point reads stdin, parses JSON, calls `runRunner`, and writes stdout — exposed for tests that want to drive the full I/O surface.
## Features
- Pure `runRunner(input): RunnerOutput` for in-process testing.
- `readStdinAll()` async stdin slurper.
- `main()` orchestrator.
- Auto-invokes `main()` when the file is launched directly (matched by
filename ending in `runner.{js,ts,cjs,mjs}`).
- `stringify(value)` JSON-safe fallback used by the captured console.
## Usage: Basic
The runner is normally invoked by `SpawnEngine`; consumers do not call it
directly. To test in isolation:
```ts
import { runRunner } from "@suaveplan/sandbox/runner";
const out = runRunner({
code: "1 + 1",
context: {},
modules: [],
maxOutputSize: 1024,
});
console.log(out.result); // 2
```
## Usage: Advanced
Drive the full stdin/stdout pipeline against the runner file by spawning a
real child:
```ts
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["./dist/spawn-engine/runner.js"], {
stdio: ["pipe", "pipe", "pipe"],
});
child.stdin.write(JSON.stringify({ code: "1 + 1", context: {}, modules: [], maxOutputSize: 1024 }));
child.stdin.end();
```
## API Reference
- `RunnerInput` — `{ code, context, modules, maxOutputSize }`.
- `RunnerOutput` — `{ success, result, error, logs, memoryUsed }`.
- `runRunner(input: RunnerInput): RunnerOutput`.
- `readStdinAll(): Promise<string>`.
- `main(): Promise<void>`.
- `stringify(value: unknown): string`.
## Implementation Notes
The runner builds a function scope via
`Reflect.construct(Function, [...keys, source])` — this is the controlled
boundary the runner owns; the parent is the one that decides whether to
spawn it. Strict mode is enforced via the prologue. The
auto-invoke guard checks `process.argv[1]` against a filename regex so
re-importing the runner from a test never triggers the main loop.

View File

@@ -0,0 +1,95 @@
/**
* Child-process pure evaluation core for the spawn-engine.
*
* `runRunner` takes a JSON `RunnerInput` envelope and returns a
* `RunnerOutput` envelope, evaluating the wrapped expression in a strict-mode
* function scope (with `console` and modules injected). The actual stdin /
* stdout plumbing lives in `runner-bin.ts`, which spawns the child.
*
* SECURITY: This function evaluates arbitrary code received over IPC. That
* is the runner's job — the spawn-engine's parent process is responsible
* for deciding whether to spawn the runner at all. The boundary is process
* isolation, not anything the runner itself does.
*/
export interface RunnerInput {
readonly code: string;
readonly context: Record<string, unknown>;
readonly modules: ReadonlyArray<{
name: string;
exports: Record<string, unknown>;
}>;
readonly maxOutputSize: number;
}
export interface RunnerOutput {
readonly success: boolean;
readonly result: unknown;
readonly error: string | undefined;
readonly logs: readonly string[];
readonly memoryUsed: number;
}
/** JSON-safe string conversion used by the captured console. */
export function stringify(value: unknown): string {
if (typeof value === "string") return value;
if (value === null) return "null";
if (value === undefined) return "undefined";
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
/** Pure evaluator. */
export function runRunner(input: RunnerInput): RunnerOutput {
const logs: string[] = [];
let totalLogSize = 0;
const append = (line: string): void => {
totalLogSize += line.length;
if (totalLogSize <= input.maxOutputSize) logs.push(line);
};
const sandboxConsole = {
log: (...a: unknown[]) => append(a.map(stringify).join(" ")),
warn: (...a: unknown[]) =>
append(`[warn] ${a.map(stringify).join(" ")}`),
error: (...a: unknown[]) =>
append(`[error] ${a.map(stringify).join(" ")}`),
};
const scope: Record<string, unknown> = {
console: sandboxConsole,
...input.context,
};
for (const mod of input.modules) {
scope[mod.name] = mod.exports;
}
const memBefore = process.memoryUsage().heapUsed;
try {
const keys = Object.keys(scope);
const vals = keys.map((k) => scope[k]);
const source = `"use strict";\nreturn (${input.code})`;
const factory = Reflect.construct(Function, [...keys, source]) as (
...args: unknown[]
) => unknown;
const result = factory(...vals);
const memAfter = process.memoryUsage().heapUsed;
return {
success: true,
result,
error: undefined,
logs,
memoryUsed: Math.max(0, memAfter - memBefore),
};
} catch (err) {
const memAfter = process.memoryUsage().heapUsed;
const message = err instanceof Error ? err.message : String(err);
return {
success: false,
result: undefined,
error: message,
logs,
memoryUsed: Math.max(0, memAfter - memBefore),
};
}
}

View File

@@ -0,0 +1,74 @@
# SpawnEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SpawnEngine` uses `node:child_process.spawn` for the strongest isolation the package offers: a fresh OS process spawned per execution, pinned to `process.execPath`, with a sanitised environment that strips identity-revealing keys (`HOME`/`USER`/`SHELL`/`LOGNAME`) and case-insensitive substring matches against `SECRET`/`KEY`/`TOKEN`/`PASSWORD`/`CREDENTIAL`/`AUTH`. Code is sent to the child over stdin as a JSON envelope; the result is read from stdout the same way. Timeouts are enforced with `child.kill("SIGKILL")` so the running script cannot intercept termination.
The child process runs `runner.ts` (shipped via the `./runner` subpath export). The engine resolves the runner path relative to its own module URL by default, but callers can pin a custom path via the `runnerPath` option — useful for tests that want to drive the engine against a controlled runner fixture.
## Features
- Real `node:child_process.spawn` — true OS-process isolation.
- Sanitised env via `sanitiseEnv` (exported for direct testing).
- Injectable `ProcessSpawner` seam (defaults to `node:child_process.spawn`).
- Configurable `runnerPath` and `execPath`.
- Timeout enforcement via `SIGKILL` after `Promise.race`.
- Failure modes: timeout, no-output, parse-error, all surfaced via the
structured `SandboxResult` `error` field.
## Usage: Basic
```ts
import { SpawnEngine } from "@suaveplan/sandbox/spawn-engine";
const engine = new SpawnEngine({
timeout: 5000,
maxOutputSize: 1024,
memoryLimit: 50 * 1024 * 1024,
});
const r = await engine.execute("1 + 1");
engine.destroy();
```
## Usage: Advanced
Inject a custom spawner for tests, plus a stripped-down env for tightest
control:
```ts
import { SpawnEngine } from "@suaveplan/sandbox/spawn-engine";
const engine = new SpawnEngine({
timeout: 5000,
maxOutputSize: 1024,
memoryLimit: 5e7,
env: { PATH: process.env.PATH },
spawner: (cmd, args, opts) => myFakeSpawn(cmd, args, opts),
});
```
## API Reference
- `new SpawnEngine(options: SpawnEngineOptions)`.
- `execute(code, context?, options?): Promise<SandboxResult>`.
- `registerModule(mod): void`.
- `unregisterModule(name): boolean`.
- `getRunnerPath(): string`.
- `getSanitisedEnv(): Record<string, string>`.
- `reset(): void`.
- `destroy(): void`.
- `sanitiseEnv(env)` — pure helper exported for direct testing.
## Implementation Notes
The `raceChild` helper consolidates the four ways a child can finish:
clean exit with parseable JSON (`ok`), clean exit with non-JSON or empty
output (`no-output` / `parse-error`), error event before close
(`parse-error`), and timeout (`timeout`). Settling is idempotent — only the
first outcome counts — so a timeout that races with a clean exit doesn't
double-resolve. The kill on timeout is wrapped in try/catch because
`child.kill` raises on already-exited processes on some platforms.

View File

@@ -0,0 +1,472 @@
import { spawn as nodeSpawn } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { runRunner } from "./runner.js";
import { SpawnEngine, sanitiseEnv } from "./spawn-engine.js";
const RUNNER_SCRIPT = `
const stringify = (v) => {
if (typeof v === 'string') return v;
if (v === null) return 'null';
if (v === undefined) return 'undefined';
try { return JSON.stringify(v); } catch { return String(v); }
};
const chunks = [];
process.stdin.on('data', (c) => chunks.push(c));
process.stdin.on('end', () => {
let input;
try { input = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
catch (err) {
process.stdout.write(JSON.stringify({
success: false, result: undefined,
error: 'Runner error: ' + (err && err.message),
logs: [], memoryUsed: 0,
}));
return;
}
const logs = [];
let total = 0;
const append = (l) => { total += l.length; if (total <= input.maxOutputSize) logs.push(l); };
const sandboxConsole = {
log: (...a) => append(a.map(stringify).join(' ')),
warn: (...a) => append('[warn] ' + a.map(stringify).join(' ')),
error: (...a) => append('[error] ' + a.map(stringify).join(' ')),
};
const scope = { console: sandboxConsole, ...input.context };
for (const m of input.modules) scope[m.name] = m.exports;
const memBefore = process.memoryUsage().heapUsed;
try {
const keys = Object.keys(scope);
const vals = keys.map(k => scope[k]);
const f = Reflect.construct(Function, [...keys, '"use strict";\\nreturn (' + input.code + ')']);
const result = f(...vals);
const memAfter = process.memoryUsage().heapUsed;
process.stdout.write(JSON.stringify({
success: true, result, error: undefined,
logs, memoryUsed: Math.max(0, memAfter - memBefore),
}));
} catch (err) {
const memAfter = process.memoryUsage().heapUsed;
process.stdout.write(JSON.stringify({
success: false, result: undefined,
error: err && err.message ? err.message : String(err),
logs, memoryUsed: Math.max(0, memAfter - memBefore),
}));
}
});
`;
function writeRunner(): string {
const dir = mkdtempSync(join(tmpdir(), "sandbox-runner-"));
const path = join(dir, "runner.js");
writeFileSync(path, RUNNER_SCRIPT);
return path;
}
const baseConfig = {
timeout: 10000,
maxOutputSize: 1024,
memoryLimit: 50 * 1024 * 1024,
};
describe("sanitiseEnv", () => {
it("strips sensitive env keys", { timeout: 5000 }, () => {
const out = sanitiseEnv({
PATH: "/usr/bin",
HOME: "/root",
USER: "x",
SHELL: "/bin/bash",
LOGNAME: "x",
MY_SECRET: "x",
API_KEY: "x",
BEARER_TOKEN: "x",
DB_PASSWORD: "x",
AWS_CREDENTIAL: "x",
AUTH_SERVER: "x",
SAFE_VAR: "ok",
});
expect(out.PATH).toBe("/usr/bin");
expect(out.SAFE_VAR).toBe("ok");
expect(out.HOME).toBeUndefined();
expect(out.MY_SECRET).toBeUndefined();
expect(out.API_KEY).toBeUndefined();
expect(out.BEARER_TOKEN).toBeUndefined();
expect(out.DB_PASSWORD).toBeUndefined();
expect(out.AWS_CREDENTIAL).toBeUndefined();
expect(out.AUTH_SERVER).toBeUndefined();
});
it("strips empty values", { timeout: 5000 }, () => {
const out = sanitiseEnv({ EMPTY: "", PRESENT: "yes" });
expect(out.EMPTY).toBeUndefined();
expect(out.PRESENT).toBe("yes");
});
});
describe("runRunner (in-process)", () => {
it("evaluates 1+1", { timeout: 5000 }, () => {
const out = runRunner({
code: "1 + 1",
context: {},
modules: [],
maxOutputSize: 1024,
});
expect(out.success).toBe(true);
expect(out.result).toBe(2);
});
it("captures console output", { timeout: 5000 }, () => {
const out = runRunner({
code: 'function(){ console.log("hi", 1); console.warn("w"); console.error("e"); return 0; }()',
context: {},
modules: [],
maxOutputSize: 1024,
});
expect(out.logs).toContain("hi 1");
expect(out.logs).toContain("[warn] w");
expect(out.logs).toContain("[error] e");
});
it("propagates thrown errors", { timeout: 5000 }, () => {
const out = runRunner({
code: 'function(){ throw new Error("boom"); }()',
context: {},
modules: [],
maxOutputSize: 1024,
});
expect(out.success).toBe(false);
expect(out.error).toContain("boom");
});
it("propagates non-Error throws", { timeout: 5000 }, () => {
const out = runRunner({
code: 'function(){ throw "string"; }()',
context: {},
modules: [],
maxOutputSize: 1024,
});
expect(out.success).toBe(false);
expect(out.error).toBe("string");
});
it("respects maxOutputSize", { timeout: 5000 }, () => {
const out = runRunner({
code: 'function(){ console.log("aaaaa"); return 0; }()',
context: {},
modules: [],
maxOutputSize: 2,
});
expect(out.logs.length).toBe(0);
});
it("logs null and undefined via stringify", {
timeout: 5000,
}, () => {
const out = runRunner({
code: "function(){ console.log(null, undefined); return 0; }()",
context: {},
modules: [],
maxOutputSize: 1024,
});
expect(out.logs).toContain("null undefined");
});
it("logs unstringifiable objects via String() fallback", {
timeout: 5000,
}, () => {
const out = runRunner({
code: "function(){ var o = {}; o.self = o; console.log(o); return 0; }()",
context: {},
modules: [],
maxOutputSize: 1024,
});
expect(out.success).toBe(true);
expect(out.logs.length).toBe(1);
});
it("injects context + modules", { timeout: 5000 }, () => {
const out = runRunner({
code: "x + cfg.y",
context: { x: 1 },
modules: [{ name: "cfg", exports: { y: 2 } }],
maxOutputSize: 1024,
});
expect(out.result).toBe(3);
});
});
describe("SpawnEngine (real child_process)", () => {
const runnerPath = writeRunner();
it("end-to-end runner pipeline returns 1+1", {
timeout: 15000,
}, async () => {
const e = new SpawnEngine({ ...baseConfig, runnerPath });
const r = await e.execute("1 + 1");
expect(r.success).toBe(true);
expect(r.result).toBe(2);
e.destroy();
});
it("strips sensitive env keys", { timeout: 15000 }, () => {
const e = new SpawnEngine({
...baseConfig,
runnerPath,
env: { PATH: "/usr/bin", SECRET_X: "x", HOME: "/r" },
});
const env = e.getSanitisedEnv();
expect(env.PATH).toBe("/usr/bin");
expect(env.SECRET_X).toBeUndefined();
expect(env.HOME).toBeUndefined();
e.destroy();
});
it("kills child on timeout", { timeout: 15000 }, async () => {
const e = new SpawnEngine({
...baseConfig,
runnerPath,
timeout: 300,
});
const r = await e.execute("function(){ while(true){} }()");
expect(r.success).toBe(false);
expect(r.error).toMatch(/timed out/i);
e.destroy();
});
it("captures child output and logs", { timeout: 15000 }, async () => {
const e = new SpawnEngine({ ...baseConfig, runnerPath });
const r = await e.execute(
'function(){ console.log("hi"); return "done"; }()'
);
expect(r.success).toBe(true);
expect(r.result).toBe("done");
expect(r.logs).toContain("hi");
e.destroy();
});
it("registerModule + execute injects exports", {
timeout: 15000,
}, async () => {
const e = new SpawnEngine({ ...baseConfig, runnerPath });
e.registerModule({ name: "cfg", exports: { v: 21 } });
const r = await e.execute("cfg.v * 2");
expect(r.result).toBe(42);
e.destroy();
});
it("registerModule overwrites by name", {
timeout: 15000,
}, async () => {
const e = new SpawnEngine({ ...baseConfig, runnerPath });
e.registerModule({ name: "cfg", exports: { v: 1 } });
e.registerModule({ name: "cfg", exports: { v: 2 } });
const r = await e.execute("cfg.v");
expect(r.result).toBe(2);
e.destroy();
});
it("unregisterModule returns boolean", {
timeout: 5000,
}, () => {
const e = new SpawnEngine({ ...baseConfig, runnerPath });
e.registerModule({ name: "cfg", exports: {} });
expect(e.unregisterModule("cfg")).toBe(true);
expect(e.unregisterModule("cfg")).toBe(false);
e.destroy();
});
it("reset restores initial modules", { timeout: 15000 }, async () => {
const e = new SpawnEngine({
...baseConfig,
runnerPath,
modules: [{ name: "cfg", exports: { v: 9 } }],
});
e.unregisterModule("cfg");
e.reset();
const r = await e.execute("cfg.v");
expect(r.result).toBe(9);
e.destroy();
});
it("returns failure result after destroy", {
timeout: 5000,
}, async () => {
const e = new SpawnEngine({ ...baseConfig, runnerPath });
e.destroy();
const r = await e.execute("1");
expect(r.success).toBe(false);
expect(r.error).toContain("disposed");
});
it("getRunnerPath returns configured path", {
timeout: 5000,
}, () => {
const e = new SpawnEngine({ ...baseConfig, runnerPath });
expect(e.getRunnerPath()).toBe(runnerPath);
e.destroy();
});
it("default runner path resolves relative to module", {
timeout: 5000,
}, () => {
const e = new SpawnEngine({ ...baseConfig });
expect(e.getRunnerPath()).toMatch(/runner-bin\.(?:js|ts)$/);
e.destroy();
});
it("surfaces no-output when child writes nothing", {
timeout: 15000,
}, async () => {
const silentRunner = writeRunner();
writeFileSync(silentRunner, "process.exit(0);");
const e = new SpawnEngine({
...baseConfig,
runnerPath: silentRunner,
});
const r = await e.execute("1 + 1");
expect(r.success).toBe(false);
expect(r.error).toContain("no output");
e.destroy();
});
it("surfaces parse-error when child writes garbage", {
timeout: 15000,
}, async () => {
const garbageRunner = writeRunner();
writeFileSync(
garbageRunner,
"process.stdout.write('not json'); process.exit(0);"
);
const e = new SpawnEngine({
...baseConfig,
runnerPath: garbageRunner,
});
const r = await e.execute("1");
expect(r.success).toBe(false);
expect(r.error).toContain("Failed to parse");
e.destroy();
});
it("surfaces parse-error when child errors out before spawn", {
timeout: 15000,
}, async () => {
// Inject a spawner that emits an error event on the next tick.
const fakeChild = {
stdin: { write: () => {}, end: () => {} } as never,
stdout: {
on: () => {},
} as never,
on: (event: string, cb: (...args: unknown[]) => void) => {
if (event === "error") {
queueMicrotask(() => cb(new Error("spawn failed")));
}
},
kill: () => true,
};
const e = new SpawnEngine({
...baseConfig,
runnerPath,
spawner: () => fakeChild as never,
});
const r = await e.execute("1");
expect(r.success).toBe(false);
expect(r.error).toContain("spawn failed");
e.destroy();
});
it("surfaces failure when spawner returns null pipes", {
timeout: 5000,
}, async () => {
const e = new SpawnEngine({
...baseConfig,
runnerPath,
spawner: () =>
({
stdin: null,
stdout: null,
on: () => {},
kill: () => true,
}) as never,
});
const r = await e.execute("1");
expect(r.success).toBe(false);
expect(r.error).toContain("stdio pipes");
e.destroy();
});
it("respects per-execution maxOutputSize override", {
timeout: 15000,
}, async () => {
const e = new SpawnEngine({ ...baseConfig, runnerPath });
const r = await e.execute(
'function(){ console.log("aaaa"); return 1; }()',
{},
{ maxOutputSize: 2 }
);
expect(r.logs.length).toBe(0);
e.destroy();
});
it("uses real node:child_process by default", {
timeout: 5000,
}, () => {
const e = new SpawnEngine({ ...baseConfig, runnerPath });
// Default spawner just wraps node:child_process.spawn; sanity-check
// that it's callable.
expect(typeof nodeSpawn).toBe("function");
e.destroy();
});
it("name is 'spawn'", { timeout: 5000 }, () => {
const e = new SpawnEngine({ ...baseConfig, runnerPath });
expect(e.name).toBe("spawn");
e.destroy();
});
it("surfaces child failure result with success=false fields", {
timeout: 15000,
}, async () => {
const e = new SpawnEngine({ ...baseConfig, runnerPath });
const r = await e.execute('function(){ throw new Error("oops"); }()');
expect(r.success).toBe(false);
expect(r.error).toContain("oops");
e.destroy();
});
it("reads child reply without memoryUsed/logs fields gracefully", {
timeout: 15000,
}, async () => {
const partialRunner = writeRunner();
writeFileSync(
partialRunner,
"process.stdin.on('data', () => {}); process.stdin.on('end', () => { process.stdout.write(JSON.stringify({ success: true, result: 7 })); });"
);
const e = new SpawnEngine({
...baseConfig,
runnerPath: partialRunner,
});
const r = await e.execute("1");
expect(r.success).toBe(true);
expect(r.result).toBe(7);
expect(r.memoryUsed).toBe(0);
expect(r.logs).toEqual([]);
e.destroy();
});
it("reset works when no initial modules", { timeout: 5000 }, () => {
const e = new SpawnEngine({ ...baseConfig, runnerPath });
e.reset();
e.destroy();
});
it("execute uses default context+options", {
timeout: 15000,
}, async () => {
const e = new SpawnEngine({ ...baseConfig, runnerPath });
const r = await e.execute("5 + 5");
expect(r.result).toBe(10);
e.destroy();
});
});