From ed210a0d155e57302e63eb4d5f9d565bc36c32a4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 22:39:06 -0700 Subject: [PATCH 1/7] chore(quest): claim quest/m0/kix/runner.md Quest-Claim: 7f8d2d05130c3865 From f57d1fe3bd7c4a890372ce3f9dedf09d94d3056b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 23:10:27 -0700 Subject: [PATCH 2/7] feat(runner): run kix jobs on your own machines with quest runner Checkpoint: quest runner login/account/run, the Claude Code adapter over claude -p stream-json with kix tools as an HTTP MCP server, job checkouts and publication, raw output upload, and the Worker's runner page, login approval, and account preference order. Co-Authored-By: Claude Opus 5.5 --- cloudflare/src/inbox/page.tsx | 3 +- cloudflare/src/index.tsx | 49 +- cloudflare/src/jobs/routes.ts | 8 + cloudflare/src/jobs/store.ts | 66 ++- cloudflare/src/runners.tsx | 160 ++++++ cloudflare/test/runners.test.ts | 124 +++++ src/cli/agent.ts | 10 +- src/cli/main.ts | 72 +++ src/runner/adapter.ts | 59 +++ src/runner/claude.ts | 194 ++++++++ src/runner/cli.ts | 213 ++++++++ src/runner/connection.ts | 169 +++++++ src/runner/home.ts | 98 ++++ src/runner/job.ts | 445 +++++++++++++++++ src/runner/mcp.ts | 134 +++++ src/runner/output.ts | 92 ++++ src/runner/prompt.ts | 111 +++++ src/runner/protocol.ts | 144 ++++++ src/runner/runner.ts | 237 +++++++++ src/runner/workspace.ts | 194 ++++++++ test/runner.test.ts | 831 ++++++++++++++++++++++++++++++++ 21 files changed, 3401 insertions(+), 12 deletions(-) create mode 100644 cloudflare/src/runners.tsx create mode 100644 cloudflare/test/runners.test.ts create mode 100644 src/runner/adapter.ts create mode 100644 src/runner/claude.ts create mode 100644 src/runner/cli.ts create mode 100644 src/runner/connection.ts create mode 100644 src/runner/home.ts create mode 100644 src/runner/job.ts create mode 100644 src/runner/mcp.ts create mode 100644 src/runner/output.ts create mode 100644 src/runner/prompt.ts create mode 100644 src/runner/protocol.ts create mode 100644 src/runner/runner.ts create mode 100644 src/runner/workspace.ts create mode 100644 test/runner.test.ts diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx index ee844133..b785e696 100644 --- a/cloudflare/src/inbox/page.tsx +++ b/cloudflare/src/inbox/page.tsx @@ -542,7 +542,8 @@ export function InboxPage(props: InboxProps) { )}

Lists enrolled projects you maintain, once you open each one's setup page. Runners and accounts are - set up with quest runner. + set up with quest runner; your runners shows them and the order + jobs use your accounts in.

{props.skipped.length > 0 && ( diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx index c24adb9d..d325c7f0 100644 --- a/cloudflare/src/index.tsx +++ b/cloudflare/src/index.tsx @@ -15,9 +15,10 @@ import { githubWebhooks } from "./github/webhook"; import * as inbox from "./inbox/model"; import { InboxPage } from "./inbox/page"; import { purge, retain } from "./jobs/output"; -import { BootstrapRequired, fromForm, inboxURL, jobs, nextURL, resume, stop } from "./jobs/routes"; -import { JobError, active } from "./jobs/store"; +import { BootstrapRequired, fromForm, hub, inboxURL, jobs, nextURL, resume, stop } from "./jobs/routes"; +import { JobError, active, listRunners, moveAccount, registerRunner } from "./jobs/store"; import { Layout, type Page, type User } from "./layout"; +import { RunnersPage, connectRequest } from "./runners"; import * as push from "./push/notify"; export const app = new Hono<{ Bindings: Env }>(); @@ -289,6 +290,50 @@ app.get("/inbox", async (c) => { />, ); }); +// The signed-in user's runners and accounts, and approval of a `quest runner login` link. +app.get("/runners", async (c) => { + const session = await createAuth(c.env).api.getSession({ headers: c.req.raw.headers }); + if (!session) return c.redirect(`/sign-in?${new URLSearchParams({ next: here(c) })}`, 303); + const owner = session.user.id; + const online = new Set(await hub(c.env, owner).online()); + return page( + c, + { title: "Runners", description: "Your runners, their agent accounts, and the order jobs use them in." }, + , + ); +}); +/** The signed-in user behind a runner form, which must come from this site. */ +async function signedIn(c: Context<{ Bindings: Env }>) { + if (c.req.header("Origin") !== new URL(c.env.AUTH_URL).origin) throw new GitHubError("Invalid origin"); + const session = await createAuth(c.env).api.getSession({ headers: c.req.raw.headers }); + if (!session) throw new GitHubError("Sign in required"); + return session.user.id; +} +app.post("/runners/connect", async (c) => { + const owner = await signedIn(c); + const body = await c.req.parseBody(); + const request = connectRequest( + Object.fromEntries(Object.entries(body).flatMap(([k, v]) => (typeof v === "string" ? [[k, v]] : []))), + ); + if (!request) throw new JobError("Name, platform, and key required"); + // Logins never declare VM builds; the job sandbox adds that. + await registerRunner(c.env.DB, owner, request.name, request.platform, false, request.key, Date.now()); + return c.redirect(`/runners?${new URLSearchParams({ connected: request.name })}`, 303); +}); +app.post("/runners/accounts", async (c) => { + const owner = await signedIn(c); + const { runner, account, move } = await c.req.parseBody(); + if (typeof runner !== "string" || typeof account !== "string" || (move !== "up" && move !== "down")) + throw new JobError("Runner, account, and direction required"); + await moveAccount(c.env.DB, owner, runner, account, move); + // A new order can route queued work that was waiting on another account. + await hub(c.env, owner).dispatch(owner); + return c.redirect("/runners", 303); +}); app.get("/projects/github/:owner/:repository/board", async (c) => { const state = await liveProject(c); if (!state) return c.redirect(`${projectURL(c.req.param("owner"), c.req.param("repository"))}/state`, 303); diff --git a/cloudflare/src/jobs/routes.ts b/cloudflare/src/jobs/routes.ts index fecb0308..aa301ed7 100644 --- a/cloudflare/src/jobs/routes.ts +++ b/cloudflare/src/jobs/routes.ts @@ -86,6 +86,14 @@ async function machine(c: Context) { const token = c.req.header("Authorization")?.match(/^Bearer ([A-Za-z0-9_-]{43})$/)?.[1]; return (token && (await store.authenticate(c.env.DB, token))) || null; } +// The machine a token registered, which `quest runner login` polls until the owner approves it. +jobs.get("/api/runner", async (c) => { + c.header("Cache-Control", "no-store"); + const runner = await machine(c); + if (!runner) return c.json({ error: "Runner token required" }, 401); + const { id, name, platform, vm } = runner; + return c.json({ id, name, platform, vm: vm === 1 }); +}); jobs.get("/api/runner/connect", async (c) => { const runner = await machine(c); if (!runner) return c.json({ error: "Runner token required" }, 401); diff --git a/cloudflare/src/jobs/store.ts b/cloudflare/src/jobs/store.ts index 2f7ca0e2..49e3acea 100644 --- a/cloudflare/src/jobs/store.ts +++ b/cloudflare/src/jobs/store.ts @@ -79,7 +79,8 @@ export type Offer = { limited: boolean; }; -async function digest(token: string) { +/** A runner token's stored form: its SHA-256 in lowercase hex. */ +export async function digest(token: string) { const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)); return Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join(""); } @@ -93,15 +94,68 @@ export async function createRunner( vm: boolean, now: number, ) { - const id = crypto.randomUUID(); const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url"); - await db - .prepare("INSERT INTO runners(id,owner,name,tokenHash,platform,vm,createdAt) VALUES(?,?,?,?,?,?,?)") - .bind(id, owner, name, await digest(token), platform, vm ? 1 : 0, now) - .run(); + const id = await registerRunner(db, owner, name, platform, vm, await digest(token), now); return { id, token }; } +/** + * Register a machine that made its own token and sent only its hash, as + * `quest runner login` does, so the token never leaves the machine. Approving + * the same hash again returns the same runner; another owner's is refused. + */ +export async function registerRunner( + db: D1Database, + owner: string, + name: string, + platform: Platform, + vm: boolean, + tokenHash: string, + now: number, +) { + const [, row] = await db.batch([ + db + .prepare( + "INSERT INTO runners(id,owner,name,tokenHash,platform,vm,createdAt) VALUES(?,?,?,?,?,?,?) ON CONFLICT(tokenHash) DO NOTHING", + ) + .bind(crypto.randomUUID(), owner, name, tokenHash, platform, vm ? 1 : 0, now), + db.prepare("SELECT id,owner FROM runners WHERE tokenHash=?").bind(tokenHash), + ]); + const runner = row!.results[0] as { id: string; owner: string }; + if (runner.owner !== owner) throw new JobError("This runner is already connected to another account"); + return runner.id; +} + +/** + * Move one of the owner's accounts a place earlier or later in their + * preference order, which routing follows across all of their runners. + */ +export async function moveAccount( + db: D1Database, + owner: string, + runner: string, + account: string, + direction: "up" | "down", +) { + const { results } = await db + .prepare( + "SELECT a.runner,a.id FROM runner_accounts a JOIN runners r ON r.id=a.runner WHERE r.owner=? ORDER BY a.rank,a.runner,a.id", + ) + .bind(owner) + .all<{ runner: string; id: string }>(); + const from = results.findIndex((a) => a.runner === runner && a.id === account); + if (from < 0) throw new JobError("Account not found"); + const to = direction === "up" ? from - 1 : from + 1; + if (to < 0 || to >= results.length) return; + [results[from], results[to]] = [results[to]!, results[from]!]; + // Ranks are renumbered in the new order, so ties from concurrent reports cannot hide the move. + await db.batch( + results.map((a, i) => + db.prepare("UPDATE runner_accounts SET rank=? WHERE runner=? AND id=?").bind(i + 1, a.runner, a.id), + ), + ); +} + export async function authenticate(db: D1Database, token: string): Promise { return db .prepare("SELECT id,owner,name,platform,vm FROM runners WHERE tokenHash=?") diff --git a/cloudflare/src/runners.tsx b/cloudflare/src/runners.tsx new file mode 100644 index 00000000..1c093344 --- /dev/null +++ b/cloudflare/src/runners.tsx @@ -0,0 +1,160 @@ +import type { Platform } from "./jobs/store"; + +// The signed-in user's runners and agent accounts: a secondary view off the +// decision inbox. It shows each runner's presence, its accounts, and the +// preference order routing follows, and approves a `quest runner login`. + +export type RunnerView = { + id: string; + name: string; + platform: Platform; + vm: number; + online: boolean; + accounts: { + runner: string; + id: string; + provider: string; + kind: string; + label: string; + rank: number; + limited: number; + }[]; +}; + +/** A machine asking to connect: what `quest runner login` put in its link. */ +export type Connect = { name: string; platform: Platform; key: string }; + +const compare = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); + +const platforms: Record = { linux: "Linux", darwin: "macOS" }; + +/** Parse the connect link's query, or null when it is not a valid request. */ +export function connectRequest(query: Record): Connect | null { + const { name, platform, key } = query; + if (!name?.trim() || name.length > 100 || (platform !== "linux" && platform !== "darwin")) return null; + if (!key || !/^[0-9a-f]{64}$/.test(key)) return null; + return { name, platform, key }; +} + +function ConnectForm(props: { connect: Connect }) { + const { name, platform, key } = props.connect; + return ( +
+

Connect a runner

+

+ {name} ({platforms[platform]}) asks to run jobs for your projects with its own agent + accounts. +

+ +
+ + + + +
+
+ ); +} + +export function RunnersPage(props: { runners: RunnerView[]; connect: Connect | null; connected?: string }) { + // The owner's preference order spans their runners, as routing reads it. + const ordered = props.runners + .flatMap((runner) => runner.accounts.map((account) => ({ runner, account }))) + .sort( + (a, b) => + a.account.rank - b.account.rank || + compare(a.account.runner, b.account.runner) || + compare(a.account.id, b.account.id), + ); + return ( +
+

Runners

+

+ Runners are your own Linux and macOS machines running quest runner. Each one drives the + official agent CLIs with the accounts signed in on it; kix never holds their tokens. +

+ {props.connected && ( +

+ Connected {props.connected}. It goes online once quest runner starts. +

+ )} + {props.connect && } +
+

Machines

+ {props.runners.length ? ( +
    + {props.runners.map((runner) => ( +
  • + {runner.name} + +
  • + ))} +
+ ) : ( +

+ No runners yet. On a Linux or macOS machine with Nix, run quest runner login, then{" "} + quest runner account add and quest runner. +

+ )} +
+
+

Account preference

+ {ordered.length ? ( + <> +

+ Jobs go to the first account here whose runner is online with a free slot. A limited account + is skipped until its runner reports it usable again. +

+
    + {ordered.map(({ runner, account }, i) => ( +
  1. + {account.label} + +
    + + + + +
    +
  2. + ))} +
+ + ) : ( +

+ No accounts yet. Add one on a runner with quest runner account add. +

+ )} +
+

+ Back to your decisions. +

+
+ ); +} diff --git a/cloudflare/test/runners.test.ts b/cloudflare/test/runners.test.ts new file mode 100644 index 00000000..fbf194c2 --- /dev/null +++ b/cloudflare/test/runners.test.ts @@ -0,0 +1,124 @@ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { app } from "../src/index"; +import * as store from "../src/jobs/store"; +import { identity, oauth, origin } from "./login-fixtures"; + +const db = env.DB; + +let cookie = ""; +let owner = ""; + +beforeEach(async () => { + await db.prepare("DELETE FROM runners").run(); + const who = identity("github"); + cookie = (await oauth(who)).session!; + owner = (await db + .prepare("SELECT userId FROM account WHERE providerId='github' AND accountId=?") + .bind(who.id) + .first<{ userId: string }>())!.userId; +}); + +const token = "t".repeat(43); +const key = async () => store.digest(token); + +function get(path: string, headers: Record = { Cookie: cookie }) { + return app.request(`${origin}${path}`, { headers, redirect: "manual" }, env); +} +function post(path: string, form: Record, headers: Record = {}) { + return app.request( + `${origin}${path}`, + { + method: "POST", + headers: { Cookie: cookie, Origin: origin, ...headers }, + body: new URLSearchParams(form), + redirect: "manual", + }, + env, + ); +} +const whoami = () => app.request(`${origin}/api/runner`, { headers: { Authorization: `Bearer ${token}` } }, env); + +describe("runner login", () => { + it("connects a machine by the hash of the token it keeps", async () => { + expect((await whoami()).status).toBe(401); + const link = `/runners?${new URLSearchParams({ name: "laptop", platform: "darwin", key: await key() })}`; + // Signed out, the link survives sign-in. + const out = await get(link, {}); + expect(out.status).toBe(303); + expect(new URL(out.headers.get("location")!, origin).searchParams.get("next")).toBe(link); + const page = await (await get(link)).text(); + expect(page).toContain("Connect laptop"); + expect(page).toContain(`value="${await key()}"`); + + const form = { name: "laptop", platform: "darwin", key: await key() }; + expect((await post("/runners/connect", form, { Origin: "https://evil.example" })).status).toBe(403); + const connected = await post("/runners/connect", form); + expect(connected.status).toBe(303); + expect(connected.headers.get("location")).toBe("/runners?connected=laptop"); + // Approving the same link twice keeps one runner. + expect((await post("/runners/connect", form)).status).toBe(303); + const { results } = await db.prepare("SELECT owner,name,platform,vm FROM runners").all(); + expect(results).toEqual([{ owner, name: "laptop", platform: "darwin", vm: 0 }]); + + const me = await whoami(); + expect(me.status).toBe(200); + expect(await me.json()).toMatchObject({ name: "laptop", platform: "darwin", vm: false }); + }); + + it("refuses a key another account already connected", async () => { + await db + .prepare("INSERT OR IGNORE INTO user(id,name,email,emailVerified,createdAt,updatedAt) VALUES(?,?,?,1,0,0)") + .bind("someone", "someone", "someone@example.test") + .run(); + await store.registerRunner(db, "someone", "theirs", "linux", false, await key(), 0); + const refused = await post("/runners/connect", { name: "mine", platform: "linux", key: await key() }); + expect(refused.status).toBe(409); + expect(await refused.text()).toContain("already connected to another account"); + }); + + it("rejects malformed connect requests", async () => { + const bad = await post("/runners/connect", { name: "x", platform: "windows", key: await key() }); + expect(bad.status).toBe(409); + const page = await ( + await get(`/runners?${new URLSearchParams({ name: "x", platform: "linux", key: "nope" })}`) + ).text(); + expect(page).not.toContain("Connect x"); + }); +}); + +describe("account preference", () => { + it("lists accounts across runners in preference order and reorders them", async () => { + const a = await store.createRunner(db, owner, "alpha", "linux", false, 0); + const b = await store.createRunner(db, owner, "beta", "darwin", false, 0); + const runner = (id: string, name: string) => ({ id, owner, name, platform: "linux" as const, vm: 0 }); + const account = (id: string) => ({ + id, + provider: "anthropic", + kind: "subscription" as const, + label: id, + limited: false, + }); + await store.report(db, runner(a.id, "alpha"), [account("first")], [], 0); + await store.report(db, runner(b.id, "beta"), [account("second"), account("third")], [], 0); + const order = async () => { + const html = await (await get("/runners")).text(); + return ["first", "second", "third"].sort((x, y) => html.indexOf(`>${x}<`) - html.indexOf(`>${y}<`)); + }; + expect(await order()).toEqual(["first", "second", "third"]); + expect((await post("/runners/accounts", { runner: b.id, account: "third", move: "up" })).status).toBe(303); + expect(await order()).toEqual(["first", "third", "second"]); + expect((await post("/runners/accounts", { runner: a.id, account: "first", move: "down" })).status).toBe(303); + expect(await order()).toEqual(["third", "first", "second"]); + // The ends stay put, and another owner's account is not found. + expect((await post("/runners/accounts", { runner: b.id, account: "third", move: "up" })).status).toBe(303); + expect(await order()).toEqual(["third", "first", "second"]); + expect((await post("/runners/accounts", { runner: "missing", account: "first", move: "up" })).status).toBe(409); + expect( + (await post("/runners/accounts", { runner: a.id, account: "first", move: "up" }, { Origin: "" })).status, + ).toBe(403); + const html = await (await get("/runners")).text(); + expect(html).toContain("alpha"); + expect(html).toContain("Offline"); + }); +}); diff --git a/src/cli/agent.ts b/src/cli/agent.ts index 876d82d4..eb17e2d7 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -220,8 +220,12 @@ const STRIPPED = [ /** The agent's environment: the user's, minus `STRIPPED`, plus its one key. */ export function agentEnvironment(env: NodeJS.ProcessEnv, variable: string, key: string): NodeJS.ProcessEnv { - const kept = Object.entries(env).filter(([name]) => !STRIPPED.some((pattern) => pattern.test(name))); - return { ...Object.fromEntries(kept), [variable]: key }; + return { ...strippedEnvironment(env), [variable]: key }; +} + +/** The user's environment minus `STRIPPED`, for an agent that brings its own credentials. */ +export function strippedEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return Object.fromEntries(Object.entries(env).filter(([name]) => !STRIPPED.some((pattern) => pattern.test(name)))); } /** @@ -239,7 +243,7 @@ export function agentDirectory(env = process.env): string { * Create `dir` readable only by this user, refusing a symlink or a directory * someone else owns, either of which another user could control. */ -function privateDirectory(dir: string): string { +export function privateDirectory(dir: string): string { mkdirSync(dir, { recursive: true, mode: 0o700 }); const stat = lstatSync(dir); if (!stat.isDirectory()) throw new Error(`${dir} is a symlink or not a directory`); diff --git a/src/cli/main.ts b/src/cli/main.ts index 44c2a2d9..6e750edd 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -18,6 +18,15 @@ import { unblocked, } from "../core"; import { AgentInputError, HARNESSES, type Launch, processDependencies, runAgent, stopAgent, stopAll } from "./agent"; +import { + DEFAULT_URL, + RunnerInputError, + addAccount, + listAccounts, + login, + removeAccount, + runRunner, +} from "../runner/cli"; import { claims, questBranch } from "./claims"; import { gate } from "./gate"; import { gitHubDependencies, GitHubInputError, pullRequest } from "./github"; @@ -507,6 +516,69 @@ export function main(argv: string[], out: Output): number | Promise { ); }); + const runnerIO = () => ({ stdout: out.stdout, stderr: out.stderr, env: process.env }); + const runnerFailure = (error: unknown) => { + if (error instanceof RunnerInputError) { + out.stderr(`error: ${error.message}\n`); + return 2; + } + warn(error instanceof Error ? error.message : String(error)); + return 1; + }; + const runnerAction = (task: () => number | Promise) => { + try { + pending = Promise.resolve(task()).catch(runnerFailure); + } catch (error) { + pending = Promise.resolve(runnerFailure(error)); + } + }; + const runner = program + .command("runner") + .summary("Run kix jobs on this machine with its own agent accounts") + .description( + "Turn this Linux or macOS machine into a kix runner. `quest runner login` connects it to your kix " + + "account, `quest runner account add` signs agent accounts in with their own CLIs, and `quest runner` " + + "runs routed jobs in the foreground until interrupted.\n\n" + + "Each account signs in through Claude Code's own flow in an isolated home (CLAUDE_CONFIG_DIR); kix " + + "stores only an opaque account id, its kind, and its label, and never sees its tokens. Every job runs " + + "inside its project's `nix develop` shell. State lives in $QUEST_RUNNER_HOME, by default " + + "$XDG_DATA_HOME/quest/runner.", + ); + runner + .command("run", { isDefault: true }) + .description( + "Connect to kix and run assigned jobs until interrupted. On SIGINT or SIGTERM, running agents stop, " + + "their work is published to their job branches, and their jobs move to another runner.", + ) + .option("--slots ", "Jobs to run at once", positiveInteger("slots"), 1) + .action((options: { slots: number }) => runnerAction(() => runRunner(runnerIO(), options))); + runner + .command("login") + .description( + "Connect this machine to your kix account: print a link to approve it while signed in, then wait. " + + "The machine keeps its token; the link carries only its hash.", + ) + .option("--url ", "The kix origin", DEFAULT_URL) + .option("--name ", "The name kix shows for this machine (default: its hostname)") + .action((options: { url: string; name?: string }) => runnerAction(() => login(runnerIO(), options))); + const account = runner.command("account").description("Add, list, and remove this runner's agent accounts"); + account + .command("add") + .description("Sign a Claude Code account in with `claude auth login` in a new isolated home") + .option("--console", "Sign in with an Anthropic Console account (API billing) instead of a subscription") + .option("--label