Public: anyone can read this; the crew can change it.
Real-cell proof, cell half: the template build walk in a 2 GiB, 1-core cell, and a real memory breach restarting the child #12002
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12001-real-cell-proof-cell-half-the-template-b → main 2 commits fork 2 threads · 2 unresolved
3 files
changed +31
−4
docs/features/real-cell-proof.md +5 −1
| @@ -3,4 +3,8 @@ | ||
| 3 | 3 | - `test/helpers/build-standin.ts` (its work in `build-standin-builder.ts`) is a deterministic stand-in for the model: it adds a `/reading` route and its test to the template's app (`READING-V1` in the prompt), or changes what it says (`READING-V2`), runs the app's real `bun test`, and pushes only when that passes. |
| 4 | 4 | - `test/build-journey.test.ts` walks a first ask to production in one process: `newApp` copies the template, the crew's stand-in builds, the pull's preview run says its tests passed, a person lands it, production serves `/reading`, and a second change lands the same way. About 8 s. |
| 5 | 5 | |
| 6 | Not proved here, and tracked in #12001: the same walk inside a 2 GiB, 1-core cell, and a real memory breach restarting a child. | |
| 6 | ## The cell half, begun (#12001) | |
| 7 | ||
| 8 | `childEnvFor` (src/spawn/cell.ts, over `cellEnv`) now passes the parent's `FORGE_CLAUDE` to a cell child, so the child's crew can run a stand-in written under the child's own directory (the one place both sides see: the cell hides the parent's data and `/tmp`). Production children read it too (`main.ts`), so a parent with Claude off `PATH` has children that find it. Unit-tested in `test/spawn-cell.test.ts`. | |
| 9 | ||
| 10 | Still not proved, and still open in #12001: the walk itself in a real 2 GiB, 1-core cell, and a real memory breach restarting a child. Both need bwrap and socat with user namespaces, which the sandbox this was written in refuses. |
src/spawn/cell.ts +15 −2
| @@ -224,7 +224,7 @@ export function cellSpecFor(input: { | ||
| 224 | 224 | * its cgroup's directory (when the kernel holds the caps) let it read its own |
| 225 | 225 | * use against its size (`selfUse`, `spawn/usage.ts`; #10853). |
| 226 | 226 | */ |
| 227 | export function cellEnv(input: { port: number; dataDir: string; url: string; parentPort?: number; size?: Size; cgroupDir?: string }): Record<string, string> { | |
| 227 | export function cellEnv(input: { port: number; dataDir: string; url: string; parentPort?: number; size?: Size; cgroupDir?: string; claude?: string }): Record<string, string> { | |
| 228 | 228 | const env = childEnv({ |
| 229 | 229 | PORT: String(input.port), |
| 230 | 230 | FORGE_HOST: "127.0.0.1", |
| @@ -234,12 +234,25 @@ export function cellEnv(input: { port: number; dataDir: string; url: string; par | ||
| 234 | 234 | ...(input.size === undefined ? {} : { [SIZE_ENV]: input.size }), |
| 235 | 235 | ...(input.cgroupDir === undefined ? {} : { [CGROUP_DIR_ENV]: input.cgroupDir }), |
| 236 | 236 | ...(input.parentPort === undefined ? {} : { FORGE_PARENT_LOCAL: parentLocal(input.parentPort) }), |
| 237 | // Which Claude Code its crew runs: the parent's own choice, a path and not | |
| 238 | // a secret, so a test can put a stand-in in the cell (#12001). | |
| 239 | ...(input.claude === undefined || input.claude === "" ? {} : { FORGE_CLAUDE: input.claude }), | |
| 237 | 240 | }); |
| 238 | 241 | delete env["USER"]; |
| 239 | 242 | delete env["LOGNAME"]; |
| 240 | 243 | return env; |
| 241 | 244 | } |
| 242 | 245 | |
| 246 | /** | |
| 247 | * What a child is started with: `cellEnv`, plus the parent's `FORGE_CLAUDE` | |
| 248 | * when it has one. The child's crew reads it (`main.ts`), so a parent run with | |
| 249 | * Claude Code somewhere off `PATH` has children that find it too. | |
| 250 | */ | |
| 251 | export function childEnvFor(input: Parameters<typeof cellEnv>[0], parentEnv: Record<string, string | undefined> = process.env): Record<string, string> { | |
| 252 | const claude = parentEnv["FORGE_CLAUDE"]; | |
| 253 | return cellEnv({ ...input, ...(claude === undefined ? {} : { claude }) }); | |
| 254 | } | |
| 255 | ||
| 243 | 256 | export interface CellChild { |
| 244 | 257 | readonly bodyId: string; |
| 245 | 258 | readonly name: string; |
| @@ -397,7 +410,7 @@ export function makeRegistry(parent: App, launcher?: Launcher): CellChildren { | ||
| 397 | 410 | const command = `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(source.main)}`; |
| 398 | 411 | const proc = Bun.spawn(cell.argv(command), { |
| 399 | 412 | cwd: dirname(source.main), |
| 400 | env: cell.env(cellEnv({ port, dataDir: dir, url, parentPort, size: input.size, ...(cell.cgroupDir === undefined ? {} : { cgroupDir: cell.cgroupDir }) })), | |
| 413 | env: cell.env(childEnvFor({ port, dataDir: dir, url, parentPort, size: input.size, ...(cell.cgroupDir === undefined ? {} : { cgroupDir: cell.cgroupDir }) })), | |
| 401 | 414 | stdin: "ignore", |
| 402 | 415 | stdout: "pipe", |
| 403 | 416 | stderr: "pipe", |
test/spawn-cell.test.ts +11 −1
| @@ -10,7 +10,7 @@ import { createRun, queueDepth } from "../src/core/runs.ts"; | ||
| 10 | 10 | import { createApp } from "../src/app.ts"; |
| 11 | 11 | import { childEnv } from "../src/apps/process.ts"; |
| 12 | 12 | import { watchedLimits } from "../src/apps/contain.ts"; |
| 13 | import { childLog, plainLine, cellChildren, crewAtWork, nextRunDue, type CellChild, stopCellChildren, stopGently, cellEnv, cellLimits, cellPlacement, cellSpecFor, forward, launchSpecFor, ownsBody } from "../src/spawn/cell.ts"; | |
| 13 | import { childLog, plainLine, cellChildren, crewAtWork, nextRunDue, type CellChild, stopCellChildren, stopGently, cellEnv, childEnvFor, cellLimits, cellPlacement, cellSpecFor, forward, launchSpecFor, ownsBody } from "../src/spawn/cell.ts"; | |
| 14 | 14 | import { fallbackPlacement, hostedPlacement, memoryBodies } from "../src/spawn/placement.ts"; |
| 15 | 15 | import type { SpawnPlan } from "../src/spawn/plan.ts"; |
| 16 | 16 | import { SIZES } from "../src/spawn/sizes.ts"; |
| @@ -311,6 +311,16 @@ describe("a child reaching its parent from inside its cell", () => { | ||
| 311 | 311 | } |
| 312 | 312 | }); |
| 313 | 313 | |
| 314 | test("cellEnv carries FORGE_CLAUDE only when the parent names one (#12001)", () => { | |
| 315 | const base = { port: 4100, dataDir: "/d/c", url: "https://kid--f.test" }; | |
| 316 | expect(cellEnv({ ...base, claude: "/d/c/build-claude" })["FORGE_CLAUDE"]).toBe("/d/c/build-claude"); | |
| 317 | expect(cellEnv(base)["FORGE_CLAUDE"]).toBeUndefined(); | |
| 318 | expect(cellEnv({ ...base, claude: "" })["FORGE_CLAUDE"]).toBeUndefined(); | |
| 319 | // The call site `startOn` uses: the parent's own environment decides. | |
| 320 | expect(childEnvFor(base, { FORGE_CLAUDE: "/opt/claude" })["FORGE_CLAUDE"]).toBe("/opt/claude"); | |
| 321 | expect(childEnvFor(base, {})["FORGE_CLAUDE"]).toBeUndefined(); | |
| 322 | }); | |
| 323 | ||
| 314 | 324 | test("a heartbeat goes to the relayed port when one is named, and to the public address otherwise", async () => { |
| 315 | 325 | const dataDir = join(tmp, "beat-child"); |
| 316 | 326 | mkdirSync(dataDir, { recursive: true }); |
new above
offline
Forwarded to every production child but nothing consumes it yet; land with its consumer, and test the call site in makeRegistry, not only cellEnv.