Founder-ruled engine-independent leg (2026-08-21 ~18:40Z; containers land 5.3, this fix lands now). Root cause on record in the scoping report: the L6 runbook's inline execFile invoker predated the stdinFrom tar-pipe contract (9d2f1f2cc) and ignored it — an open never-fed pipe stdin left the in-container `tar -xf -` blocked on read forever (row 42576855, the 08-12 seven-minute hang). makeRealStagingDocker implements the contract as committed, tested code: - no stdinFrom → stdin IGNORED (closed), so a stdin-reading child terminates on EOF (the hang class dies even on a misused step); - stdinFrom → two processes, producer piped into consumer with EOF forwarded, DUAL-EXIT checked (a failing tar into a succeeding exec no longer masks an empty stage), EPIPE on the wiring swallowed and the producer's death reported; - consumer-exits-early → producer killed and the step FAILS named (this invoker's own first cut hung exactly there — the producer's stdout backed up against a pipe nobody drained; the RED test caught it at 20s); - every step bounded by a timeout (default 120s) that kills both and returns a NAMED failure, never an unbounded wait. The runbook now IMPORTS the helper — an invoker inlined in a document drifts behind the code contract with nothing to fail. 8/8 hermetic (sh stand-ins, no engine) + 19/19 adjacent container suites; typecheck clean. NO live containment claim: the real-engine proof (docker vs Apple container exec-stdin semantics) explicitly waits on the 5.3 engine-substrate ruling, and the helper's docs say so. Claude-Session: https://claude.ai/code/session_01DWhNDDavuNoFWK8ZrYtSdH (cherry picked from commit 1f6d82c71df86d8fba149c0cfc99d18bc003baaa)
8.7 KiB
L6 — the 51-02 runner drives a REAL scenario through the container (container-mode e2e) + host-mode parity
Runs host-side, after L3 (needs the built image + a container-local daemon that boots). This is the
live proof of the 51-04 step-3 mechanism: the runner's container-mode opt-in stands the
scenario-local daemon up INSIDE the testbed image (by manifest identity), drives the SAME shipped
scenario through the SAME host-side reads pointed at the container's published port, and stamps the
image manifest id onto every results-ledger row. It also carries the L4 hermetic fail-closed leg's
step-3 form (the grounding note in L4-hermetic-fail-closed.md points here) and the host-mode
byte-intact parity check that closes the additive-opt-in fence behaviorally.
The step-3 pieces this exercises are unit-proven in the VM (test/scenario-container.test.ts,
test/scenario-daemon-mode.test.ts) with an INJECTED docker runner; L6 is where the REAL docker
invoker + a live container replace the injection — the leg no source-only proof can give.
Topology of a container-mode run (what talks to what)
- The daemon runs inside the container (
rig daemon starton the container port, container-local HOME/DB, no mounts). - The host-side
rigreads/writes (the shipped CLI subprocesses the runner already uses) run on the HOST againstOPENRIG_URL = http://127.0.0.1:<published port>— the adapter self-sets that URL to its OWN container (never a foreign target). So the host still needs the built CLI bin for the READ side.
Prerequisites
# L0 resolved the base + stub-assets slots; the build verb produced the image + manifest.
scripts/build-testbed-image.sh # -> dist/testbed-image/manifest.json
GIT_SHA="$(git rev-parse HEAD)"; IMAGE="openrig-testbed:${GIT_SHA}"
EVID="dist/testbed-image/evidence/${GIT_SHA}"; mkdir -p "${EVID}"
# Built HOST rig bin for the read side (the same bin run-scenarios.mjs uses).
npm run build -w packages/cli && npm run build -w packages/daemon
test -x packages/cli/dist/bin-wrapper.js || { echo "BLOCKER: build the CLI bin first"; exit 1; }
command -v docker >/dev/null || { echo "BLOCKER: docker not on PATH"; exit 1; }
L6.1 — drive a real scenario through container-mode (the runner as a host-side wrapper)
The operator runs the runner in container-mode by supplying the step-3 daemon spawner + imageId.
This wiring composes only already-tested helpers (spawnContainerDaemon, runScenarioFile,
withImageId, makeRealStagingDocker). The REAL docker invoker is a COMMITTED helper, imported —
never re-inlined here. (The prior inline execFile invoker predated the stdinFrom tar-pipe
contract and ignored it, leaving the in-container tar -xf - an open never-fed stdin: the 08-12
seven-minute extraction hang, row 42576855. The committed helper feeds the pipe, checks BOTH exits,
and bounds every step with a timeout so a stall is a NAMED failure — proven hermetically in
test/staging-docker-invoker.test.ts. That proof covers the invoker only; live containment on a
real engine still rides THIS leg.) Run it host-side with the tsx loader:
node --import tsx - "$IMAGE" "$(jq -r .manifestDigest dist/testbed-image/manifest.json)" <<'RUN' | tee "${EVID}/L6-container.txt"
import { resolve } from "node:path";
import { runScenarioFile } from "./packages/daemon/test/helpers/scenario-pipeline.ts";
import { spawnContainerDaemon } from "./packages/daemon/test/helpers/scenario-container.ts";
import { makeRealStagingDocker } from "./packages/daemon/test/helpers/staging-docker-invoker.ts";
const [image, imageId] = process.argv.slice(2);
const RIG_BIN = resolve("packages/cli/dist/bin-wrapper.js");
const SCENARIO = resolve("packages/daemon/test/fixtures/scenarios"); // pick one committed scenario file below
// The committed real invoker: two-process tar pipe, dual-exit check, per-step timeout.
const docker = makeRealStagingDocker();
// Container-mode: inject the ScenarioDaemon-shaped container adapter + the manifest id.
const daemon = (scaffold /*, opts */) => spawnContainerDaemon(scaffold, { image, docker });
// COMMITTED scenario (the prior literal `scenario-collision.yaml` DOES NOT EXIST in the tree —
// operator-caught at the L4/L6 re-entry). L6 proves the container RUNNER, so it drives a real
// shipped scenario; override with L6_SCENARIO to run another COMMITTED file, never an improvised one.
const scenario = `${SCENARIO}/` + (process.env.L6_SCENARIO ?? "scenario-02-baton.yaml");
const baseEnv = { HOME: process.env.HOME, PATH: process.env.PATH, TERM: "xterm" };
const records = [];
const result = await runScenarioFile(scenario, {
rigBin: RIG_BIN, baseEnv, daemon, imageId,
deps: { appendRecord: (r) => records.push(r), normalizer: (_s, v) => v },
});
console.log("VERDICT:", result.verdict, "scenario:", result.scenario);
console.log("LEDGER:", JSON.stringify(records));
process.exit(result.verdict === "PASS" ? 0 : 1);
RUN
PASS: the scenario reaches its expected verdict driven against the in-container daemon, AND every
ledger row carries imageId == manifest.manifestDigest (grep the LEDGER: line). FAIL: the daemon
never comes up in the container / a read cannot reach the published port / a ledger row is missing the
image id. (Ground the exact expected verdict against the chosen scenario — a 51-03-dependent
cross-surface scenario is not expected green until its normalizer ships; pick a settled scenario.)
L6.2 — host-mode parity (the additive-opt-in fence, behaviorally)
Run the IDENTICAL scenario in host-mode (no daemon override, no imageId) and compare verdicts:
L6_MODE=host node --import tsx packages/daemon/scripts/run-scenarios.mjs \
packages/daemon/test/fixtures/scenarios/scenario-02-baton.yaml | tee "${EVID}/L6-host.txt"
PASS: the host-mode verdict for the same scenario matches the container-mode verdict from L6.1 —
container-mode changed nothing about the host-mode path (the 51-02 contract is byte-intact; the ledger
rows differ ONLY by the added imageId key in container-mode). FAIL: host-mode verdict diverges
from pre-51-04 for the same scenario (a fence breach — that would demand a PM 51-02 gate).
L6.3 — container-mode fail-closed (the L4 step-3 form)
Inject a FOREIGN daemon target into the base env and confirm the run HARD-REFUSES before standing any
container up (the container is not an excuse to weaken the DAEMON_TARGET guard — same as host-mode):
OPENRIG_URL='http://foreign-daemon.invalid:9999' node --import tsx - "$IMAGE" <<'RUN' 2>&1 | tee "${EVID}/L6-failclosed.txt"; RC=${PIPESTATUS[0]:-$?}
import { resolve } from "node:path";
import { runScenarioFile } from "./packages/daemon/test/helpers/scenario-pipeline.ts";
import { spawnContainerDaemon } from "./packages/daemon/test/helpers/scenario-container.ts";
const [image] = process.argv.slice(2);
const daemon = (scaffold) => spawnContainerDaemon(scaffold, { image, docker: async () => ({ stdout: "", stderr: "", code: 0 }) });
try {
await runScenarioFile(resolve("packages/daemon/test/fixtures/scenarios/scenario-02-baton.yaml"),
{ rigBin: resolve("packages/cli/dist/bin-wrapper.js"), baseEnv: process.env, daemon });
console.log("NO-REFUSAL"); process.exit(0);
} catch (e) { console.error("REFUSED: " + e.message); process.exit(7); }
RUN
PASS: non-zero exit + REFUSED: naming the foreign OPENRIG_URL — prepareHermeticEnv fail-closes
on the foreign target in baseEnv and NO container is created. FAIL: NO-REFUSAL / exit 0 (the
guard was bypassed in container-mode). This is the step-3 form of the L4 leg (the packaged-helper form
lives in L4-hermetic-fail-closed.md); either form is real — record which one the shipped surface ran.
Teardown + evidence
Container-mode tears its own container down (the adapter's stop = docker rm -f), so a clean run
leaves nothing. Sweep any strays from a failed run, then verdict on the captured bytes:
docker ps -aq --filter "ancestor=${IMAGE}" | xargs -r docker rm -f >/dev/null
{ grep -q 'VERDICT: PASS' "${EVID}/L6-container.txt" \
&& grep -q "$(jq -r .manifestDigest dist/testbed-image/manifest.json)" "${EVID}/L6-container.txt" \
&& grep -q 'REFUSED:' "${EVID}/L6-failclosed.txt" \
&& echo "VERDICT: PASS — container-mode drives a real scenario by image identity, ledger carries the manifest id, host-mode parity holds, fail-closed refuses" \
|| echo "VERDICT: FAIL / FORM-DEFERRED — see L6-*.txt"; } | tee "${EVID}/L6-verdict.txt"
Fences (binding): the scenario YAML is byte-unchanged (51-03 fence); host-mode stays byte-intact (L6.2 is the behavioral proof — any divergence is a PM 51-02 gate, not a fold); no real-HOME mounts; the image is selected BY MANIFEST IDENTITY and its digest is the ledger's cross-version key.