fix(testbed): L6 STEP-0 — stage the container-mode topology across the host/container boundary

The defect (operator receipt 1caca5be): the loader resolves a scenario's `topology` to a HOST
absolute path and buildRealDeps forwards it UNCHANGED to a CONTAINER daemon (`rig up <hostPath>`),
which reads it inside the container and reports a mystery "Source not found" — the same
host-vs-container path assumption behind the /root staging block and the readable-not-writable block.

Fix (the class, not the instance): a container-mode `stageTopology` seam on the ScenarioDaemon
contract that stages the topology's DIRECTORY into the container (the fixtures reference culture.md +
agents/ relatively, so siblings ride along) and returns the IN-CONTAINER path. The pipeline consumes
it before `up`; host-mode omits the seam (identity — the host path passes through unchanged). The
METHOD (tar-pipe plan + argv + expectFile fence) is imported from dev50's shared module — one method
for runbook and adapter, no re-derivation.

New scenario-container-stage.ts (makeContainerStageTopology) drives the plan's steps
(mkdir → extract-with-tar-stdin → fence) through the docker seam, extended to carry the extract
step's stdin. Loud+named on failure: any non-zero step (INCLUDING the fence, which now asserts the
FILE arrived via expectFile — not just the mkdir'd dir) throws ContainerStageError with the step
diagnostic PROPAGATED, so a masked tar failure (empty-but-writable stage) can never green through
to an EACCES/not-found three steps downstream. The seam contract requires the invoker to check BOTH
the tar and docker exits (a shell pipe masks the first).

Harness-class only (test/helpers + test/) — no pack/image touch, no repin #5. VM gate: staging
driver 3/3 (ordering + masked-tar-fail + fence-fail), adapter test green, parity fence unbroken,
pipeline 17/17 (host-mode unregressed), daemon tsc 0. node_modules per-worktree (readlink verified
inside the worktree, no primary-tree false-green). Docker L6 green is HOST-SIDE — handed off.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-08-07 20:55:36 +00:00
1 parent 4fa5ba48ea
commit a8fdb565d9
5 files changed
+155 -3

No files matched your search

@@ -0,0 +1,69 @@
// L6 STEP-0 — the container-mode TOPOLOGY STAGING adapter (the pipeline's half of the boundary).
//
// The defect it closes: the loader resolves a scenario's `topology` to a HOST absolute path, and
// buildRealDeps forwards it UNCHANGED to a CONTAINER daemon (`rig up <hostPath>`), which reads it
// INSIDE the container and reports a mystery "Source not found" — the same host-vs-container path
// assumption behind the /root staging block and the readable-but-not-writable block.
//
// The METHOD lives in the shared module (testbed-published-daemon.ts: stageTopologyPlan + argv
// helpers, one source of truth with the runbook). THIS file is only the pipeline INTEGRATION — it
// drives the plan's steps through the injected docker seam. It stages the topology's DIRECTORY (the
// fixtures reference culture.md + agents/ relatively, so they must ride along) and returns the
// IN-CONTAINER path to the topology file — never the host absolute (containerStagePath throws on
// absolute/'..' so a host path cannot leak through).
import { basename, dirname } from "node:path";
import { stageTopologyPlan } from "./testbed-published-daemon.js";
import type { DockerResult } from "./scenario-container.js";
/**
* The injected docker seam, extended to carry a step's stdin. A step with `stdinFrom` (the tar-side
* argv) runs `tar <stdinFrom>` piped into `docker <argv>` (the tar-pipe stage); a step without it is
* a plain `docker <argv>`. The invoker MUST check BOTH the tar exit AND the docker exit and report a
* non-zero code if EITHER fails — a shell pipeline reports only the last command's status, so a
* failing tar into a succeeding `docker exec` would otherwise mask an empty stage (the false-green
* class). Real host-side invoker implements the two-process pipe + dual-exit check; the VM fake
* models both, including the tar-fails/docker-succeeds case.
*/
export type StagingDocker = (args: string[], stdinFrom?: string[]) => Promise<DockerResult>;
/** Loud, typed failure — a silent stage/fence failure is exactly the false-green class we keep
* killing (an empty or unwritable stage looks fine until `rig up` EACCESes three steps downstream). */
export class ContainerStageError extends Error {
constructor(message: string) {
super(message);
this.name = "ContainerStageError";
}
}
/**
* Container-mode `stageTopology`: stage the host topology file's DIRECTORY into the container's
* exec-user stage, then return the in-container path to the topology file. Host-mode never wires this
* (identity — the host daemon reads the host path directly). Drives the plan's steps IN ORDER
* (mkdir → extract → fence); the fence runs AFTER extraction (an empty stage passes a naive read
* check) and BEFORE the path is consumed, or the first symptom is EACCES downstream, not a named
* failure here. Any non-zero step (incl. the fence) throws with the step's diagnostic PROPAGATED.
*/
export function makeContainerStageTopology(
container: string,
docker: StagingDocker,
): (hostTopologyPath: string) => Promise<string> {
return async (hostTopologyPath: string): Promise<string> => {
const hostDir = dirname(hostTopologyPath);
// expectFile = the topology's basename: the fence asserts the FILE arrived, not just the dir
// mkdir created — so a shipped-nothing delivery (masked tar failure) is unrepresentable.
const plan = stageTopologyPlan({ container, hostDir, name: "topologies", expectFile: basename(hostTopologyPath) });
for (const step of plan.steps) {
const res = await docker(step.argv, step.stdinFrom);
if (res.code !== 0) {
throw new ContainerStageError(
`container stage step '${step.label}' failed (exit ${res.code}) in ${container}: ` +
`${res.stderr || res.stdout || "(no output)"}`,
);
}
}
// The in-container topology path: the staged dir + the topology's basename. NEVER the host
// absolute — plan.stagePath came from containerStagePath (throws on absolute/'..').
return `${plan.stagePath}/${basename(hostTopologyPath)}`;
};
}
@@ -19,6 +19,7 @@
import { randomBytes } from "node:crypto";
import { assertNoForeignDaemon, type HermeticScaffold } from "./hermetic-env.js";
import { type ScenarioDaemon } from "./scenario-daemon.js";
import { makeContainerStageTopology, type StagingDocker } from "./scenario-container-stage.js";
// The ONE published-daemon procedure — imported, never re-derived (drift is what the
// module's parity fence exists to catch): explicit 0.0.0.0 bind + the bearer that bind
// demands, the unqualified `P:C` publish, and the explicit non-ephemeral host port.
@@ -48,8 +49,9 @@ export class ContainerDaemonError extends Error {
export interface SpawnContainerDaemonOptions {
/** The testbed image identity to run (manifest.image, e.g. "openrig-testbed:<gitSha>"). */
image: string;
/** Injected docker invoker — the container analogue of runRig's `node <rigBin>` seam. */
docker: (args: string[]) => Promise<DockerResult>;
/** Injected docker invoker — the container analogue of runRig's `node <rigBin>` seam. Extended to
* carry a step's stdin (the topology-stage tar-pipe); see StagingDocker for the dual-exit contract. */
docker: StagingDocker;
/** The daemon port INSIDE the container (default CONTAINER_PORT = 7433). */
containerPort?: number;
/** Override the published host port. Default L3_HOST_PORT (19433) — EXPLICIT + non-ephemeral;
@@ -159,5 +161,9 @@ export async function spawnContainerDaemon(
await docker(["rm", "-f", containerId]).catch(() => {});
scaffold.cleanup();
},
// L6 STEP-0 — container-mode path translation: stage the topology's dir into the container and
// return its in-container path, so `rig up` never receives a host-absolute path the container
// daemon cannot read. buildRealDeps/the pipeline consumes this before `up`; host-mode omits it.
stageTopology: makeContainerStageTopology(containerId, docker),
};
}
@@ -50,6 +50,13 @@ export interface ScenarioDaemon {
restart: () => Promise<void>;
/** Stop the daemon (via the shipped `rig daemon stop`) and remove the scaffold. */
stop: () => Promise<void>;
/**
* L6 STEP-0 — translate a HOST topology path to a path the daemon can read. Host-mode omits this
* (identity: the daemon reads the host path directly). CONTAINER-mode implements it by staging the
* topology's directory INTO the container and returning the in-container path, so `rig up` is never
* handed a host-absolute path a container daemon cannot resolve ("Source not found").
*/
stageTopology?: (hostTopologyPath: string) => Promise<string>;
}
export interface SpawnScenarioDaemonOptions {
@@ -251,10 +251,17 @@ export async function runScenarioFile(
mkdirSync(seatCwd, { recursive: true });
const daemon = await resolveScenarioDaemonSpawner(opts)(scaffold, opts);
try {
// L6 STEP-0 — container-mode translates the HOST topology path to the in-container staged path
// (host-mode has no stageTopology → the host path passes through unchanged). A stage/fence
// failure throws here (loud, named) and the finally tears the container down — never a mystery
// "Source not found" from `rig up` reading a host path it cannot see inside the container.
const upTopologyPath = daemon.stageTopology
? await daemon.stageTopology(loaded.loaded.topologyPath)
: loaded.loaded.topologyPath;
const baseDeps = buildRealDeps({
daemon,
rigBin: opts.rigBin,
topologyPath: loaded.loaded.topologyPath,
topologyPath: upTopologyPath,
seatCwd,
...opts.deps,
// Container-mode stamps the image id onto every ledger row; host-mode (no
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import {
makeContainerStageTopology,
ContainerStageError,
type StagingDocker,
} from "./helpers/scenario-container-stage.js";
import { CONTAINER_STAGE_ROOT } from "./helpers/testbed-published-daemon.js";
// L6 STEP-0 — the container-mode topology-staging driver. It drives the shared module's plan
// (mkdir → extract-with-tar-stdin → fence) through the injected docker seam and returns the
// IN-CONTAINER topology path, never the host absolute. The fence targets the FILE (expectFile), so a
// shipped-nothing delivery — a masked tar failure that leaves an empty but readable+writable stage —
// is unrepresentable. Runs hermetically with a fake StagingDocker; the real-docker leg is host-side.
const CONTAINER = "c0ffeeb0ba";
const HOST_TOPO = "/host/fixtures/scenarios/topo-stub-baton.yaml";
const STAGE = `${CONTAINER_STAGE_ROOT}/topologies`;
/** A recording fake for the extended (stdin-carrying) docker seam. `fail(label)` returns a non-zero
* exit for a given step — including the tar-fails/docker-succeeds case, which the real invoker
* surfaces as a non-zero extract because it checks BOTH exits (a shell would mask it). */
function fakeStagingDocker(fail?: (label: string) => number) {
const calls: Array<{ args: string[]; stdinFrom?: string[] }> = [];
const label = (args: string[], stdinFrom?: string[]) =>
args.includes("mkdir") ? "mkdir" : stdinFrom ? "extract" : "fence";
const docker: StagingDocker = vi.fn(async (args, stdinFrom) => {
calls.push({ args, stdinFrom });
const l = label(args, stdinFrom);
const code = fail?.(l) ?? 0;
return { stdout: "", stderr: code ? `boom-${l}` : "", code };
});
return { docker, calls };
}
describe("makeContainerStageTopology — stage the topology dir into the container", () => {
it("drives mkdir → extract(tar stdin) → fence IN ORDER and returns the in-container path", async () => {
const { docker, calls } = fakeStagingDocker();
const containerPath = await makeContainerStageTopology(CONTAINER, docker)(HOST_TOPO);
expect(containerPath).toBe(`${STAGE}/topo-stub-baton.yaml`); // in-container, never the host absolute
expect(calls.map((c) => (c.args.includes("mkdir") ? "mkdir" : c.stdinFrom ? "extract" : "fence")))
.toEqual(["mkdir", "extract", "fence"]); // ordering is the contract
expect(calls[0]!.args).toEqual(["exec", CONTAINER, "mkdir", "-p", STAGE]);
// extract carries the tar-side argv as stdin — tar the HOST DIR (siblings ride along: culture.md, agents/)
expect(calls[1]!.args).toEqual(["exec", "-i", CONTAINER, "tar", "-C", STAGE, "-xf", "-"]);
expect(calls[1]!.stdinFrom).toEqual(["-C", "/host/fixtures/scenarios", "-cf", "-", "."]);
// the fence asserts the FILE arrived (expectFile), not just the mkdir'd dir
expect(calls[2]!.args.join(" ")).toContain(`test -r '${STAGE}/topo-stub-baton.yaml'`);
});
it("a masked tar failure (extract non-zero) fails LOUD + NAMED — not a silent empty stage", async () => {
const { docker } = fakeStagingDocker((l) => (l === "extract" ? 2 : 0));
await expect(makeContainerStageTopology(CONTAINER, docker)(HOST_TOPO))
.rejects.toThrow(/stage step 'extract' failed \(exit 2\).*boom-extract/s);
});
it("a fence failure (content did NOT arrive) fails LOUD + NAMED at the fence, not 3 steps later", async () => {
const { docker } = fakeStagingDocker((l) => (l === "fence" ? 1 : 0));
const p = makeContainerStageTopology(CONTAINER, docker)(HOST_TOPO);
await expect(p).rejects.toThrow(ContainerStageError);
await expect(p).rejects.toThrow(/stage step 'fence' failed \(exit 1\)/);
});
});