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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/10541-the-cell-suites-pass-on-a-pull-s-check-s → main 4 commits fork

  • Merged. scout/10541-the-cell-suites-pass-on-a-pull-s-check-s was merged into main as 1be62c1.

Review

Sign in to review this change.
10 files changed +140 −16
scripts/unreached-allow.json −1
@@ -15,7 +15,6 @@
1515 "src/flow/review_rounds.ts",
1616 "src/flow/shared_red.ts",
1717 "src/flow/stale_fold.ts",
18 "src/kernel/why_now.ts",
1918 "src/spawn/pod.ts",
2019 "src/tui/gallery.ts",
2120 "src/tui/ui/qr.ts",
src/crew/sandbox.ts +12 −4
@@ -179,12 +179,20 @@ export function dataInTemp(data: string): boolean {
179179
180180 function tempDenied(data: string, tmpDir: string | undefined): string[] {
181181 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.
185190 return [
186191 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 }),
188196 ];
189197 }
190198
test/child-quota.test.ts +7 −2
@@ -10,7 +10,8 @@ import { join } from "node:path";
1010 import { createApp, type App } from "../src/app.ts";
1111 import { createUser } from "../src/core/users.ts";
1212 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";
1415 import { usePlacement, useProbe } from "../src/spawn/door.ts";
1516 import { hostedPlacement, memoryBodies } from "../src/spawn/placement.ts";
1617 import { quietLogs } from "./helpers/globals.ts";
@@ -101,7 +102,11 @@ describe("child quota", () => {
101102
102103 test("room for small but not large is refused only for large", async () => {
103104 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);
105110 expect(quotaRefusal(dir, "small")).toBeUndefined();
106111 expect(quotaRefusal(dir, "large")).toContain("memory limit");
107112 });
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
314314 const { denyRead, tmpDir } = policy();
315315 expect(denied(join(dataDir, "crew/scout/01repo/7"), denyRead)).toBe(false);
316316 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));
318320 expect(denied(tmpDir!, denyRead)).toBe(false);
319321 });
320322
@@ -610,3 +612,31 @@ test.skipIf(process.platform !== "linux")("a child forge's run reads and writes
610612 expect(p.denyRead).toContain(neighbour);
611613 expect(p.denyRead).toContain(join(data, "forge.db"));
612614 });
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 @@
55 * does not. It runs where bwrap and socat are (as `spawn-cell.test.ts`'s
66 * real child does) and says it skipped where they are not.
77 */
8 import { expect, test } from "bun:test";
8 import { test } from "bun:test";
99 import { join } from "node:path";
1010 import { portInsteadOfSocket } from "../src/git/hook-address.ts";
1111 import { missing } from "./helpers/tools.ts";
12 import { failedWith } from "./helpers/machine-note.ts";
13 import { resultLine } from "./helpers/result-line.ts";
1214
1315 const cellTools = process.platform === "linux" && !portInsteadOfSocket() && !missing("bwrap", "socat");
1416
@@ -17,17 +19,19 @@ test.skipIf(!cellTools)("a child forge spawned into a cell serves its app at <ap
1719 for (const [k, v] of Object.entries(process.env)) if (v !== undefined && k !== "FORGE_CELLS") env[k] = v;
1820 const proc = Bun.spawn([process.execPath, join(import.meta.dir, "helpers", "real-spawn-e2e.ts")], { env, stdout: "pipe", stderr: "pipe" });
1921 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);
2123 let seen: Record<string, unknown>;
2224 try {
2325 seen = JSON.parse(line) as Record<string, unknown>;
2426 } 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));
2628 }
2729 if (typeof seen["skipped"] === "string") {
2830 console.log(`SKIPPING: this host refuses a cell inside the test (${seen["skipped"]}), so no child was spawned into one`);
2931 return;
3032 }
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));
3337 }, 200_000);
test/spawn-cell.test.ts +6 −3
@@ -15,10 +15,12 @@ import { notePeer } from "../src/http/peer.ts";
1515 import type { App } from "../src/app.ts";
1616 import { portInsteadOfSocket } from "../src/git/hook-address.ts";
1717 import { missing } from "./helpers/tools.ts";
18 import { resultLine } from "./helpers/result-line.ts";
1819 import { hostAllowed } from "../src/apps/egress.ts";
1920 import { childModelHosts, childRegistryHosts, setCapacity } from "../src/core/capacity.ts";
2021 import { openBeatDoor, parentLocal } from "../src/spawn/beat-door.ts";
2122 import { lastHeard, sendBeat } from "../src/spawn/heartbeat.ts";
23 import { failedWith } from "./helpers/machine-note.ts";
2224
2325 const plan = (name: string): SpawnPlan => ({ name, org: "o", apps: [], briefs: [], subscriptions: [], direction: "", keepAccess: false, budgetUsd: null });
2426 const tmp = mkdtempSync(join(tmpdir(), "spawn-cell-"));
@@ -420,18 +422,19 @@ describe("a real child forge", () => {
420422 for (const [k, v] of Object.entries(process.env)) if (v !== undefined && k !== "FORGE_CELLS") env[k] = v;
421423 const proc = Bun.spawn([process.execPath, join(import.meta.dir, "helpers", "real-child-forge.ts")], { env, stdout: "pipe", stderr: "pipe" });
422424 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);
424426 let seen: Record<string, unknown>;
425427 try {
426428 seen = JSON.parse(line) as Record<string, unknown>;
427429 } 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));
429431 }
430432 if (typeof seen["skipped"] === "string") {
431433 console.log(`SKIPPING: this host refuses a cell inside the test (${seen["skipped"]}), so no child forge was booted in one`);
432434 return;
433435 }
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));
435438 expect(seen["first"]).toBe(503);
436439 expect(seen["firstMs"] as number).toBeLessThan(1_000);
437440 expect(seen["limits"]).toEqual({ memoryMiB: 2048, cpus: 1, diskMiB: 2048 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs