Agent runner
Spawn a headless coding agent with a scrubbed environment, an isolated worktree, and a transcript you can audit line by line.
What it is
Agent Runner spawns a headless coding-agent CLI as a detached subprocess in a caller-supplied worktree, building its environment from scratch instead of inheriting the caller's. It streams the run's stream-json output to a durable .jsonl file and parses that transcript into a structured report of tool calls, files touched, and outcome.
What ships in the module
Browser-safe entry point
Import @caisson/agent-runner/browser inside a client bundle for the ProviderConfig model, CLAUDE_CLI_PROFILE, PASSTHROUGH_KEYS, and buildEngineEnv, the same module the runner itself imports, so you can run and show the env scrub anywhere. The main entry keeps the full node-capable surface (detached spawn, run registry, transcript parsing), and every browser-entry export is also on it.
Env built from scratch, not inherited
buildEngineEnv() never spreads process.env. It starts from an empty object, copies only the PASSTHROUGH_KEYS allowlist (PATH, LANG, LC_ALL, LC_CTYPE, TERM, TZ, TMPDIR), then adds the target provider's routing vars and the one auth key the caller passed in, nothing else reaches the child.
Provider-agnostic profile
ProviderConfig is a Zod-validated {binary, baseUrlEnv, authEnv, model, configDirEnv, modelEnv, args} shape, no vendor is hardcoded. The shipped CLAUDE_CLI_PROFILE runs the Claude Code CLI headless in stream-json mode with --strict-mcp-config, so no MCP server can be smuggled into the sandbox.
Whole-token argv templating
The {task} and {model} placeholders in a provider's args are substituted only when they are an entire argv element, never spliced into a larger string, a hostile task string can't add, split, or merge argv entries, and there's no shell in the spawn path to inject into.
Detached, isolated worktree spawn
spawn() launches the agent CLI with its own HOME and config dir inside a caller-supplied worktree, detached and unref'd so the run survives the launcher process exiting. stdout and stderr write straight to the transcript file descriptor, so there's no pipe-pumping babysitter to lose data if the caller dies.
A structured report, not a raw log
summarize() walks the stream-json events into a tool-call count, the file set an Edit/Write/MultiEdit/NotebookEdit tool touched, and the last assistant text. finalReport() adds status, timestamps, binary, and model, the shape a caller reviews before trusting the diff.
Fail-closed run registry
Every RunMeta read off disk is .strict()-validated before use, and a runId is checked against a UUID shape before it ever becomes a path segment. status() self-heals a run whose process died without a recorded outcome, marking it done or error instead of leaving it falsely running forever.
const env: Record<string, string> = {};
for (const key of PASSTHROUGH_KEYS) {
const value = parentEnv[key];
if (typeof value === "string" && value.length > 0) env[key] = value;
}
// Isolation + provider routing only — no secret beyond the one provider key.
env["HOME"] = opts.home;
env[opts.provider.baseUrlEnv] = opts.baseUrl;
env[opts.provider.authEnv] = opts.authKey;
if (opts.provider.configDirEnv !== undefined) {
env[opts.provider.configDirEnv] = opts.configDir;
}
if (opts.provider.modelEnv !== undefined) {
env[opts.provider.modelEnv] = opts.provider.model;
}
// Hygiene for CLIs that honor these conventions: no self-update, no telemetry from the sandbox.
env["DISABLE_AUTOUPDATER"] = "1";
env["DISABLE_TELEMETRY"] = "1";
env["DISABLE_ERROR_REPORTING"] = "1";
return env;- The env object starts empty, PASSTHROUGH_KEYS is the only thing ever copied from the parent process, never a blanket process.env spread.
- Only the ONE target-provider auth key the caller passed in (opts.authKey) is added, every other secret sitting in the parent shell has no path into the child.