From b9aa2327a6d71aa5c025bc138606252c03e8e771 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 23:03:44 -0700 Subject: [PATCH 1/5] chore(quest): claim quest/m0/kix/sandbox.md Quest-Claim: bdcb3fa98e2bfc53 From 6ef7f7fbf26d53afca2160d19992e7b5724d2ae9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 23:54:36 -0700 Subject: [PATCH 2/5] feat(runner): run every job's commands in Claude Code's OS sandbox Commands write only to the checkout and a private temporary directory, read no account home, runner state, or shared temp files, and reach no network host or Nix daemon. The file tools are confined by dontAsk permission rules, and the checkout's own Claude settings and MCP servers are never loaded. The runner builds the devShell with nix print-dev-env from the job's revision and Claude Code sources it inside the sandbox. Co-Authored-By: Claude Opus 5.5 --- README.md | 32 ++++++-- flake.nix | 10 ++- quest/m0/kix/README.md | 2 +- quest/m0/kix/opencode.md | 6 ++ quest/m0/kix/sandbox.md | 84 +++++++++++--------- src/runner/adapter.ts | 5 +- src/runner/claude.ts | 85 +++++++++++++++++--- src/runner/cli.ts | 43 +++++++++-- src/runner/job.ts | 28 +++++-- src/runner/prompt.ts | 51 ++++++++---- src/runner/protocol.ts | 2 +- src/runner/runner.ts | 4 +- src/runner/sandbox.ts | 71 +++++++++++++++++ src/runner/workspace.ts | 7 ++ test/runner.test.ts | 162 ++++++++++++++++++++++++++++++++++----- 15 files changed, 482 insertions(+), 110 deletions(-) create mode 100644 src/runner/sandbox.ts diff --git a/README.md b/README.md index 65f9531c..5c4aaa33 100644 --- a/README.md +++ b/README.md @@ -512,21 +512,37 @@ through `claude auth login` in its own `CLAUDE_CONFIG_DIR`, so kix stores only an opaque account id, its kind, and a label. kix.quest/runners shows each runner's presence and sets the order jobs use your accounts in. -Every job runs in its own checkout inside the project's `nix develop` shell, -with the flake's `nixConfig` honored; a project without a flake gets a bootstrap -job instead. The agent reports through kix tools: `checkpoint` summaries publish +Every job runs in its own checkout with the project's devShell, which the +runner builds with `nix print-dev-env` from the job's revision, honoring the +flake's `nixConfig`; a project without a flake gets a bootstrap job instead. +Claude Code's sandbox (bubblewrap on Linux, Seatbelt on macOS) confines every +command the agent runs, the devShell's shellHook included: commands write only +to the checkout and a private temporary directory that is also their `HOME`, +cannot read account homes, the runner's state, or shared temporary +directories, and reach no network host or Nix daemon. The agent's file tools +edit only the checkout, WebFetch is off, and the checkout's own `.claude` +settings, hooks, and MCP servers are never loaded. Claude Code itself runs +outside the sandbox with a minimal environment, so its login stays out of the +commands' reach. This is an OS sandbox, not a VM, and it narrows rather than +removes the risk of running agent commands on your machine. + +On Linux the sandbox needs `bwrap` and `socat` (the flake's `runner` package +pins both) and unprivileged user namespaces; on Ubuntu 23.10 and later, +AppArmor allows them only for programs with a profile granting `userns`. +The agent reports through kix tools: `checkpoint` summaries publish work in progress to the job's branch, `ask` pauses the job on a decision for you, and `deliver` publishes the result and its pull request. A job on an issue or pull request reads it through `context`, since the runner holds no GitHub token. kix publishes as its GitHub App, so the runner never pushes. A job that hits an account's limit pauses for you to pick another account; the runner never switches on its own. On SIGINT or SIGTERM, running work is published and its jobs move to another -runner. Until the job sandbox ships, runners take only trusted revisions, -agents run with `--permission-mode bypassPermissions`, and a job resumes with -another maintainer's answer only once you approve it in the inbox. +runner. Runners take only trusted revisions until they can build untrusted +heads' devShells in a VM, and a job resumes with another maintainer's answer +only once you approve it in the inbox. -The flake's `runner` package is a `quest` whose Claude Code is pinned by this -repository's lock (`nix run github:kixelated/quest#runner -- runner`); +The flake's `runner` package is a `quest` whose Claude Code (and on Linux, +bubblewrap and socat) is pinned by this repository's lock +(`nix run github:kixelated/quest#runner -- runner`); otherwise `claude` comes from `QUEST_RUNNER_CLAUDE` or `PATH`. State lives in `$QUEST_RUNNER_HOME`, by default `$XDG_DATA_HOME/quest/runner`. To keep a runner up, run `quest runner` as a user service, such as a systemd user unit with `Restart=on-failure`. diff --git a/flake.nix b/flake.nix index ba92332a..6efc08e9 100644 --- a/flake.nix +++ b/flake.nix @@ -29,13 +29,19 @@ config.allowUnfreePredicate = pkg: nixpkgs.lib.getName pkg == "claude-code"; }; quest = pkgs.callPackage ./nix/package.nix { }; - # `quest runner` with the agent CLIs it drives pinned by this flake's lock. + # `quest runner` with the agent CLIs it drives pinned by this flake's lock, and on + # Linux the bubblewrap and socat Claude Code's sandbox runs every job's commands in. runner = pkgs.symlinkJoin { name = "quest-runner-${quest.version}"; paths = [ quest ]; nativeBuildInputs = [ pkgs.makeWrapper ]; postBuild = '' - wrapProgram $out/bin/quest --set-default QUEST_RUNNER_CLAUDE ${pkgs.lib.getExe pkgs.claude-code} + wrapProgram $out/bin/quest --set-default QUEST_RUNNER_CLAUDE ${pkgs.lib.getExe pkgs.claude-code} ${pkgs.lib.optionalString pkgs.stdenv.isLinux "--prefix PATH : ${ + pkgs.lib.makeBinPath [ + pkgs.bubblewrap + pkgs.socat + ] + }"} ''; meta = quest.meta; }; diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md index 8da0e5e8..754065e7 100644 --- a/quest/m0/kix/README.md +++ b/quest/m0/kix/README.md @@ -114,7 +114,7 @@ fixes what it finds: - [Deploy kix.quest](/quest/m0/kix/deploy.md) - the operator registers the App and deploys the Worker with GitHub sign-in at kix.quest - [Operator setup](/quest/m0/kix/operator-setup.md) - fork namespace, pilot installations, and a verified test PR - [Per-project forks](/quest/m0/kix/forks.md) - each project publishes through its own fork, recorded by repository ID -- [Job sandbox](/quest/m0/kix/sandbox.md) - OS-sandboxed commands, with untrusted PR devShells built in a VM on Linux runners +- [Job sandbox](/quest/m0/kix/sandbox.md) - untrusted PR devShells built in a VM on Linux runners, maintainer PR heads trusted, and macOS verified - [opencode adapter](/quest/m0/kix/opencode.md) - OpenCode Go, ChatGPT plans, and API-key providers through opencode - [Merge jobs](/quest/m0/kix/merge-job.md) - a merge pick fixes conflicts, CI, and reviews on a runner, then lands the PR - [Agent activity](/quest/m0/kix/activity.md) - agent cards and map highlights built from checkpoint summaries diff --git a/quest/m0/kix/opencode.md b/quest/m0/kix/opencode.md index 6880b2a8..edefb716 100644 --- a/quest/m0/kix/opencode.md +++ b/quest/m0/kix/opencode.md @@ -18,6 +18,12 @@ jobs in the UI: questions, checkpoint summaries, and results. - Give each account its own isolated opencode config/data home. opencode dropped Claude subscription login in March 2026, so Claude subscriptions stay on the Claude Code adapter. +- Decided 2026-10-15 for the [job sandbox](/quest/m0/kix/sandbox.md): + opencode has no native sandbox, so run its commands under Anthropic's + sandbox-runtime with the runner's `Sandbox` policy (`src/runner/sandbox.ts`). + Keep the account's login outside what those commands can read: verify how + much of its home the wrapped process exposes, and deny reads of every other + account home. - Verify that structured questions, the checkpoint status tool, resume, and cancellation map onto the shared adapter interface without provider-specific behavior leaking into the service. diff --git a/quest/m0/kix/sandbox.md b/quest/m0/kix/sandbox.md index 1d59c71a..02a015c5 100644 --- a/quest/m0/kix/sandbox.md +++ b/quest/m0/kix/sandbox.md @@ -1,12 +1,13 @@ -# [L] Sandbox every runner job +# [L] Run untrusted PR heads on sandboxed runners ## Goal -Every job's commands run in an OS sandbox on the runner: writes only to the -job's worktree, network only to the provider, kix, and Nix substituters, and -no access to other account homes or the machine's secrets. Jobs on PR heads -containing non-maintainer commits route to Linux runners, which build the -PR's devShell in a per-job VM before its commands run in the sandbox. +Jobs on PR heads containing non-maintainer commits route to Linux runners, +which build the PR's devShell in a per-job VM before its commands run in the +OS sandbox every job already uses. kix classifies PR heads whose commits all +come from maintainers or kix jobs as trusted, so those run on any runner. The +sandbox is verified on macOS, and the owner's approval of another +maintainer's answer is lifted once it is. ## Plan @@ -17,44 +18,55 @@ Decided 2026-10-15, from Codex's review of kixelated/quest#121: just untrusted ones: one execution path, which also contains prompt injection from untrusted issue text. - OS sandbox, not a VM per job: bubblewrap on Linux and Seatbelt on macOS. - Use Claude Code's built-in sandbox, and Anthropic's sandbox-runtime - around opencode, which has no native sandbox. Claude Code's docs say this - does not replace a container or VM; document that residual risk. -- Keep the agent CLI's login outside what sandboxed commands can read. For - opencode under sandbox-runtime, verify how much of its account home the - wrapped process exposes, and deny reads of every other account home. + Claude Code's docs say this does not replace a container or VM; the README + documents that residual risk. - Job coordination marks every `iterate` job untrusted and routes it only to Linux runners that register with `vm: true`. Classify PR heads whose commits all come from maintainers or kix jobs as trusted here. -- Trusted revisions (the default branch, kix job branches, and maintainer - commits) build their devShell through the host Nix daemon before entering - the sandbox. Verify whether sandboxed commands such as `nix flake check` - need daemon access, and allow it only for trusted revisions. - Untrusted heads: evaluate and build the PR's own devShell in a per-job KVM microVM on a Linux runner, then bring the closure into the host store - and run the job's commands in the OS sandbox without daemon access. Verify - how to import unsigned paths without making the runner trust arbitrary - builders, and never publish them to the shared cache. + and run the job's commands in the OS sandbox. Verify how to import unsigned + paths without making the runner trust arbitrary builders, and never + publish them to the shared cache. The runner refuses untrusted jobs until + then. - macOS runners take trusted revisions only, since a Linux VM cannot build a darwin devShell. An untrusted-head job waits with "needs a Linux runner" while none is online. Cloud runners are Linux-only. -- `quest runner` (`src/runner/`) runs Claude Code with - `--permission-mode bypassPermissions` and the user's environment, minus - GitHub tokens, and refuses untrusted jobs. Decided 2026-10-22 on - [kixelated/quest#188](https://github.com/kixelated/quest/pull/188): keep - that until this sandbox ships and replaces it as the job's boundary. -- Decided 2026-10-22 on the same PR, from Codex's security review: until this - sandbox ships, a job resumes with an answer from a maintainer other than - the runner's owner (the job's requester) only after the owner approves it - in the inbox, since the agent would act on that text with the owner's - machine and credentials. kix holds the job and the runner refuses an - answer without `approvedAt`. Shipping the sandbox lifts this gate: remove - the approval step from kix, the inbox, and the runner. -- The devShell bootstrap job runs sandboxed in the runner's own pinned Nix - environment. -- Test that the boundary stops writes outside the worktree, reading - another account home or `~/.ssh`, network to unlisted hosts, and a PR - whose shellHook or flake tries to run on the host. +- Decided 2026-10-22 on + [kixelated/quest#188](https://github.com/kixelated/quest/pull/188), from + Codex's security review: a job resumes with an answer from a maintainer + other than the runner's owner (the job's requester) only after the owner + approves it in the inbox, since the agent would act on that text with the + owner's machine. kix holds the job and the runner refuses an answer + without `approvedAt`. The sandbox lifts this gate: remove the approval step + from kix, the inbox, and the runner. + +Shipped in [kixelated/quest#212](https://github.com/kixelated/quest/pull/212) +(`src/runner/sandbox.ts`), and verified live on Linux with Claude Code +2.1.291 and 2.1.305: + +- Claude Code's sandbox runs every command in `dontAsk` mode with + `--setting-sources user`, since a checkout's `.claude/settings.json` hooks + run outside the sandbox even in `-p` mode. The file tools edit and read + only the checkout, and WebFetch is off. +- The runner builds the devShell with `nix print-dev-env` from the job's + revision in the bare clone, never the edited checkout, and Claude Code + sources it through `CLAUDE_ENV_FILE` inside the sandbox, shellHook + included. +- Commands have no network and no Nix daemon: on Linux the sandbox's seccomp + filter refuses Unix sockets, and allowing them all would expose every + socket on the filesystem, while a trusted Nix user could reach the + daemon's root-level options. `nix build` and `nix flake check` run in CI + instead. + +Remaining verification: + +- On macOS, sandboxed commands cannot reach the account's Keychain item + (`security find-generic-password`), `/private/var/folders`, or another + home, and the realpath'd `/private/tmp` directory works as `TMPDIR`. +- On Ubuntu 23.10 and later, unprivileged user namespaces need an AppArmor + profile for the runner; document one and fail fast when the sandbox cannot + start, instead of failing each job. - Sources: [Claude Code sandboxing](https://code.claude.com/docs/en/sandboxing) and [sandbox-runtime](https://www.npmjs.com/package/@anthropic-ai/sandbox-runtime). diff --git a/src/runner/adapter.ts b/src/runner/adapter.ts index 1b1fb279..98dcda4f 100644 --- a/src/runner/adapter.ts +++ b/src/runner/adapter.ts @@ -4,6 +4,7 @@ // Questions, checkpoints, and delivery reach kix through those tools, not // through anything CLI-specific. import type { AccountKind } from "./home"; +import type { Sandbox } from "./sandbox"; export interface Launch { /** The job's checkout, where the agent works. */ @@ -19,8 +20,8 @@ export interface Launch { system: string; /** The kix tools' MCP endpoint and bearer token. */ tools: { url: string; token: string }; - /** A command prefix the CLI runs under, such as entering the project's Nix devShell. */ - shell: string[]; + /** The OS sandbox every command the agent runs is confined to. */ + sandbox: Sandbox; env: NodeJS.ProcessEnv; /** Receives each line of the CLI's raw output. */ output(line: string): void; diff --git a/src/runner/claude.ts b/src/runner/claude.ts index d7792d80..33e71062 100644 --- a/src/runner/claude.ts +++ b/src/runner/claude.ts @@ -3,6 +3,11 @@ // in an isolated `CLAUDE_CONFIG_DIR` per account; the runner never reads its // tokens. The kix tools attach as an HTTP MCP server, and AskUserQuestion is // disabled so every question goes through kix's `ask` tool instead. +// +// Claude Code's own sandbox confines every shell command, and permission +// rules confine its file tools, which the sandbox does not cover. Only the +// runner's settings apply: the checkout's own settings could add hooks that +// run outside the sandbox, so neither they nor its CLAUDE.md are loaded. import { execFile, spawn } from "node:child_process"; import { rmSync, writeFileSync } from "node:fs"; import { constants } from "node:os"; @@ -11,6 +16,7 @@ import { promisify } from "node:util"; import { boundedLines, LINE_LIMIT, strippedEnvironment } from "../cli/agent"; import type { Adapter, Exit, Launch, Running, SignedIn } from "./adapter"; +import type { Sandbox } from "./sandbox"; const run = promisify(execFile); @@ -29,6 +35,54 @@ export function accountEnvironment(env: NodeJS.ProcessEnv, home: string): NodeJS return { ...kept, CLAUDE_CONFIG_DIR: home }; } +/** + * The variables a job's agent keeps from the runner's environment. Its + * commands inherit them, so anything else, such as a cloud credential, stays out. + */ +const KEPT = + /^(PATH|HOME|USER|LOGNAME|SHELL|LANG|LANGUAGE|LC_\w+|TZ|TERM|(HTTPS?|ALL|NO)_PROXY|(https?|all|no)_proxy|SSL_CERT_(FILE|DIR)|NIX_SSL_CERT_FILE|NODE_EXTRA_CA_CERTS|__CF_USER_TEXT_ENCODING)$/; + +/** + * A job's environment: the account's home, the sandbox's temporary directory + * and per-command script, and little else. Not CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: + * it covers files such as `.env` and `package.json` with mount points that stay + * behind in the checkout as empty files, which kix would then publish. + */ +export function jobEnvironment(env: NodeJS.ProcessEnv, home: string, sandbox: Sandbox): NodeJS.ProcessEnv { + const kept = Object.fromEntries(Object.entries(env).filter(([name]) => KEPT.test(name))); + return { + ...kept, + CLAUDE_CONFIG_DIR: home, + TMPDIR: sandbox.tmp, + CLAUDE_ENV_FILE: sandbox.env, + }; +} + +/** + * Claude Code settings for a job. In `dontAsk` mode anything not allowed is + * refused: shell commands run only inside the sandbox, the file tools edit + * only the checkout and read only the checkout (outside it Claude Code would + * ask), and WebFetch, which would run outside the sandbox, is off. + */ +export function claudeSettings(sandbox: Sandbox): Record { + return { + sandbox: { + enabled: true, + failIfUnavailable: true, + allowUnsandboxedCommands: false, + autoAllowBashIfSandboxed: true, + filesystem: { + denyRead: sandbox.deny, + allowRead: [sandbox.cwd, sandbox.repository, sandbox.tmp], + allowWrite: [sandbox.tmp], + }, + network: { allowedDomains: sandbox.domains, strictAllowlist: true }, + }, + // A leading `//` makes a rule's path absolute. + permissions: { allow: ["Bash", `Edit(/${sandbox.cwd}/**)`, "WebSearch", "mcp__kix"] }, + }; +} + function json(line: string): Record | undefined { try { const value: unknown = JSON.parse(line); @@ -82,10 +136,9 @@ export class Claude implements Adapter { readonly name = "claude"; readonly provider = "anthropic"; - /** `binary` is an absolute path, resolved before entering a devShell that could shadow it. */ + /** `binary` is an absolute path, so a devShell's PATH cannot substitute another `claude`. */ constructor( readonly binary: string, - readonly permissionMode = "bypassPermissions", readonly env: NodeJS.ProcessEnv = process.env, ) {} @@ -133,29 +186,37 @@ export class Claude implements Adapter { // The bearer token stays out of the command line, which other users can read, in the account's private home. const config = join(launch.home, `kix-tools-${launch.session}.json`); writeFileSync(config, JSON.stringify(mcp), { mode: 0o600 }); + const settings = join(launch.home, `kix-settings-${launch.session}.json`); + writeFileSync(settings, JSON.stringify(claudeSettings(launch.sandbox)), { mode: 0o600 }); + const cleanup = () => { + rmSync(config, { force: true }); + rmSync(settings, { force: true }); + }; const argv = [ - ...launch.shell, - this.binary, "-p", "--output-format", "stream-json", "--verbose", ...(launch.resume ? ["--resume", launch.session] : ["--session-id", launch.session]), "--permission-mode", - this.permissionMode, + "dontAsk", + "--settings", + settings, + "--setting-sources", + "user", "--mcp-config", config, "--strict-mcp-config", "--append-system-prompt", launch.system, "--disallowedTools", - "AskUserQuestion", + "AskUserQuestion,WebFetch", ]; - const child = spawn(argv[0]!, argv.slice(1), { + const child = spawn(this.binary, argv, { cwd: launch.cwd, - env: { ...accountEnvironment(launch.env, launch.home) }, + env: jobEnvironment(launch.env, launch.home, launch.sandbox), stdio: ["pipe", "pipe", "pipe"], - // Its own process group, so stopping it also stops the devShell and every tool it started. + // Its own process group, so stopping it also stops every tool it started. detached: true, }); const reader = streamReader(); @@ -180,11 +241,11 @@ export class Claude implements Adapter { let killer: ReturnType | undefined; const done = new Promise((resolve) => { child.once("error", (error: NodeJS.ErrnoException) => { - rmSync(config, { force: true }); - resolve({ limited: null, failed: true, reply: `could not start ${argv[0]}: ${error.message}` }); + cleanup(); + resolve({ limited: null, failed: true, reply: `could not start ${this.binary}: ${error.message}` }); }); child.once("close", (code, name) => { - rmSync(config, { force: true }); + cleanup(); clearTimeout(killer); lines.end(); resolve(reader.exit(code ?? (name ? 128 + constants.signals[name] : 1), stderr)); diff --git a/src/runner/cli.ts b/src/runner/cli.ts index f983245d..a0af15c1 100644 --- a/src/runner/cli.ts +++ b/src/runner/cli.ts @@ -1,11 +1,12 @@ // The `quest runner` commands: sign the machine in to kix, sign agent // accounts in with their own CLIs, and run jobs in the foreground. -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { accessSync, constants, realpathSync, rmSync } from "node:fs"; import { hostname } from "node:os"; import { delimiter, isAbsolute, join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; +import { promisify } from "node:util"; import type { Adapter } from "./adapter"; import { Claude } from "./claude"; @@ -15,6 +16,8 @@ import { Runner } from "./runner"; export const DEFAULT_URL = "https://kix.quest"; +const run = promisify(execFile); + /** A runner command's mistake in its arguments or setup, reported as a usage error. */ export class RunnerInputError extends Error {} @@ -61,7 +64,7 @@ export function claudeBinary(env: NodeJS.ProcessEnv): string { } function adapterFor(io: RunnerIO): Adapter { - return new Claude(claudeBinary(io.env), "bypassPermissions", io.env); + return new Claude(claudeBinary(io.env), io.env); } function home(io: RunnerIO): RunnerHome { @@ -158,11 +161,29 @@ export function removeAccount(io: RunnerIO, id: string): number { return 0; } -/** Projects without a flake are bootstrapped in the runner's own environment, which has Nix. */ -export function devShell(job: Job): string[] { - if (job.workflow === "bootstrap") return []; - // Trusted revisions only: their flake's nixConfig, such as its substituters, is honored. - return ["nix", "develop", "--accept-flake-config", "--no-write-lock-file", "--command"]; +/** + * The job's devShell environment, which its sandboxed commands source: the + * script `nix print-dev-env` prints for the flake at the job's revision. The + * runner evaluates and builds it through the host's Nix, so it reads the + * committed revision rather than a checkout the agent may have edited, and + * runs nothing from it; the shellHook runs inside the sandbox. A project + * without a flake is bootstrapped in the runner's own environment. + */ +export async function devShell(job: Job, repository: string, env: NodeJS.ProcessEnv): Promise { + if (job.workflow === "bootstrap") return null; + const url = `git+file://${repository.split("/").map(encodeURIComponent).join("/")}?rev=${job.revision}`; + try { + // Trusted revisions only: their flake's nixConfig, such as its substituters, is honored. + const { stdout } = await run("nix", ["print-dev-env", "--accept-flake-config", "--no-write-lock-file", url], { + env, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + return stdout; + } catch (error) { + const stderr = (error as { stderr?: string }).stderr?.trim().split("\n").slice(-20).join("\n"); + throw new Error(`could not build the devShell at ${job.revision}${stderr ? `:\n${stderr}` : ""}`); + } } /** Run jobs in the foreground until interrupted. */ @@ -175,6 +196,12 @@ export async function runRunner(io: RunnerIO, options: { slots: number }): Promi const adapter = adapterFor(io); if (!which("nix", io.env)) throw new RunnerInputError("nix is not installed; every job runs in its project's devShell"); + // Claude Code's sandbox needs both on Linux; macOS has Seatbelt built in. + const missing = process.platform === "linux" ? ["bwrap", "socat"].filter((name) => !which(name, io.env)) : []; + if (missing.length) + throw new RunnerInputError( + `${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} not installed; every job runs in an OS sandbox (install bubblewrap and socat, or use the flake's runner package)`, + ); try { execFileSync("git", ["--version"], { stdio: "ignore" }); } catch { @@ -189,7 +216,7 @@ export async function runRunner(io: RunnerIO, options: { slots: number }): Promi login: current, slots: options.slots, adapter: () => adapter, - shell: devShell, + devShell: (job, repository) => devShell(job, repository, io.env), remote: (job) => `https://github.com/${job.owner}/${job.name}.git`, env: io.env, fetch: fetcher, diff --git a/src/runner/job.ts b/src/runner/job.ts index 9c5335ba..be841787 100644 --- a/src/runner/job.ts +++ b/src/runner/job.ts @@ -13,6 +13,7 @@ import { serveTools } from "./mcp"; import { OutputUploader } from "./output"; import { resumePrompt, startPrompt, SYSTEM } from "./prompt"; import { type Job, type Message, type Outcome, clip } from "./protocol"; +import { type Sandbox, closeSandbox, openSandbox } from "./sandbox"; import { Workspace } from "./workspace"; /** What an attempt needs from its runner. */ @@ -23,8 +24,12 @@ export interface Context { /** The account's isolated CLI home. */ home(account: Account): string; adapter(account: Account): Adapter; - /** The command prefix the agent runs under for this job, such as its project's devShell. */ - shell(job: Job): string[]; + /** + * The job's devShell as a script for its sandboxed commands to source, + * evaluated from the job's revision in the bare clone `repository`, or + * null to run in the runner's own environment. + */ + devShell(job: Job, repository: string): Promise; /** Where to fetch the job's repository from. */ remote(job: Job): string; env: NodeJS.ProcessEnv; @@ -130,13 +135,14 @@ export class Attempt { async #run(): Promise { const { job, ctx } = this; try { + // An untrusted head's devShell must be built in a VM, which this runner cannot do yet. if (!job.trusted) return await this.#finish( "failed", - "This runner runs only trusted revisions until the job sandbox ships; run this job on a runner that builds untrusted heads in a VM.", + "This runner runs only trusted revisions; run this job on a runner that builds untrusted heads in a VM.", ); - // Until the job sandbox ships, the agent acts on an answer with this machine's - // access only once this runner's owner gave or approved it. kix holds such jobs. + // Until the sandbox is verified on every runner platform, the agent acts on an + // answer only once this runner's owner gave or approved it. kix holds such jobs. if (job.questions.some((q) => q.answer !== null && typeof q.approvedAt !== "number")) return await this.#finish( "failed", @@ -147,14 +153,23 @@ export class Attempt { return await this.#finish("failed", `The account ${job.account} is no longer on this runner.`); this.#workspace = await Workspace.prepare(ctx.root, job, ctx.remote(job)); if (this.#ended()) return await this.#conclude(null, account); + const devShell = await ctx.devShell(job, this.#workspace.repository); + if (this.#ended()) return await this.#conclude(null, account); const resume = job.session !== null; const output = new OutputUploader( (seq, text) => ctx.request({ type: "output", job: job.id, attempt: job.attempt, seq, text }), (line) => ctx.log(`${job.id}: ${line}`), ); const tools = await serveTools(this.#tools()); + let sandbox: Sandbox | undefined; let exit: Exit; try { + sandbox = openSandbox({ + cwd: this.#workspace.dir, + repository: this.#workspace.repository, + runnerHome: ctx.root, + devShell, + }); this.#agent = ctx.adapter(account).launch({ cwd: this.#workspace.dir, home: ctx.home(account), @@ -163,7 +178,7 @@ export class Attempt { prompt: resume ? resumePrompt(job) : startPrompt(job, this.instructions, this.#workspace.dir), system: SYSTEM, tools: { url: tools.url, token: tools.token }, - shell: ctx.shell(job), + sandbox, env: ctx.env, output: (line) => output.write(line), }); @@ -173,6 +188,7 @@ export class Attempt { } finally { await tools.close(); await output.close(); + if (sandbox) closeSandbox(sandbox); } await this.#conclude(exit, account); } catch (error) { diff --git a/src/runner/prompt.ts b/src/runner/prompt.ts index 39ef7232..b75dd67a 100644 --- a/src/runner/prompt.ts +++ b/src/runner/prompt.ts @@ -1,9 +1,9 @@ // What a job's agent is told. The workflow comes from the quest guide and // skills this binary ships, given in full rather than as a slash command the // CLI might not know and would then read as plain text. The repository's own -// instructions are loaded explicitly, since not every CLI reads AGENTS.md. +// instructions are loaded explicitly, since the sandboxed CLI loads none. import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { lstatSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { GUIDE, skill } from "../cli/skills"; @@ -18,29 +18,52 @@ Report only through the kix tools: - \`deliver\`: when the work is done, with a summary and the pull request's title, body, and draft flag. kix publishes the working tree and opens or updates the pull request. End your turn after delivering. - \`context\`: on a job for an issue or pull request, read it as GitHub has it now: its text and comments, and a pull request's reviews, unresolved review threads, and checks. Use it wherever the workflow reads them with \`gh\` or \`quest status\`. -Where the workflow says to claim with \`quest start\`, or to take or release a pull request with \`quest iterate\` or \`quest release\`, skip it; kix holds the job's pull request. Where it says to push, open or update a pull request, mark it ready, or comment on it, call \`deliver\` instead. Where it says to ask or prompt the user, call \`ask\`; you can always prompt, so ignore instructions for agents that cannot. Never run \`git push\`, \`gh\`, or anything else that writes to GitHub. Commit locally if you like; kix publishes your working tree as it is.`; +Where the workflow says to claim with \`quest start\`, or to take or release a pull request with \`quest iterate\` or \`quest release\`, skip it; kix holds the job's pull request. Where it says to push, open or update a pull request, mark it ready, or comment on it, call \`deliver\` instead. Where it says to ask or prompt the user, call \`ask\`; you can always prompt, so ignore instructions for agents that cannot. Never run \`git push\`, \`gh\`, or anything else that writes to GitHub. Commit locally if you like; kix publishes your working tree as it is. + +Your shell commands run in an OS sandbox, with the project's devShell already loaded in each one. They can write only to this checkout and \`$TMPDIR\` (\`$HOME\` is inside it), reach no network host, and have no Nix daemon, so \`nix build\`, \`nix develop\`, and \`nix flake check\` fail. When a check cannot run there, say which and why in your summary rather than working around the sandbox.`; + +/** + * A root instruction file's text. The runner reads it outside the sandbox, so + * only a regular file counts: a symlink committed to a job's branch could + * otherwise put any file this user can read into the prompt. + */ +function instructionFile(dir: string, name: string): string | null { + const path = join(dir, name); + try { + return lstatSync(path).isFile() ? readFileSync(path, "utf8").trim() : null; + } catch { + return null; + } +} /** - * The repository's `AGENTS.md` files, which not every CLI reads (Claude Code - * reads CLAUDE.md on its own): the root's in full, and where the nested ones - * are, since each applies to the files under its directory. + * The repository's `AGENTS.md` and `CLAUDE.md` files, which a job's CLI does + * not load itself (Claude Code reads only the runner's settings): the root's + * in full, and where the nested ones are, since each applies to the files + * under its directory. */ function repositoryInstructions(dir: string): string { const parts: string[] = []; - const root = join(dir, "AGENTS.md"); - if (existsSync(root)) parts.push(`## AGENTS.md\n\n${readFileSync(root, "utf8").trim()}`); + const agents = instructionFile(dir, "AGENTS.md"); + if (agents !== null) parts.push(`## AGENTS.md\n\n${agents}`); + const claude = instructionFile(dir, "CLAUDE.md"); + if (claude !== null && claude !== agents) parts.push(`## CLAUDE.md\n\n${claude}`); let tracked: string[] = []; try { - tracked = execFileSync("git", ["-C", dir, "ls-files", "-z", "--", ":(glob)**/AGENTS.md"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }) + tracked = execFileSync( + "git", + ["-C", dir, "ls-files", "-z", "--", ":(glob)**/AGENTS.md", ":(glob)**/CLAUDE.md"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ) .split("\0") - .filter((path) => path && path !== "AGENTS.md"); + .filter((path) => path && path !== "AGENTS.md" && path !== "CLAUDE.md"); } catch {} if (tracked.length) parts.push( - `## Nested AGENTS.md files\n\nEach applies to the files under its directory, in addition to the root's. Read the ones for the directories you work in before changing anything there:\n\n${tracked.map((path) => `- \`${path}\``).join("\n")}`, + `## Nested instruction files\n\nEach applies to the files under its directory, in addition to the root's. Read the ones for the directories you work in before changing anything there:\n\n${tracked.map((path) => `- \`${path}\``).join("\n")}`, ); return parts.join("\n\n"); } diff --git a/src/runner/protocol.ts b/src/runner/protocol.ts index 39dfb8a0..2c352ca8 100644 --- a/src/runner/protocol.ts +++ b/src/runner/protocol.ts @@ -49,7 +49,7 @@ export interface Job { /** The commit the job builds on, such as the default branch when it was authorized. */ revision: string; branch: string; - /** Trusted revisions only, until the job sandbox ships. */ + /** Trusted revisions only, until runners build untrusted heads' devShells in a VM. */ trusted: number; account: string | null; /** The agent session to resume on this runner and account. */ diff --git a/src/runner/runner.ts b/src/runner/runner.ts index eba10e71..62e2f680 100644 --- a/src/runner/runner.ts +++ b/src/runner/runner.ts @@ -16,7 +16,7 @@ export interface RunnerOptions { /** How many jobs run at once. */ slots: number; adapter(account: Account): Adapter; - shell(job: Job): string[]; + devShell(job: Job, repository: string): Promise; remote(job: Job): string; env?: NodeJS.ProcessEnv; dial?: Dial; @@ -182,7 +182,7 @@ export class Runner { accounts: () => options.home.accounts(), home: (account) => options.home.accountHome(account.id), adapter: options.adapter, - shell: options.shell, + devShell: options.devShell, remote: options.remote, env: options.env ?? process.env, request: (message) => this.#send(message), diff --git a/src/runner/sandbox.ts b/src/runner/sandbox.ts new file mode 100644 index 00000000..908d6cb9 --- /dev/null +++ b/src/runner/sandbox.ts @@ -0,0 +1,71 @@ +// The OS sandbox every job's commands run in: bubblewrap on Linux, Seatbelt +// on macOS, through the agent CLI's own sandbox. Commands write only to the +// job's checkout and a private temporary directory, read no account home, +// runner state, or shared temporary files, reach no network host, and have +// no Nix daemon. The project's devShell is evaluated by the runner from the +// job's revision, never the checkout an agent edited, and its environment, +// shellHook included, is sourced inside the sandbox before each command. +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface Sandbox { + /** The job's checkout, which commands read and write. */ + cwd: string; + /** The bare clone the checkout's `.git` points into, which commands read. */ + repository: string; + /** The attempt's private directory: commands' TMPDIR and HOME, and nothing else writable besides the checkout. */ + tmp: string; + /** A script sourced inside the sandbox before each command: the private HOME and the devShell's environment. */ + env: string; + /** Paths commands cannot read: every account home, the runner's state, and shared temporary directories. */ + deny: string[]; + /** Hosts commands may reach. None: kix publishes, and the devShell is built before the job runs. */ + domains: string[]; +} + +/** Where other users' and this user's private files live, per platform. */ +function hidden(platform: NodeJS.Platform): string[] { + return platform === "darwin" + ? ["/Users", "/var/root", "/tmp", "/private/tmp", "/private/var/folders"] + : ["/home", "/root", "/tmp", "/var/tmp", "/run/user"]; +} + +/** + * Make an attempt's sandbox. `devShell` is the script `nix print-dev-env` + * printed for the job's revision, or null to run in the runner's own + * environment. The temporary directory sits directly under /tmp, since the + * sandbox's bridge sockets live in it and a socket path is at most 104 bytes. + */ +export function openSandbox(options: { + cwd: string; + repository: string; + runnerHome: string; + devShell: string | null; + platform?: NodeJS.Platform; + home?: string; +}): Sandbox { + const platform = options.platform ?? process.platform; + const tmp = realpathSync(mkdtempSync("/tmp/quest-job-")); + const home = join(tmp, "home"); + mkdirSync(home); + const lines = [`export HOME=${quote(home)}`]; + if (options.devShell !== null) { + const script = join(tmp, "devshell.sh"); + writeFileSync(script, options.devShell); + // The shellHook's chatter would otherwise open every command's output. + lines.push(`. ${quote(script)} >/dev/null`); + } + const env = join(tmp, "env.sh"); + writeFileSync(env, `${lines.join("\n")}\n`); + const deny = [...new Set([...hidden(platform), options.home ?? homedir(), options.runnerHome])]; + return { cwd: options.cwd, repository: options.repository, tmp, env, deny, domains: [] }; +} + +export function closeSandbox(sandbox: Sandbox): void { + rmSync(sandbox.tmp, { recursive: true, force: true }); +} + +function quote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} diff --git a/src/runner/workspace.ts b/src/runner/workspace.ts index 282ca13a..fdce8167 100644 --- a/src/runner/workspace.ts +++ b/src/runner/workspace.ts @@ -51,6 +51,11 @@ export class Workspace { this.#record = record; } + /** The bare clone the checkout belongs to, which holds the job's revision. */ + get repository(): string { + return this.#git; + } + /** The head the job's branch holds, as the next publication must name it. */ get head(): string | null { return this.#record.head; @@ -74,6 +79,8 @@ export class Workspace { } else { await run("git", ["-C", git, "remote", "set-url", "origin", remote]); } + // The devShell is evaluated from the job's revision, never from the checkout. + await fetch(git, job.revision); const record = load(file); if (record && record.head === job.head && existsSync(join(dir, ".git"))) return new Workspace(git, dir, file, record); diff --git a/test/runner.test.ts b/test/runner.test.ts index 7f7f544f..3832325c 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileS import { join } from "node:path"; import { describe, expect, test, vi } from "vitest"; -import { Claude, streamReader } from "../src/runner/claude"; +import { Claude, jobEnvironment, streamReader } from "../src/runner/claude"; import { devShell, login } from "../src/runner/cli"; import { Connection, type Socket, backoff } from "../src/runner/connection"; import { type Account, RunnerHome } from "../src/runner/home"; @@ -12,6 +12,7 @@ import { FLUSH_BYTES, OutputUploader, split } from "../src/runner/output"; import { formatAnswer, resumePrompt, startPrompt } from "../src/runner/prompt"; import type { Job } from "../src/runner/protocol"; import { Runner } from "../src/runner/runner"; +import { type Sandbox, closeSandbox, openSandbox } from "../src/runner/sandbox"; import { Workspace } from "../src/runner/workspace"; import { tempDir } from "./fixture"; @@ -296,6 +297,70 @@ describe("workspace", () => { }); }); +describe("sandbox", () => { + posix("hides every account home, the runner's state, and shared temporary files", () => { + const runnerHome = tempDir(); + const linux = openSandbox({ + cwd: "/w", + repository: "/r.git", + runnerHome, + devShell: null, + platform: "linux", + home: "/home/me", + }); + try { + expect(linux.deny).toEqual(["/home", "/root", "/tmp", "/var/tmp", "/run/user", "/home/me", runnerHome]); + expect(linux.domains).toEqual([]); + // A bootstrap job runs in the runner's own environment: only the private HOME is set. + expect(readFileSync(linux.env, "utf8")).toBe(`export HOME='${linux.tmp}/home'\n`); + // Short enough for the sandbox's bridge sockets inside it. + expect(linux.tmp.length).toBeLessThan(40); + } finally { + closeSandbox(linux); + } + expect(existsSync(linux.tmp)).toBe(false); + const mac = openSandbox({ + cwd: "/w", + repository: "/r.git", + runnerHome, + devShell: "export A=1\n", + platform: "darwin", + home: "/Users/me", + }); + try { + expect(mac.deny).toEqual(expect.arrayContaining(["/Users", "/private/tmp", "/private/var/folders"])); + expect(readFileSync(join(mac.tmp, "devshell.sh"), "utf8")).toBe("export A=1\n"); + } finally { + closeSandbox(mac); + } + }); + + test("keeps only the agent's own variables in a job's environment", () => { + const sandbox = { tmp: "/tmp/quest-job-x", env: "/tmp/quest-job-x/env.sh" } as Sandbox; + const env = jobEnvironment( + { + PATH: "/bin", + HOME: "/home/me", + LANG: "C", + HTTPS_PROXY: "p", + AWS_SECRET_ACCESS_KEY: "s", + SSH_AUTH_SOCK: "a", + }, + "/acct", + sandbox, + ); + expect(env).toEqual({ + PATH: "/bin", + HOME: "/home/me", + LANG: "C", + HTTPS_PROXY: "p", + CLAUDE_CONFIG_DIR: "/acct", + TMPDIR: "/tmp/quest-job-x", + CLAUDE_ENV_FILE: "/tmp/quest-job-x/env.sh", + }); + }); +}); + describe("Claude Code output", () => { test("reads limits from rate limit events, assistant errors, and results", () => { const event = streamReader(); @@ -404,6 +469,18 @@ describe("prompts", () => { expect(prompt).toContain("- `cloudflare/AGENTS.md`\n- `cloudflare/src/AGENTS.md`"); }); + posix("carry CLAUDE.md, which the sandboxed CLI does not load, but never through a symlink", () => { + const dir = tempDir(); + const secret = join(tempDir(), "id_ed25519"); + writeFileSync(secret, "PRIVATE KEY\n"); + symlinkSync(secret, join(dir, "AGENTS.md")); + writeFileSync(join(dir, "CLAUDE.md"), "Prefer small diffs.\n"); + const prompt = startPrompt(job("a".repeat(40)), null, dir); + expect(prompt).toContain("## CLAUDE.md\n\nPrefer small diffs."); + expect(prompt).not.toContain("PRIVATE KEY"); + expect(prompt).not.toContain("## AGENTS.md"); + }); + test("send plan and iterate jobs to the context tool", () => { const plan = startPrompt(job("a".repeat(40), { workflow: "plan", target: "issues/4" }), null, tempDir()); expect(plan).toContain("Plan issue #4 of owner/repo. Read it with the `context` tool first"); @@ -476,16 +553,31 @@ describe("login", () => { }); }); -/** A fake `claude` that runs the steps the test wrote for a new or resumed session. */ +/** + * A fake `claude` that runs the steps the test wrote for a new or resumed + * session. Its log and steps sit beside its directory, since a job's + * environment keeps none of the test's variables. + */ const FAKE_CLAUDE = ` const fs = require("node:fs"); +const { join } = require("node:path"); const argv = process.argv.slice(2); const at = (flag) => argv[argv.indexOf(flag) + 1]; const resume = argv.includes("--resume"); const session = resume ? at("--resume") : at("--session-id"); const stdin = fs.readFileSync(0, "utf8"); -const log = (value) => fs.appendFileSync(process.env.FAKE_LOG, JSON.stringify(value) + "\\n"); -log({ argv, stdin, cwd: process.cwd(), home: process.env.CLAUDE_CONFIG_DIR, gh: process.env.GH_TOKEN ?? null }); +const log = (value) => fs.appendFileSync(join(__dirname, "..", "calls.log"), JSON.stringify(value) + "\\n"); +log({ + argv, + stdin, + cwd: process.cwd(), + home: process.env.CLAUDE_CONFIG_DIR, + gh: process.env.GH_TOKEN ?? null, + secret: process.env.AWS_SECRET_ACCESS_KEY ?? null, + tmp: process.env.TMPDIR, + envFile: fs.readFileSync(process.env.CLAUDE_ENV_FILE, "utf8"), + settings: JSON.parse(fs.readFileSync(at("--settings"), "utf8")), +}); const kix = JSON.parse(fs.readFileSync(at("--mcp-config"), "utf8")).mcpServers.kix; const out = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); out({ type: "system", subtype: "init", session_id: session }); @@ -500,7 +592,7 @@ async function rpc(method, params) { } (async () => { await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "fake", version: "0" } }); - const steps = JSON.parse(fs.readFileSync(process.env.FAKE_STEPS, "utf8"))[resume ? "resume" : "start"] ?? []; + const steps = JSON.parse(fs.readFileSync(join(__dirname, "..", "steps.json"), "utf8"))[resume ? "resume" : "start"] ?? []; for (const step of steps) { if (step.write) fs.writeFileSync(step.write, step.content); if (step.remove) fs.rmSync(step.remove); @@ -517,14 +609,12 @@ async function rpc(method, params) { })(); `; -/** A fake `nix` that runs `develop ... --command ` in place. */ +/** A fake `nix` whose `print-dev-env` prints a one-line devShell. */ const FAKE_NIX = ` const fs = require("node:fs"); const argv = process.argv.slice(2); -fs.appendFileSync(process.env.FAKE_LOG, JSON.stringify({ nix: argv.slice(0, argv.indexOf("--command")) }) + "\\n"); -const [command, ...args] = argv.slice(argv.indexOf("--command") + 1); -const result = require("node:child_process").spawnSync(command, args, { stdio: "inherit" }); -process.exit(result.status ?? 1); +fs.appendFileSync(require("node:path").join(__dirname, "..", "calls.log"), JSON.stringify({ nix: argv }) + "\\n"); +process.stdout.write("export IN_DEVSHELL=1\\n"); `; interface Rig { @@ -574,9 +664,8 @@ function rig( const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, - FAKE_LOG: log, - FAKE_STEPS: stepsFile, GH_TOKEN: "secret", + AWS_SECRET_ACCESS_KEY: "secret", }; const logs: string[] = []; const source = upstream(); @@ -584,8 +673,8 @@ function rig( home, login: { url: "http://kix.test", token: "token", id: "r1", name: "laptop" }, slots: 2, - adapter: () => new Claude(join(bin, "claude"), "bypassPermissions", env), - shell: devShell, + adapter: () => new Claude(join(bin, "claude"), env), + devShell: (job, repository) => devShell(job, repository, env), remote: () => source.dir, env, dial: hub.dial, @@ -683,13 +772,50 @@ describe("quest runner", () => { ["old.txt", true], ]); const [nix, launch] = r.calls(); - expect(nix).toEqual({ nix: ["develop", "--accept-flake-config", "--no-write-lock-file"] }); + // The devShell comes from the job's revision in the bare clone, not from the checkout. + const repository = r.home.path("repos", "owner", "repo.git"); + expect(nix).toEqual({ + nix: [ + "print-dev-env", + "--accept-flake-config", + "--no-write-lock-file", + `git+file://${repository}?rev=${r.upstream.commit}`, + ], + }); expect(launch.argv).toEqual(expect.arrayContaining(["-p", "--output-format", "stream-json", "--session-id"])); - expect(launch.argv.slice(-2)).toEqual(["--disallowedTools", "AskUserQuestion"]); + expect(launch.argv).toEqual( + expect.arrayContaining([ + "--permission-mode", + "dontAsk", + "--setting-sources", + "user", + "--strict-mcp-config", + ]), + ); + expect(launch.argv.slice(-2)).toEqual(["--disallowedTools", "AskUserQuestion,WebFetch"]); expect(launch.home).toBe(r.home.accountHome("acct1")); // The tools' bearer token never appears on the command line. expect(JSON.stringify(launch.argv)).not.toContain("Bearer"); expect(launch.gh).toBeNull(); + expect(launch.secret).toBeNull(); + // Commands run sandboxed in the checkout, with a private temporary directory and the devShell sourced first. + const checkout = r.home.path("jobs", "job1"); + expect(launch.cwd).toBe(checkout); + expect(launch.tmp).toMatch(/^\/.*quest-job-/); + expect(launch.envFile).toContain(`export HOME='${launch.tmp}/home'`); + expect(launch.envFile).toContain(`. '${launch.tmp}/devshell.sh' >/dev/null`); + expect(launch.settings.sandbox).toMatchObject({ + enabled: true, + failIfUnavailable: true, + allowUnsandboxedCommands: false, + filesystem: { allowRead: [checkout, repository, launch.tmp], allowWrite: [launch.tmp] }, + network: { allowedDomains: [], strictAllowlist: true }, + }); + expect(launch.settings.sandbox.filesystem.denyRead).toEqual(expect.arrayContaining([r.home.dir])); + expect(launch.settings.permissions.allow).toEqual(["Bash", `Edit(/${checkout}/**)`, "WebSearch", "mcp__kix"]); + // The attempt's temporary directory and settings are gone once it ends. + expect(existsSync(launch.tmp)).toBe(false); + expect(existsSync(launch.argv[launch.argv.indexOf("--settings") + 1])).toBe(false); expect(launch.stdin).toContain("Always run the tests."); expect(launch.stdin).toContain("kix adds for this job: Extra rule."); // Raw output arrives as numbered chunks, and the checkout is gone once the job completed. @@ -988,8 +1114,8 @@ describe("quest runner", () => { home, login: { url: "http://kix.test", token: "token", id: "r1", name: "laptop" }, slots: 1, - adapter: () => new Claude("claude", "bypassPermissions", {}), - shell: devShell, + adapter: () => new Claude("claude", {}), + devShell: async () => null, remote: () => "", dial: hub.dial, log: () => {}, From eee816b13a9d119fee50b56db322ff7c45c52bf1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 08:33:56 -0700 Subject: [PATCH 3/5] feat(runner): let trusted revisions reach the package registries A trusted revision's sandboxed commands may now reach npm (also under yarn's name), crates.io's index and downloads, and PyPI, so its checks can install dependencies. An untrusted revision's commands still reach no network host. The system prompt names the reachable hosts. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++++- quest/m0/kix/sandbox.md | 16 +++++++++++----- src/runner/job.ts | 5 +++-- src/runner/prompt.ts | 14 +++++++++++--- src/runner/sandbox.ts | 29 ++++++++++++++++++++++++----- test/runner.test.ts | 19 +++++++++++++++---- 6 files changed, 69 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 3561a459..5f850b92 100644 --- a/README.md +++ b/README.md @@ -519,7 +519,11 @@ Claude Code's sandbox (bubblewrap on Linux, Seatbelt on macOS) confines every command the agent runs, the devShell's shellHook included: commands write only to the checkout and a private temporary directory that is also their `HOME`, cannot read account homes, the runner's state, or shared temporary -directories, and reach no network host or Nix daemon. The agent's file tools +directories, and have no Nix daemon. A trusted revision's commands reach only +the npm, crates.io, and PyPI package registries, so its checks can install +dependencies; an untrusted one's reach no network host. npm's registry also +accepts publishes, so a prompt-injected agent holding someone's npm token could +publish the checkout there. The agent's file tools edit only the checkout, WebFetch is off, and the checkout's own `.claude` settings, hooks, and MCP servers are never loaded. Claude Code itself runs outside the sandbox with a minimal environment, so its login stays out of the diff --git a/quest/m0/kix/sandbox.md b/quest/m0/kix/sandbox.md index 02a015c5..f147f026 100644 --- a/quest/m0/kix/sandbox.md +++ b/quest/m0/kix/sandbox.md @@ -53,11 +53,17 @@ Shipped in [kixelated/quest#212](https://github.com/kixelated/quest/pull/212) revision in the bare clone, never the edited checkout, and Claude Code sources it through `CLAUDE_ENV_FILE` inside the sandbox, shellHook included. -- Commands have no network and no Nix daemon: on Linux the sandbox's seccomp - filter refuses Unix sockets, and allowing them all would expose every - socket on the filesystem, while a trusted Nix user could reach the - daemon's root-level options. `nix build` and `nix flake check` run in CI - instead. +- Commands have no Nix daemon: on Linux the sandbox's seccomp filter + refuses Unix sockets, and allowing them all would expose every socket on + the filesystem, while a trusted Nix user could reach the daemon's + root-level options. `nix build` and `nix flake check` run in CI instead. +- Decided 2026-10-24 on + [kixelated/quest#212](https://github.com/kixelated/quest/pull/212): a + trusted revision's commands reach a fixed list of package registries + (npm, crates.io's index and downloads, PyPI), since most repositories + cannot run their checks without installing dependencies. An untrusted + revision's commands reach no network host. The answer-approval gate stays + until the sandbox is verified on macOS. Remaining verification: diff --git a/src/runner/job.ts b/src/runner/job.ts index be841787..5905d025 100644 --- a/src/runner/job.ts +++ b/src/runner/job.ts @@ -11,7 +11,7 @@ import type { Account } from "./home"; import type { Tool } from "./mcp"; import { serveTools } from "./mcp"; import { OutputUploader } from "./output"; -import { resumePrompt, startPrompt, SYSTEM } from "./prompt"; +import { resumePrompt, startPrompt, systemPrompt } from "./prompt"; import { type Job, type Message, type Outcome, clip } from "./protocol"; import { type Sandbox, closeSandbox, openSandbox } from "./sandbox"; import { Workspace } from "./workspace"; @@ -169,6 +169,7 @@ export class Attempt { repository: this.#workspace.repository, runnerHome: ctx.root, devShell, + trusted: Boolean(job.trusted), }); this.#agent = ctx.adapter(account).launch({ cwd: this.#workspace.dir, @@ -176,7 +177,7 @@ export class Attempt { session: this.#session, resume, prompt: resume ? resumePrompt(job) : startPrompt(job, this.instructions, this.#workspace.dir), - system: SYSTEM, + system: systemPrompt(sandbox.domains), tools: { url: tools.url, token: tools.token }, sandbox, env: ctx.env, diff --git a/src/runner/prompt.ts b/src/runner/prompt.ts index b75dd67a..6d6498d6 100644 --- a/src/runner/prompt.ts +++ b/src/runner/prompt.ts @@ -9,8 +9,15 @@ import { join } from "node:path"; import { GUIDE, skill } from "../cli/skills"; import type { Job, Prompt, Question } from "./protocol"; -/** How kix replaces the parts of a workflow that assume a local session with GitHub access. */ -export const SYSTEM = `You are running a kix job on one of the maintainer's own machines. kix (kix.quest) routes the job, talks to the maintainer, and publishes the job's work to GitHub as its App. kix already holds this job's claim. +/** + * How kix replaces the parts of a workflow that assume a local session with + * GitHub access, and what the sandbox lets commands reach. + */ +export function systemPrompt(domains: string[]): string { + const network = domains.length + ? `reach only these package registries (${domains.join(", ")})` + : "reach no network host"; + return `You are running a kix job on one of the maintainer's own machines. kix (kix.quest) routes the job, talks to the maintainer, and publishes the job's work to GitHub as its App. kix already holds this job's claim. Report only through the kix tools: - \`checkpoint\`: at each meaningful step, with a short summary the maintainer reads on their phone. kix publishes the working tree's changes to the job's branch, so another runner can continue if this one goes away. @@ -20,7 +27,8 @@ Report only through the kix tools: Where the workflow says to claim with \`quest start\`, or to take or release a pull request with \`quest iterate\` or \`quest release\`, skip it; kix holds the job's pull request. Where it says to push, open or update a pull request, mark it ready, or comment on it, call \`deliver\` instead. Where it says to ask or prompt the user, call \`ask\`; you can always prompt, so ignore instructions for agents that cannot. Never run \`git push\`, \`gh\`, or anything else that writes to GitHub. Commit locally if you like; kix publishes your working tree as it is. -Your shell commands run in an OS sandbox, with the project's devShell already loaded in each one. They can write only to this checkout and \`$TMPDIR\` (\`$HOME\` is inside it), reach no network host, and have no Nix daemon, so \`nix build\`, \`nix develop\`, and \`nix flake check\` fail. When a check cannot run there, say which and why in your summary rather than working around the sandbox.`; +Your shell commands run in an OS sandbox, with the project's devShell already loaded in each one. They can write only to this checkout and \`$TMPDIR\` (\`$HOME\` is inside it), ${network}, and have no Nix daemon, so \`nix build\`, \`nix develop\`, and \`nix flake check\` fail. When a check cannot run there, say which and why in your summary rather than working around the sandbox.`; +} /** * A root instruction file's text. The runner reads it outside the sandbox, so diff --git a/src/runner/sandbox.ts b/src/runner/sandbox.ts index 908d6cb9..c4341da7 100644 --- a/src/runner/sandbox.ts +++ b/src/runner/sandbox.ts @@ -1,8 +1,9 @@ // The OS sandbox every job's commands run in: bubblewrap on Linux, Seatbelt // on macOS, through the agent CLI's own sandbox. Commands write only to the // job's checkout and a private temporary directory, read no account home, -// runner state, or shared temporary files, reach no network host, and have -// no Nix daemon. The project's devShell is evaluated by the runner from the +// runner state, or shared temporary files, and have no Nix daemon. A trusted +// revision's commands reach only the package registries; an untrusted one's +// reach no network host. The project's devShell is evaluated by the runner from the // job's revision, never the checkout an agent edited, and its environment, // shellHook included, is sourced inside the sandbox before each command. import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; @@ -20,10 +21,26 @@ export interface Sandbox { env: string; /** Paths commands cannot read: every account home, the runner's state, and shared temporary directories. */ deny: string[]; - /** Hosts commands may reach. None: kix publishes, and the devShell is built before the job runs. */ + /** Hosts commands may reach: the package registries for a trusted revision, otherwise none. */ domains: string[]; } +/** + * The package registries a trusted revision's commands may reach, so its + * checks can install dependencies: npm (also under yarn's name), crates.io's + * index and downloads, and PyPI. Nothing else needs the network: kix + * publishes, and the runner builds the devShell before the job runs. + * crates.io's own host, which serves only its publishing API, stays out. + */ +export const REGISTRIES = [ + "registry.npmjs.org", + "registry.yarnpkg.com", + "index.crates.io", + "static.crates.io", + "pypi.org", + "files.pythonhosted.org", +]; + /** Where other users' and this user's private files live, per platform. */ function hidden(platform: NodeJS.Platform): string[] { return platform === "darwin" @@ -34,7 +51,7 @@ function hidden(platform: NodeJS.Platform): string[] { /** * Make an attempt's sandbox. `devShell` is the script `nix print-dev-env` * printed for the job's revision, or null to run in the runner's own - * environment. The temporary directory sits directly under /tmp, since the + * environment. Only a `trusted` revision's commands reach the registries. The temporary directory sits directly under /tmp, since the * sandbox's bridge sockets live in it and a socket path is at most 104 bytes. */ export function openSandbox(options: { @@ -42,6 +59,7 @@ export function openSandbox(options: { repository: string; runnerHome: string; devShell: string | null; + trusted: boolean; platform?: NodeJS.Platform; home?: string; }): Sandbox { @@ -59,7 +77,8 @@ export function openSandbox(options: { const env = join(tmp, "env.sh"); writeFileSync(env, `${lines.join("\n")}\n`); const deny = [...new Set([...hidden(platform), options.home ?? homedir(), options.runnerHome])]; - return { cwd: options.cwd, repository: options.repository, tmp, env, deny, domains: [] }; + const domains = options.trusted ? [...REGISTRIES] : []; + return { cwd: options.cwd, repository: options.repository, tmp, env, deny, domains }; } export function closeSandbox(sandbox: Sandbox): void { diff --git a/test/runner.test.ts b/test/runner.test.ts index 81116831..d185f541 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -10,10 +10,10 @@ import { Connection, type Socket, backoff } from "../src/runner/connection"; import { type Account, RunnerHome } from "../src/runner/home"; import { serveTools } from "../src/runner/mcp"; import { FLUSH_BYTES, OutputUploader, split } from "../src/runner/output"; -import { formatAnswer, resumePrompt, startPrompt } from "../src/runner/prompt"; +import { formatAnswer, resumePrompt, startPrompt, systemPrompt } from "../src/runner/prompt"; import type { Job } from "../src/runner/protocol"; import { Runner } from "../src/runner/runner"; -import { type Sandbox, closeSandbox, openSandbox } from "../src/runner/sandbox"; +import { REGISTRIES, type Sandbox, closeSandbox, openSandbox } from "../src/runner/sandbox"; import { Workspace } from "../src/runner/workspace"; import { tempDir } from "./fixture"; @@ -306,12 +306,13 @@ describe("sandbox", () => { repository: "/r.git", runnerHome, devShell: null, + trusted: true, platform: "linux", home: "/home/me", }); try { expect(linux.deny).toEqual(["/home", "/root", "/tmp", "/var/tmp", "/run/user", "/home/me", runnerHome]); - expect(linux.domains).toEqual([]); + expect(linux.domains).toEqual(REGISTRIES); // A bootstrap job runs in the runner's own environment: only the private HOME is set. expect(readFileSync(linux.env, "utf8")).toBe(`export HOME='${linux.tmp}/home'\n`); // Short enough for the sandbox's bridge sockets inside it. @@ -325,17 +326,25 @@ describe("sandbox", () => { repository: "/r.git", runnerHome, devShell: "export A=1\n", + trusted: false, platform: "darwin", home: "/Users/me", }); try { expect(mac.deny).toEqual(expect.arrayContaining(["/Users", "/private/tmp", "/private/var/folders"])); expect(readFileSync(join(mac.tmp, "devshell.sh"), "utf8")).toBe("export A=1\n"); + // An untrusted revision's commands reach no host. + expect(mac.domains).toEqual([]); } finally { closeSandbox(mac); } }); + test("tells the agent which hosts its commands reach", () => { + expect(systemPrompt(REGISTRIES)).toContain("reach only these package registries (registry.npmjs.org, "); + expect(systemPrompt([])).toContain("reach no network host"); + }); + test("keeps only the agent's own variables in a job's environment", () => { const sandbox = { tmp: "/tmp/quest-job-x", env: "/tmp/quest-job-x/env.sh" } as Sandbox; const env = jobEnvironment( @@ -856,8 +865,10 @@ describe("quest runner", () => { failIfUnavailable: true, allowUnsandboxedCommands: false, filesystem: { allowRead: [checkout, repository, launch.tmp], allowWrite: [launch.tmp] }, - network: { allowedDomains: [], strictAllowlist: true }, + // A trusted revision's commands reach the package registries and nothing else. + network: { allowedDomains: REGISTRIES, strictAllowlist: true }, }); + expect(launch.argv[launch.argv.indexOf("--append-system-prompt") + 1]).toContain("registry.npmjs.org"); expect(launch.settings.sandbox.filesystem.denyRead).toEqual(expect.arrayContaining([r.home.dir])); expect(launch.settings.permissions.allow).toEqual(["Bash", `Edit(/${checkout}/**)`, "WebSearch", "mcp__kix"]); // The attempt's temporary directory and settings are gone once it ends. From a4ec70ffa592e40e3c758a3b4dabf2cbc3fab1a0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 09:12:09 -0700 Subject: [PATCH 4/5] fix(runner): enter opencode's devShell with bash, which the script needs Co-Authored-By: Claude Opus 5.5 --- src/runner/opencode.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runner/opencode.ts b/src/runner/opencode.ts index 184cdf46..706d1f87 100644 --- a/src/runner/opencode.ts +++ b/src/runner/opencode.ts @@ -273,9 +273,9 @@ export class Opencode implements Adapter { }; // The account's XDG directories apply to opencode alone: the devShell's nix reads the user's. const xdg = Object.entries(accountDirectories(launch.home)).map(([name, dir]) => `${name}=${dir}`); - // The sandbox's script sets the private HOME and enters the devShell before opencode starts. + // The sandbox's script sets the private HOME and enters the devShell, in bash's syntax, before opencode starts. const argv = [ - "/bin/sh", + "bash", "-c", '. "$0" && exec "$@"', launch.sandbox.env, From 742f83332e9351a918f3fd109596f8c6d873ce18 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 09:33:15 -0700 Subject: [PATCH 5/5] fix(runner): abort a devShell build on cancel or shutdown; check the sandbox only for Claude Code A cancel, a moved attempt, or a runner shutdown now kills nix print-dev-env, which runs before there is an agent to stop. The bubblewrap and socat check runs only when a Claude Code account needs an adapter, including one added while the runner runs, so opencode-only Linux runners start without them. Co-Authored-By: Claude Opus 5.5 --- flake.nix | 2 +- src/runner/cli.ts | 55 ++++++++++++++++++++++++++------------- src/runner/job.ts | 26 ++++++++++++++----- src/runner/runner.ts | 2 +- test/runner.test.ts | 62 ++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 116 insertions(+), 31 deletions(-) diff --git a/flake.nix b/flake.nix index 939e8c57..f54f89f4 100644 --- a/flake.nix +++ b/flake.nix @@ -30,7 +30,7 @@ }; quest = pkgs.callPackage ./nix/package.nix { }; # `quest runner` with the agent CLIs it drives pinned by this flake's lock, and on - # Linux the bubblewrap and socat Claude Code's sandbox runs every job's commands in. + # Linux the bubblewrap and socat Claude Code's sandbox runs its jobs' commands in. runner = pkgs.symlinkJoin { name = "quest-runner-${quest.version}"; paths = [ quest ]; diff --git a/src/runner/cli.ts b/src/runner/cli.ts index 899bd579..db26e231 100644 --- a/src/runner/cli.ts +++ b/src/runner/cli.ts @@ -75,6 +75,30 @@ export function adapterFor(name: AdapterName, env: NodeJS.ProcessEnv): Adapter { return name === "opencode" ? new Opencode(binary, env) : new Claude(binary, env); } +/** + * The adapters a running runner launches jobs with, each resolved once, the + * first time an account needs it. Claude Code sandboxes its jobs' commands, + * which on Linux needs bubblewrap and socat, so they are checked then; macOS + * has Seatbelt built in. opencode runs unsandboxed, so it needs neither. + */ +export function runnerAdapters(env: NodeJS.ProcessEnv): (name: AdapterName) => Adapter { + const adapters = new Map(); + return (name) => { + let adapter = adapters.get(name); + if (adapter) return adapter; + const missing = + name === "claude" && process.platform === "linux" + ? ["bwrap", "socat"].filter((tool) => !which(tool, env)) + : []; + if (missing.length) + throw new RunnerInputError( + `${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} not installed; Claude Code jobs run in an OS sandbox (install bubblewrap and socat, or use the flake's runner package)`, + ); + adapters.set(name, (adapter = adapterFor(name, env))); + return adapter; + }; +} + function home(io: RunnerIO): RunnerHome { return new RunnerHome(defaultHome(io.env)); } @@ -188,7 +212,12 @@ export function removeAccount(io: RunnerIO, id: string): number { * runs nothing from it; the shellHook runs inside the sandbox. A project * without a flake is bootstrapped in the runner's own environment. */ -export async function devShell(job: Job, repository: string, env: NodeJS.ProcessEnv): Promise { +export async function devShell( + job: Job, + repository: string, + env: NodeJS.ProcessEnv, + signal?: AbortSignal, +): Promise { if (job.workflow === "bootstrap") return null; const url = `git+file://${repository.split("/").map(encodeURIComponent).join("/")}?rev=${job.revision}`; try { @@ -197,6 +226,7 @@ export async function devShell(job: Job, repository: string, env: NodeJS.Process env, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, + signal, }); return stdout; } catch (error) { @@ -213,18 +243,11 @@ export async function runRunner(io: RunnerIO, options: { slots: number }): Promi if (!current) throw new RunnerInputError("this machine is not signed in; run `quest runner login` first"); const accounts = state.accounts(); if (!accounts.length) throw new RunnerInputError("no accounts; add one with `quest runner account add`"); - // Only the CLIs some account uses, each resolved once, before any job enters a devShell. - const adapters = new Map(); - for (const account of accounts) - if (!adapters.has(account.adapter)) adapters.set(account.adapter, adapterFor(account.adapter, io.env)); + // Only the CLIs some account uses, resolved before any job enters a devShell. + const adapter = runnerAdapters(io.env); + for (const account of accounts) adapter(account.adapter); if (!which("nix", io.env)) throw new RunnerInputError("nix is not installed; every job runs in its project's devShell"); - // Claude Code's sandbox needs both on Linux; macOS has Seatbelt built in. - const missing = process.platform === "linux" ? ["bwrap", "socat"].filter((name) => !which(name, io.env)) : []; - if (missing.length) - throw new RunnerInputError( - `${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} not installed; every job runs in an OS sandbox (install bubblewrap and socat, or use the flake's runner package)`, - ); try { execFileSync("git", ["--version"], { stdio: "ignore" }); } catch { @@ -238,13 +261,9 @@ export async function runRunner(io: RunnerIO, options: { slots: number }): Promi home: state, login: current, slots: options.slots, - // An account added while the runner runs uses a CLI resolved then. - adapter: (account) => { - let adapter = adapters.get(account.adapter); - if (!adapter) adapters.set(account.adapter, (adapter = adapterFor(account.adapter, io.env))); - return adapter; - }, - devShell: (job, repository) => devShell(job, repository, io.env), + // An account added while the runner runs uses a CLI resolved, and a sandbox checked, then. + adapter: (account) => adapter(account.adapter), + devShell: (job, repository, signal) => devShell(job, repository, io.env, signal), remote: (job) => `https://github.com/${job.owner}/${job.name}.git`, env: io.env, fetch: fetcher, diff --git a/src/runner/job.ts b/src/runner/job.ts index a1bc8760..e94ccd19 100644 --- a/src/runner/job.ts +++ b/src/runner/job.ts @@ -27,9 +27,9 @@ export interface Context { /** * The job's devShell as a script for its sandboxed commands to source, * evaluated from the job's revision in the bare clone `repository`, or - * null to run in the runner's own environment. + * null to run in the runner's own environment. `signal` aborts its build. */ - devShell(job: Job, repository: string): Promise; + devShell(job: Job, repository: string, signal: AbortSignal): Promise; /** Where to fetch the job's repository from. */ remote(job: Job): string; env: NodeJS.ProcessEnv; @@ -90,6 +90,8 @@ export class Attempt { #session: string; #workspace: Workspace | undefined; #agent: Running | undefined; + /** Aborts the devShell build, which runs before there is an agent to stop. */ + #preparing = new AbortController(); #publishing: Promise = Promise.resolve(); #delivered: Delivery | undefined; #cancelled = false; @@ -121,18 +123,23 @@ export class Attempt { /** The maintainer asked to stop: report a cancelled result with the partial work. */ cancel(): void { this.#cancelled = true; - this.#agent?.stop(); + this.#halt(); } /** The job is not this attempt's anymore: stop without reporting. */ stop(): void { this.#stopped = true; - this.#agent?.stop(); + this.#halt(); } /** The runner is shutting down: preserve the work on the branch, then let the job move. */ abandon(): void { this.#abandoned = true; + this.#halt(); + } + + #halt(): void { + this.#preparing.abort(); this.#agent?.stop(); } @@ -164,9 +171,16 @@ export class Attempt { const account = ctx.accounts().find((a) => a.id === job.account); if (!account) return await this.#finish("failed", `The account ${job.account} is no longer on this runner.`); + // Before any checkout: an account added while the runner runs may lack its CLI or sandbox. + const adapter = ctx.adapter(account); this.#workspace = await Workspace.prepare(ctx.root, job, ctx.remote(job)); if (this.#ended()) return await this.#conclude(null, account); - const devShell = await ctx.devShell(job, this.#workspace.repository); + const devShell = await ctx + .devShell(job, this.#workspace.repository, this.#preparing.signal) + .catch((error: unknown) => { + if (this.#ended()) return null; + throw error; + }); if (this.#ended()) return await this.#conclude(null, account); const resume = job.session !== null; const output = new OutputUploader( @@ -184,7 +198,7 @@ export class Attempt { devShell, trusted: Boolean(job.trusted), }); - this.#agent = ctx.adapter(account).launch({ + this.#agent = adapter.launch({ cwd: this.#workspace.dir, home: ctx.home(account), session: this.#session, diff --git a/src/runner/runner.ts b/src/runner/runner.ts index 698fa13c..e892971c 100644 --- a/src/runner/runner.ts +++ b/src/runner/runner.ts @@ -16,7 +16,7 @@ export interface RunnerOptions { /** How many jobs run at once. */ slots: number; adapter(account: Account): Adapter; - devShell(job: Job, repository: string): Promise; + devShell(job: Job, repository: string, signal: AbortSignal): Promise; remote(job: Job): string; env?: NodeJS.ProcessEnv; dial?: Dial; diff --git a/test/runner.test.ts b/test/runner.test.ts index 5b8c33c7..10a9a0a7 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -6,7 +6,7 @@ import { describe, expect, test, vi } from "vitest"; import type { AdapterName } from "../src/runner/adapter"; import { Claude, jobEnvironment, streamReader } from "../src/runner/claude"; -import { addAccount, devShell, login, RunnerInputError } from "../src/runner/cli"; +import { addAccount, devShell, login, RunnerInputError, runnerAdapters } from "../src/runner/cli"; import { Connection, type Socket, backoff } from "../src/runner/connection"; import { type Account, RunnerHome } from "../src/runner/home"; import { serveTools } from "../src/runner/mcp"; @@ -654,6 +654,30 @@ describe("account add", () => { }); }); +describe("runner adapters", () => { + test.skipIf(process.platform !== "linux")( + "need bubblewrap and socat on Linux only once a Claude Code account needs them", + () => { + const bin = join(tempDir(), "bin"); + mkdirSync(bin); + const install = (...names: string[]) => { + for (const name of names) { + writeFileSync(join(bin, name), "#!/bin/sh\n"); + chmodSync(join(bin, name), 0o755); + } + }; + install("claude", "opencode"); + const adapter = runnerAdapters({ PATH: bin }); + // An opencode-only runner starts without them. + expect(adapter("opencode")).toBeInstanceOf(Opencode); + expect(() => adapter("claude")).toThrow(/bwrap and socat are not installed/); + // A Claude Code account added while the runner runs is checked then. + install("bwrap", "socat"); + expect(adapter("claude")).toBeInstanceOf(Claude); + }, + ); +}); + describe("login", () => { posix("connects once the owner approves the link, keeping the token local", async () => { const home = tempDir(); @@ -864,12 +888,21 @@ process.on("SIGTERM", () => process.exit(143)); server.listen(0, "127.0.0.1", () => console.log("opencode server listening on http://127.0.0.1:" + server.address().port)); `; -/** A fake `nix` whose `print-dev-env` prints a one-line devShell. */ +/** A fake `nix` whose `print-dev-env` prints a one-line devShell, or builds until killed when the steps say `nix`. */ const FAKE_NIX = ` const fs = require("node:fs"); +const { join } = require("node:path"); const argv = process.argv.slice(2); -fs.appendFileSync(require("node:path").join(__dirname, "..", "calls.log"), JSON.stringify({ nix: argv }) + "\\n"); -process.stdout.write("export IN_DEVSHELL=1\\n"); +const log = (value) => fs.appendFileSync(join(__dirname, "..", "calls.log"), JSON.stringify(value) + "\\n"); +const steps = join(__dirname, "..", "steps.json"); +if (fs.existsSync(steps) && JSON.parse(fs.readFileSync(steps, "utf8")).nix) { + process.on("SIGTERM", () => { log({ nixTerminated: true }); process.exit(143); }); + setInterval(() => {}, 1000); + log({ nix: argv }); +} else { + log({ nix: argv }); + process.stdout.write("export IN_DEVSHELL=1\\n"); +} `; interface Rig { @@ -937,7 +970,7 @@ function rig( slots: 2, adapter: () => cli === "claude" ? new Claude(join(bin, "claude"), env) : new Opencode(join(bin, "opencode"), env), - devShell: (job, repository) => devShell(job, repository, env), + devShell: (job, repository, signal) => devShell(job, repository, env, signal), remote: () => source.dir, env, dial: hub.dial, @@ -1280,6 +1313,25 @@ describe("quest runner", () => { await r.runner.shutdown(); }); + posix("a cancel or shutdown kills a devShell build before the agent starts", async () => { + const r = rig(); + r.steps({ nix: [true] }); + const building = (count: number) => + vi.waitFor(() => expect(r.calls().filter((c) => c.nix)).toHaveLength(count), { timeout: 10_000 }); + r.assign(job(r.upstream.commit)); + await building(1); + r.hub.socket.deliver({ type: "cancel", job: "job1", attempt: 1 }); + expect(await r.result()).toMatchObject({ outcome: "cancelled", summary: "The maintainer stopped this job." }); + await idle(r.runner); + + r.assign(job(r.upstream.commit, { id: "job2" })); + await building(2); + await r.runner.shutdown(); + expect(r.hub.of("result").filter((m) => m.job === "job2")).toEqual([]); + expect(r.calls().filter((c) => c.nixTerminated)).toHaveLength(2); + expect(r.calls().filter((c) => c.argv)).toHaveLength(0); + }); + posix("pauses a limited account until it resets, never switching accounts", async () => { const r = rig(); const resets = Math.floor(Date.now() / 1000) + 3600;