diff --git a/README.md b/README.md index e1a40747..3826a83b 100644 --- a/README.md +++ b/README.md @@ -522,9 +522,30 @@ default. Use Claude Code for Claude subscriptions, since opencode cannot sign in to them. 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. On Claude +Code accounts, 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 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 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. opencode has no sandbox yet: on opencode +accounts the agent's commands run unconfined on your machine, in the devShell +with a private `HOME`. + +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, listing any suggested follow-ups, which the inbox offers you to plan. A job on an issue or @@ -532,13 +553,14 @@ pull request reads it through `context`, since the runner holds no GitHub token; a new plan reads your idea and pasted context there. 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 every tool allowed (Claude Code's `bypassPermissions`), and a -job resumes with another maintainer's answer only once you approve it in the -inbox. opencode runs as `opencode serve` on localhost behind a password made for each turn. - -The flake's `runner` package is a `quest` whose Claude Code and opencode are -pinned by this repository's lock (`nix run github:kixelated/quest#runner -- runner`); +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. opencode runs as `opencode serve` on +localhost behind a password made for each turn. + +The flake's `runner` package is a `quest` whose Claude Code and opencode (and on +Linux, bubblewrap and socat) are pinned by this repository's lock +(`nix run github:kixelated/quest#runner -- runner`); otherwise `claude` and `opencode` come from `QUEST_RUNNER_CLAUDE` and `QUEST_RUNNER_OPENCODE`, or `PATH`. State lives in `$QUEST_RUNNER_HOME`, by default `$XDG_DATA_HOME/quest/runner`; one `quest runner` runs in a home at a time. To keep a runner up, run `quest runner` as a user diff --git a/flake.nix b/flake.nix index f9353698..f54f89f4 100644 --- a/flake.nix +++ b/flake.nix @@ -29,7 +29,8 @@ 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 its jobs' commands in. runner = pkgs.symlinkJoin { name = "quest-runner-${quest.version}"; paths = [ quest ]; @@ -37,7 +38,12 @@ postBuild = '' wrapProgram $out/bin/quest \ --set-default QUEST_RUNNER_CLAUDE ${pkgs.lib.getExe pkgs.claude-code} \ - --set-default QUEST_RUNNER_OPENCODE ${pkgs.lib.getExe pkgs.opencode} + --set-default QUEST_RUNNER_OPENCODE ${pkgs.lib.getExe pkgs.opencode} ${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 33c15d08..637e83eb 100644 --- a/quest/m0/kix/README.md +++ b/quest/m0/kix/README.md @@ -120,7 +120,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, opencode sandboxed, and macOS verified - [Merge jobs](/quest/m0/kix/merge-job.md) - a merge pick fixes conflicts, CI, and reviews on a runner, then lands the PR - [Web execution](/quest/m0/kix/execution.md) - spawn and iterate through the UI, ending in contribution PRs - [Onboarding guide](/quest/m0/kix/guide.md) - a complete maintainer path, from runner setup through a reviewed PR diff --git a/quest/m0/kix/sandbox.md b/quest/m0/kix/sandbox.md index 1d59c71a..1ed340e6 100644 --- a/quest/m0/kix/sandbox.md +++ b/quest/m0/kix/sandbox.md @@ -1,12 +1,14 @@ -# [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 Claude Code job already uses. opencode jobs' commands run in +that sandbox too. 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 +19,72 @@ 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 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 work: + +- opencode has no native sandbox, and its jobs' commands still run unconfined + on the host (in the revision's devShell, with the attempt's private HOME). + Decided 2026-10-15: run `opencode serve` under Anthropic's sandbox-runtime + with the runner's `Sandbox` policy (`src/runner/sandbox.ts`). The runner + reaches the server on localhost, and opencode needs its providers' hosts, + so check both under sandbox-runtime's network isolation. Keep the account's + login out of what the commands can read: verify how much of its XDG home the + wrapped process exposes, and deny reads of every other account home. + Commands also inherit the per-turn `OPENCODE_SERVER_PASSWORD`, and the kix + tools' bearer token in `OPENCODE_CONFIG_CONTENT`; keep both out of reach. + +- 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 00be2559..ceca968b 100644 --- a/src/runner/adapter.ts +++ b/src/runner/adapter.ts @@ -5,6 +5,7 @@ // through anything CLI-specific. import { strippedEnvironment } from "../cli/agent"; import type { AccountKind } from "./home"; +import type { Sandbox } from "./sandbox"; /** The agent CLIs a runner drives. */ export type AdapterName = "claude" | "opencode"; @@ -48,8 +49,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 36c8a6e6..ed4e46c0 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"; @@ -20,6 +25,7 @@ import { signalGroup, STOP_GRACE_MS, } from "./adapter"; +import type { Sandbox } from "./sandbox"; const run = promisify(execFile); @@ -28,6 +34,54 @@ export function accountEnvironment(env: NodeJS.ProcessEnv, home: string): NodeJS return { ...baseEnvironment(env), 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); @@ -80,10 +134,9 @@ export function streamReader() { export class Claude implements Adapter { readonly name = "claude"; - /** `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, ) {} @@ -131,29 +184,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(); @@ -174,11 +235,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 04693b3a..db26e231 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, AdapterName } from "./adapter"; import { Claude } from "./claude"; @@ -16,6 +17,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 {} @@ -69,7 +72,31 @@ export function cliBinary(name: AdapterName, env: NodeJS.ProcessEnv): string { export function adapterFor(name: AdapterName, env: NodeJS.ProcessEnv): Adapter { const binary = cliBinary(name, env); - return name === "opencode" ? new Opencode(binary, env) : new Claude(binary, "bypassPermissions", env); + 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 { @@ -177,11 +204,35 @@ 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, + signal?: AbortSignal, +): 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, + signal, + }); + 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. */ @@ -192,10 +243,9 @@ 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"); try { @@ -211,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; - }, - shell: devShell, + // 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 cc36000f..e94ccd19 100644 --- a/src/runner/job.ts +++ b/src/runner/job.ts @@ -11,8 +11,9 @@ 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"; /** 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. `signal` aborts its build. + */ + devShell(job: Job, repository: string, signal: AbortSignal): Promise; /** Where to fetch the job's repository from. */ remote(job: Job): string; env: NodeJS.ProcessEnv; @@ -85,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; @@ -116,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(); } @@ -143,13 +155,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", @@ -158,25 +171,42 @@ 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, 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( (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 { - this.#agent = ctx.adapter(account).launch({ + sandbox = openSandbox({ + cwd: this.#workspace.dir, + repository: this.#workspace.repository, + runnerHome: ctx.root, + devShell, + trusted: Boolean(job.trusted), + }); + this.#agent = adapter.launch({ cwd: this.#workspace.dir, home: ctx.home(account), 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 }, - shell: ctx.shell(job), + sandbox, env: ctx.env, output: (line) => output.write(line), }); @@ -186,6 +216,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/opencode.ts b/src/runner/opencode.ts index 1017c3a2..706d1f87 100644 --- a/src/runner/opencode.ts +++ b/src/runner/opencode.ts @@ -6,6 +6,11 @@ // home signed in to, never the credentials. The kix tools attach as a remote // MCP server, and opencode's question tool is denied so every question goes // through kix's `ask` instead. +// +// opencode has no sandbox of its own, and the runner does not yet wrap it in +// one: its commands run on the host, in the devShell the runner built from the +// job's revision and with the attempt's private HOME, but not confined to the +// checkout. import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; @@ -268,8 +273,12 @@ 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, in bash's syntax, before opencode starts. const argv = [ - ...launch.shell, + "bash", + "-c", + '. "$0" && exec "$@"', + launch.sandbox.env, "/usr/bin/env", ...xdg, this.binary, @@ -282,6 +291,7 @@ export class Opencode implements Adapter { cwd: launch.cwd, env: { ...environment(launch.env), + TMPDIR: launch.sandbox.tmp, OPENCODE_SERVER_PASSWORD: password, OPENCODE_CONFIG_CONTENT: JSON.stringify(config), }, diff --git a/src/runner/prompt.ts b/src/runner/prompt.ts index a0126434..ed69fa57 100644 --- a/src/runner/prompt.ts +++ b/src/runner/prompt.ts @@ -1,16 +1,23 @@ // 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"; 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. @@ -18,29 +25,53 @@ 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. List the follow-ups you suggest as \`followups\` too, which kix offers the maintainer to plan. 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\`. On a new plan, it reads the maintainer's idea and any context pasted with it. -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), ${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.`; +} /** - * 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. + * 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` 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 5059848f..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; - shell(job: Job): string[]; + devShell(job: Job, repository: string, signal: AbortSignal): Promise; remote(job: Job): string; env?: NodeJS.ProcessEnv; dial?: Dial; @@ -186,7 +186,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..c4341da7 --- /dev/null +++ b/src/runner/sandbox.ts @@ -0,0 +1,90 @@ +// 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, 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"; +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: 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" + ? ["/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. 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: { + cwd: string; + repository: string; + runnerHome: string; + devShell: string | null; + trusted: boolean; + 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])]; + const domains = options.trusted ? [...REGISTRIES] : []; + 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 29296b78..10a9a0a7 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -5,8 +5,8 @@ import { join, resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; import type { AdapterName } from "../src/runner/adapter"; -import { Claude, streamReader } from "../src/runner/claude"; -import { addAccount, devShell, login, RunnerInputError } from "../src/runner/cli"; +import { Claude, jobEnvironment, streamReader } from "../src/runner/claude"; +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"; @@ -20,9 +20,10 @@ import { turnExit, } from "../src/runner/opencode"; 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 { REGISTRIES, type Sandbox, closeSandbox, openSandbox } from "../src/runner/sandbox"; import { Workspace } from "../src/runner/workspace"; import { tempDir } from "./fixture"; @@ -307,6 +308,79 @@ 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, + 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(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. + 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", + 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( + { + 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(); @@ -489,6 +563,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"); @@ -568,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(); @@ -646,16 +756,31 @@ describe("runner home", () => { }); }); -/** 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 }); @@ -670,7 +795,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); @@ -694,9 +819,11 @@ async function rpc(method, params) { */ const FAKE_OPENCODE = ` const fs = require("node:fs"); +const { join } = require("node:path"); const http = require("node:http"); const argv = process.argv.slice(2); -const log = (value) => fs.appendFileSync(process.env.FAKE_LOG, JSON.stringify(value) + "\\n"); +const logFile = join(__dirname, "..", "calls.log"); +const log = (value) => fs.appendFileSync(logFile, JSON.stringify(value) + "\\n"); const config = JSON.parse(process.env.OPENCODE_CONFIG_CONTENT); const auth = "Basic " + Buffer.from("opencode:" + process.env.OPENCODE_SERVER_PASSWORD).toString("base64"); const kix = config.mcp.kix; @@ -720,11 +847,11 @@ const body = (request) => new Promise((resolve) => { }); const json = (response, value) => response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(value)); async function prompt(session, input) { - const steps = JSON.parse(fs.readFileSync(process.env.FAKE_STEPS, "utf8")); - const resume = fs.existsSync(process.env.FAKE_LOG) && fs.readFileSync(process.env.FAKE_LOG, "utf8").includes('"prompted":"' + session + '"'); + const steps = JSON.parse(fs.readFileSync(join(__dirname, "..", "steps.json"), "utf8")); + const resume = fs.existsSync(logFile) && fs.readFileSync(logFile, "utf8").includes('"prompted":"' + session + '"'); log({ argv, cwd: process.cwd(), stdin: input.parts[0].text, system: input.system, session, prompted: session, - xdg: process.env.XDG_DATA_HOME, gh: process.env.GH_TOKEN ?? null, config, + xdg: process.env.XDG_DATA_HOME, devShell: process.env.IN_DEVSHELL ?? null, gh: process.env.GH_TOKEN ?? null, config, }); await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "fake", version: "0" } }); for (const step of steps[resume ? "resume" : "start"] ?? []) { @@ -761,14 +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` that runs `develop ... --command ` in place. */ +/** 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(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); +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 { @@ -825,9 +959,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(); @@ -836,10 +969,8 @@ function rig( login: { url: "http://kix.test", token: "token", id: "r1", name: "laptop" }, slots: 2, adapter: () => - cli === "claude" - ? new Claude(join(bin, "claude"), "bypassPermissions", env) - : new Opencode(join(bin, "opencode"), env), - shell: devShell, + cli === "claude" ? new Claude(join(bin, "claude"), env) : new Opencode(join(bin, "opencode"), env), + devShell: (job, repository, signal) => devShell(job, repository, env, signal), remote: () => source.dir, env, dial: hub.dial, @@ -937,13 +1068,52 @@ 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] }, + // 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. + 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. @@ -1041,10 +1211,11 @@ describe("quest runner", () => { await idle(r.runner); expect(r.hub.of("checkpoint")).toMatchObject([{ summary: "Drafted", session: question.session }]); const [nix, first] = r.calls(); - expect(nix).toEqual({ nix: ["develop", "--accept-flake-config", "--no-write-lock-file"] }); + expect(nix.nix.slice(0, 3)).toEqual(["print-dev-env", "--accept-flake-config", "--no-write-lock-file"]); const home = r.home.accountHome("acct1"); - // The account's XDG home applies to opencode alone, set inside the devShell. + // The account's XDG home applies to opencode alone, set inside the devShell built from the revision. expect(first.argv).toEqual(["serve", "--hostname=127.0.0.1", "--port=0"]); + expect(first.devShell).toBe("1"); expect(first.xdg).toBe(join(home, "data")); expect(first.gh).toBeNull(); expect(first.stdin).toContain("kix adds for this job: Extra rule."); @@ -1142,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; @@ -1380,8 +1570,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: () => {},