Public: anyone can read this; the crew can change it.
The cell suites pass on a pull's check: spawn-cell-e2e and crew-sandbox #560 are green on today's main #10551
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10541-the-cell-suites-pass-on-a-pull-s-check-s → main 4 commits fork
10 files
changed +140
−16
scripts/unreached-allow.json −1
| @@ -15,7 +15,6 @@ | ||
| 15 | 15 | "src/flow/review_rounds.ts", |
| 16 | 16 | "src/flow/shared_red.ts", |
| 17 | 17 | "src/flow/stale_fold.ts", |
| 18 | "src/kernel/why_now.ts", | |
| 19 | 18 | "src/spawn/pod.ts", |
| 20 | 19 | "src/tui/gallery.ts", |
| 21 | 20 | "src/tui/ui/qr.ts", |
src/crew/sandbox.ts +12 −4
| @@ -179,12 +179,20 @@ export function dataInTemp(data: string): boolean { | ||
| 179 | 179 | |
| 180 | 180 | function tempDenied(data: string, tmpDir: string | undefined): string[] { |
| 181 | 181 | const temp = tmpdir(); |
| 182 | // The run's own temp directory is in it (`runTmpDir`): everything else there is denied. | |
| 183 | if (tmpDir !== undefined && !inside(data, tmpDir) && inside(temp, tmpDir)) return others(temp, basename(tmpDir)); | |
| 184 | if (!dataInTemp(data)) return [temp]; | |
| 182 | const own = tmpDir !== undefined && !inside(data, tmpDir) && inside(temp, tmpDir) ? basename(tmpDir) : undefined; | |
| 183 | if (!dataInTemp(data)) { | |
| 184 | // The run's own temp directory is in it (`runTmpDir`): everything else there is denied. | |
| 185 | return own === undefined ? [temp] : others(temp, own); | |
| 186 | } | |
| 187 | // The data directory is in it too, so listing the whole would deny the | |
| 188 | // agent's own crew directory with it (a check's long temp, #10541): the | |
| 189 | // forge's own pieces are denied by name, and every other run's temp. | |
| 185 | 190 | return [ |
| 186 | 191 | join(temp, `claude-${process.getuid?.() ?? 0}`), |
| 187 | ...others(temp, "").filter((path) => basename(path).startsWith("fc-")), | |
| 192 | ...others(temp, "").filter((path) => { | |
| 193 | const name = basename(path); | |
| 194 | return name.startsWith("fc-") || (name.startsWith(RUN_TMP_PREFIX) && name !== own); | |
| 195 | }), | |
| 188 | 196 | ]; |
| 189 | 197 | } |
| 190 | 198 |
test/child-quota.test.ts +7 −2
| @@ -10,7 +10,8 @@ import { join } from "node:path"; | ||
| 10 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 11 | 11 | import { createUser } from "../src/core/users.ts"; |
| 12 | 12 | import { serve, type Servers } from "../src/http/server.ts"; |
| 13 | import { quotaRefusal } from "../src/spawn/quota.ts"; | |
| 13 | import { heldByChildren, quotaRefusal } from "../src/spawn/quota.ts"; | |
| 14 | import { SIZES } from "../src/spawn/sizes.ts"; | |
| 14 | 15 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; |
| 15 | 16 | import { hostedPlacement, memoryBodies } from "../src/spawn/placement.ts"; |
| 16 | 17 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -101,7 +102,11 @@ describe("child quota", () => { | ||
| 101 | 102 | |
| 102 | 103 | test("room for small but not large is refused only for large", async () => { |
| 103 | 104 | clear(); |
| 104 | process.env["FORGE_CHILD_MEMORY_MIB"] = "2000"; | |
| 105 | // Room for exactly one more small beside what the ledger already holds, read | |
| 106 | // from the sizes rather than typed, so a resized small keeps the test true. | |
| 107 | const room = heldByChildren(dir).memoryMiB + SIZES.small.memoryMiB; | |
| 108 | expect(SIZES.large.memoryMiB).toBeGreaterThan(SIZES.small.memoryMiB); | |
| 109 | process.env["FORGE_CHILD_MEMORY_MIB"] = String(room); | |
| 105 | 110 | expect(quotaRefusal(dir, "small")).toBeUndefined(); |
| 106 | 111 | expect(quotaRefusal(dir, "large")).toContain("memory limit"); |
| 107 | 112 | }); |
test/crew-sandbox.test.ts +31 −1
| @@ -314,7 +314,9 @@ describe("the shell sandbox denies the whole data directory but the agent's own | ||
| 314 | 314 | const { denyRead, tmpDir } = policy(); |
| 315 | 315 | expect(denied(join(dataDir, "crew/scout/01repo/7"), denyRead)).toBe(false); |
| 316 | 316 | expect(tmpDir).toBeDefined(); |
| 317 | expect(tmpDir!.startsWith(join(dataDir, "crew/scout/.t/"))).toBe(true); | |
| 317 | // Under the agent, unless the machine's temp is long enough (a check's) | |
| 318 | // that Claude Code's sockets would not fit there (`runTmpDir`, #10541). | |
| 319 | expect(tmpDir).toBe(runTmpDir(dataDir, "scout", runId)); | |
| 318 | 320 | expect(denied(tmpDir!, denyRead)).toBe(false); |
| 319 | 321 | }); |
| 320 | 322 | |
| @@ -610,3 +612,31 @@ test.skipIf(process.platform !== "linux")("a child forge's run reads and writes | ||
| 610 | 612 | expect(p.denyRead).toContain(neighbour); |
| 611 | 613 | expect(p.denyRead).toContain(join(data, "forge.db")); |
| 612 | 614 | }); |
| 615 | ||
| 616 | test.skipIf(process.platform !== "linux")("in a long temp directory holding the data directory, the agent keeps its own crew directory and run temp (#10541)", () => { | |
| 617 | // A check's temp is /data/checks/<id>.home/shard-<x>/tmp: long enough that | |
| 618 | // the run's temp moves beside the data directory, which then must not be denied whole. | |
| 619 | const saved = process.env["TMPDIR"]; | |
| 620 | const temp = join(tmpdir(), `forge-long-temp-${Date.now()}`, "a-check-home-of-some-length.home", "shard-AbCdEf", "tmp"); | |
| 621 | mkdirSync(temp, { recursive: true }); | |
| 622 | process.env["TMPDIR"] = temp; | |
| 623 | try { | |
| 624 | const data = join(temp, "forge-sandbox-allow-RY4WTr"); | |
| 625 | for (const dir of ["crew/scout/01repo/7", "crew/mason", "repos"]) mkdirSync(join(data, dir), { recursive: true }); | |
| 626 | mkdirSync(join(temp, "fr-otherrun0001"), { recursive: true }); | |
| 627 | mkdirSync(join(temp, "fc-somecell"), { recursive: true }); | |
| 628 | const p = sandboxPolicy({ dataDir: data, agentName: "scout", urls: [], runId: "01m46abcdefghjkmnpqrstvwxy" }); | |
| 629 | const denied = (path: string) => p.denyRead.some((d) => path === d || path.startsWith(`${d}/`)); | |
| 630 | expect(p.tmpDir).toBe(join(temp, "fr-kmnpqrstvwxy")); | |
| 631 | expect(denied(join(data, "crew/scout/01repo/7"))).toBe(false); | |
| 632 | expect(denied(p.tmpDir!)).toBe(false); | |
| 633 | expect(denied(join(data, "crew/mason"))).toBe(true); | |
| 634 | expect(denied(join(data, "repos"))).toBe(true); | |
| 635 | expect(denied(join(temp, "fr-otherrun0001"))).toBe(true); | |
| 636 | expect(denied(join(temp, "fc-somecell"))).toBe(true); | |
| 637 | expect(p.denyRead).toContain(join(temp, `claude-${process.getuid?.() ?? 0}`)); | |
| 638 | } finally { | |
| 639 | if (saved === undefined) delete process.env["TMPDIR"]; | |
| 640 | else process.env["TMPDIR"] = saved; | |
| 641 | } | |
| 642 | }); |
test/helpers/machine-note.ts added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | /** | |
| 2 | * What the machine was like when a heavy test failed: the free share of the | |
| 3 | * disk its temp directory is on and the load against the cores. A cell test | |
| 4 | * that times out under load or a full disk says so in its message, not as a | |
| 5 | * bare timeout (#10541). | |
| 6 | */ | |
| 7 | import { statfsSync } from "node:fs"; | |
| 8 | import { availableParallelism, loadavg, tmpdir } from "node:os"; | |
| 9 | ||
| 10 | export function machineNote(dir: string = tmpdir()): string { | |
| 11 | let disk = "disk unknown"; | |
| 12 | try { | |
| 13 | const s = statfsSync(dir); | |
| 14 | disk = `disk ${Math.round((s.bavail / s.blocks) * 100)}% free under ${dir}`; | |
| 15 | } catch { | |
| 16 | // a temp directory that is gone has no figure | |
| 17 | } | |
| 18 | const [one] = loadavg(); | |
| 19 | return `${disk}; load ${(one ?? 0).toFixed(1)} on ${availableParallelism()} cores`; | |
| 20 | } | |
| 21 | ||
| 22 | /** The test's whole evidence in one message: what the run printed, its stderr's tail, and the machine. */ | |
| 23 | export function failedWith(what: string, seen: unknown, errText: string): string { | |
| 24 | return `${what}: ${JSON.stringify(seen)}\n${machineNote()}\nstderr tail: ${errText.slice(-1500)}`; | |
| 25 | } |
test/helpers/result-line.ts added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | /** | |
| 2 | * The line a heavy test's helper printed as its result: the last line of its | |
| 3 | * stdout that is a JSON object. The helper prints its result before it stops | |
| 4 | * its cell children, and their shutdown lines ("forge draining", "forge | |
| 5 | * shutting down") reach stdout after it, so the last line is not the result | |
| 6 | * (#10541). | |
| 7 | */ | |
| 8 | export function resultLine(out: string): string { | |
| 9 | const lines = out.trim().split("\n"); | |
| 10 | for (let i = lines.length - 1; i >= 0; i--) { | |
| 11 | const line = (lines[i] ?? "").trim(); | |
| 12 | if (!line.startsWith("{")) continue; | |
| 13 | try { | |
| 14 | const v: unknown = JSON.parse(line); | |
| 15 | if (v !== null && typeof v === "object" && !Array.isArray(v)) return line; | |
| 16 | } catch { | |
| 17 | // a log line that happens to open with a brace | |
| 18 | } | |
| 19 | } | |
| 20 | return ""; | |
| 21 | } |
test/machine-note.test.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { tmpdir } from "node:os"; | |
| 3 | import { failedWith, machineNote } from "./helpers/machine-note.ts"; | |
| 4 | ||
| 5 | test("a heavy test's failure names the disk's free share and the load, not a bare timeout (#10541)", () => { | |
| 6 | expect(machineNote(tmpdir())).toMatch(/^disk \d+% free under .+; load \d+\.\d on \d+ cores$/); | |
| 7 | expect(machineNote("/no/such/dir")).toStartWith("disk unknown; load "); | |
| 8 | const message = failedWith("the child forge did not come up in its cell", { failure: "its cell did not come up: no output" }, "x".repeat(3000) + "tail"); | |
| 9 | expect(message).toContain("its cell did not come up: no output"); | |
| 10 | expect(message).toContain("% free under"); | |
| 11 | expect(message.endsWith("tail")).toBe(true); | |
| 12 | expect(message.length).toBeLessThan(2000); | |
| 13 | }); |
test/result-line.test.ts added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { resultLine } from "./helpers/result-line.ts"; | |
| 3 | ||
| 4 | test("a helper's result is found under the child's shutdown lines that follow it (#10541)", () => { | |
| 5 | const out = [ | |
| 6 | '2026-11-10T03:34:05.000Z INFO [forge] child forge name=kid line="booted"', | |
| 7 | '{"placement":"cell","status":200,"body":"bakery says hi","app":"bakery"}', | |
| 8 | '2026-11-10T03:34:05.253Z INFO [forge] child forge name=kid line="forge draining: /readyz answers 503 from now"', | |
| 9 | '2026-11-10T03:34:15.239Z INFO [forge] child forge name=kid line="forge shutting down"', | |
| 10 | "", | |
| 11 | ].join("\n"); | |
| 12 | expect(JSON.parse(resultLine(out))).toEqual({ placement: "cell", status: 200, body: "bakery says hi", app: "bakery" }); | |
| 13 | expect(resultLine('{"skipped":"no bwrap"}\n')).toBe('{"skipped":"no bwrap"}'); | |
| 14 | expect(resultLine("{ not json\nonly logs\n")).toBe(""); | |
| 15 | expect(resultLine("")).toBe(""); | |
| 16 | }); |
test/spawn-cell-e2e.test.ts +9 −5
| @@ -5,10 +5,12 @@ | ||
| 5 | 5 | * does not. It runs where bwrap and socat are (as `spawn-cell.test.ts`'s |
| 6 | 6 | * real child does) and says it skipped where they are not. |
| 7 | 7 | */ |
| 8 | import { expect, test } from "bun:test"; | |
| 8 | import { test } from "bun:test"; | |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { portInsteadOfSocket } from "../src/git/hook-address.ts"; |
| 11 | 11 | import { missing } from "./helpers/tools.ts"; |
| 12 | import { failedWith } from "./helpers/machine-note.ts"; | |
| 13 | import { resultLine } from "./helpers/result-line.ts"; | |
| 12 | 14 | |
| 13 | 15 | const cellTools = process.platform === "linux" && !portInsteadOfSocket() && !missing("bwrap", "socat"); |
| 14 | 16 | |
| @@ -17,17 +19,19 @@ test.skipIf(!cellTools)("a child forge spawned into a cell serves its app at <ap | ||
| 17 | 19 | for (const [k, v] of Object.entries(process.env)) if (v !== undefined && k !== "FORGE_CELLS") env[k] = v; |
| 18 | 20 | const proc = Bun.spawn([process.execPath, join(import.meta.dir, "helpers", "real-spawn-e2e.ts")], { env, stdout: "pipe", stderr: "pipe" }); |
| 19 | 21 | const [out, errText] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); |
| 20 | const line = out.trim().split("\n").pop() ?? ""; | |
| 22 | const line = resultLine(out); | |
| 21 | 23 | let seen: Record<string, unknown>; |
| 22 | 24 | try { |
| 23 | 25 | seen = JSON.parse(line) as Record<string, unknown>; |
| 24 | 26 | } catch { |
| 25 | throw new Error(`the spawn run printed no result: ${out.slice(-800)} ${errText.slice(-800)}`); | |
| 27 | throw new Error(failedWith("the spawn run printed no result", out.slice(-800), errText)); | |
| 26 | 28 | } |
| 27 | 29 | if (typeof seen["skipped"] === "string") { |
| 28 | 30 | console.log(`SKIPPING: this host refuses a cell inside the test (${seen["skipped"]}), so no child was spawned into one`); |
| 29 | 31 | return; |
| 30 | 32 | } |
| 31 | expect(seen["placement"]).toBe("cell"); | |
| 32 | expect(seen).toMatchObject({ status: 200, app: "bakery", body: "bakery says hi" }); | |
| 33 | // The child's own reason (its parent's "did not answer" line) and the | |
| 34 | // machine's disk and load are in the message, not a bare mismatch. | |
| 35 | const ok = seen["placement"] === "cell" && seen["status"] === 200 && seen["app"] === "bakery" && seen["body"] === "bakery says hi"; | |
| 36 | if (!ok) throw new Error(failedWith("the child forge's app did not answer through the cell", { seen, out: out.slice(-1500) }, errText)); | |
| 33 | 37 | }, 200_000); |
test/spawn-cell.test.ts +6 −3
| @@ -15,10 +15,12 @@ import { notePeer } from "../src/http/peer.ts"; | ||
| 15 | 15 | import type { App } from "../src/app.ts"; |
| 16 | 16 | import { portInsteadOfSocket } from "../src/git/hook-address.ts"; |
| 17 | 17 | import { missing } from "./helpers/tools.ts"; |
| 18 | import { resultLine } from "./helpers/result-line.ts"; | |
| 18 | 19 | import { hostAllowed } from "../src/apps/egress.ts"; |
| 19 | 20 | import { childModelHosts, childRegistryHosts, setCapacity } from "../src/core/capacity.ts"; |
| 20 | 21 | import { openBeatDoor, parentLocal } from "../src/spawn/beat-door.ts"; |
| 21 | 22 | import { lastHeard, sendBeat } from "../src/spawn/heartbeat.ts"; |
| 23 | import { failedWith } from "./helpers/machine-note.ts"; | |
| 22 | 24 | |
| 23 | 25 | const plan = (name: string): SpawnPlan => ({ name, org: "o", apps: [], briefs: [], subscriptions: [], direction: "", keepAccess: false, budgetUsd: null }); |
| 24 | 26 | const tmp = mkdtempSync(join(tmpdir(), "spawn-cell-")); |
| @@ -420,18 +422,19 @@ describe("a real child forge", () => { | ||
| 420 | 422 | for (const [k, v] of Object.entries(process.env)) if (v !== undefined && k !== "FORGE_CELLS") env[k] = v; |
| 421 | 423 | const proc = Bun.spawn([process.execPath, join(import.meta.dir, "helpers", "real-child-forge.ts")], { env, stdout: "pipe", stderr: "pipe" }); |
| 422 | 424 | const [out, errText] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); |
| 423 | const line = out.trim().split("\n").pop() ?? ""; | |
| 425 | const line = resultLine(out); | |
| 424 | 426 | let seen: Record<string, unknown>; |
| 425 | 427 | try { |
| 426 | 428 | seen = JSON.parse(line) as Record<string, unknown>; |
| 427 | 429 | } catch { |
| 428 | throw new Error(`the child forge run printed no result: ${out.slice(-800)} ${errText.slice(-800)}`); | |
| 430 | throw new Error(failedWith("the child forge run printed no result", out.slice(-800), errText)); | |
| 429 | 431 | } |
| 430 | 432 | if (typeof seen["skipped"] === "string") { |
| 431 | 433 | console.log(`SKIPPING: this host refuses a cell inside the test (${seen["skipped"]}), so no child forge was booted in one`); |
| 432 | 434 | return; |
| 433 | 435 | } |
| 434 | expect(seen["failure"]).toBeNull(); | |
| 436 | // The child's own reason and the machine's disk and load, not a bare mismatch. | |
| 437 | if (seen["failure"] !== null || seen["health"] !== 200) throw new Error(failedWith("the child forge did not come up in its cell", { seen, out: out.slice(-1500) }, errText)); | |
| 435 | 438 | expect(seen["first"]).toBe(503); |
| 436 | 439 | expect(seen["firstMs"] as number).toBeLessThan(1_000); |
| 437 | 440 | expect(seen["limits"]).toEqual({ memoryMiB: 2048, cpus: 1, diskMiB: 2048 }); |