Skip to content

Commit fb90fa3

Browse files
committed
Phase 2: docker run argv construction, verified launching the real image
INT-CONTAINER-RUNTIME-CONTRACT / CONST-ISOLATION-CONTAINER-PER-JOB. buildDockerRunArgs constructs the `docker run` argv as an explicit array -- never a shell string, so no interpolation and no injection. Every isolation flag is fixed and load-bearing: --rm --init --cap-drop=ALL --security-opt no-new-privileges --pids-limit=512 --shm-size=1g, plus --memory/--cpus. --ipc=host and --privileged are asserted ABSENT. The env is an explicit -e NAME=VALUE allowlist built from buildContainerEnv's closed map: no bare `-e NAME` (which would inherit the host value), no --env-file, undefined values skipped rather than passed empty. /job/pi is mounted :ro (the agent cannot rewrite its instructions), /workspace is the only writable mount. Verified end to end: the constructed argv launched the real pi-job image with all flags and the runner ran to its config check (exit 2 on the missing /job/prompt.md), proving the argv is correct against a real container, not just asserted in a unit test. Five worker modules now built and each verified against real infrastructure (real git with hostile objects, real Valkey, the real image): exit-code, env-allowlist, materialize, budget, docker-run. The BullMQ Worker wrapper + 30-min timeout, GitHub token minting, and branch-protection precondition follow -- the first needs the wiring research in flight, the latter two need a GitHub App to verify rather than guess.
1 parent b2bbced commit fb90fa3

2 files changed

Lines changed: 132 additions & 0 deletions

File tree

worker/src/docker-run.mjs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* INT-CONTAINER-RUNTIME-CONTRACT. Construct the `docker run` argv for one job container.
3+
*
4+
* Every flag here is the enforcement surface of CONST-ISOLATION-CONTAINER-PER-JOB -- pi has no
5+
* permission system, so the container is the only real control. The argv is built as an explicit
6+
* array (never a shell string): no interpolation, no injection, and the env allowlist is passed
7+
* with explicit `-e NAME` where the value is read from the argv env map, never `--env-file` and
8+
* never a host pass-through.
9+
*/
10+
11+
/** The fixed isolation flags. Not configurable -- these ARE the boundary. */
12+
export const ISOLATION_FLAGS = [
13+
"--rm", // ephemeral: gone after the run
14+
"--init", // reap zombies (Chromium spawns many); node is PID 1 and does not reap
15+
"--cap-drop=ALL", // pi would otherwise inherit the launching user's capabilities
16+
"--security-opt",
17+
"no-new-privileges",
18+
"--pids-limit=512", // bound a fork bomb (UNVERIFIED figure; measured headroom ~4.5x, see spec)
19+
"--shm-size=1g", // Chromium OOMs on the default 64MB /dev/shm; NOT --ipc=host (shares host ns)
20+
];
21+
22+
/**
23+
* Build the full `docker run` argv (excluding the leading "docker").
24+
*
25+
* @param image pinned job image tag/digest
26+
* @param env the closed env map from buildContainerEnv -- passed as explicit -e NAME=VALUE
27+
* @param jobPiDir host path to the materialised .pi/ (mounted /job/pi:ro)
28+
* @param workspace host path to the fresh clone / local folder (mounted /workspace:rw)
29+
* @param name container name (for `docker stop` at the timeout)
30+
* @param memory e.g. "4g"; cpus e.g. "2"
31+
* @param extraFlags escape hatch for a Linux-only --user uid:gid on a bind-mounted local folder
32+
*/
33+
export function buildDockerRunArgs({
34+
image,
35+
env,
36+
jobPiDir,
37+
workspace,
38+
name,
39+
memory = "4g",
40+
cpus = "2",
41+
extraFlags = [],
42+
}) {
43+
if (!image) throw new Error("docker run: image is required");
44+
if (!name) throw new Error("docker run: container name is required");
45+
if (!workspace) throw new Error("docker run: workspace mount is required");
46+
47+
const args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}`, ...extraFlags];
48+
49+
// Explicit env allowlist. Each entry is `-e NAME=VALUE`, built from the closed map -- so a
50+
// stray host variable cannot ride along (no bare `-e NAME` inheriting from the host, no
51+
// --env-file). Undefined values are skipped, never passed as an empty string.
52+
for (const [k, v] of Object.entries(env ?? {})) {
53+
if (v === undefined || v === null) continue;
54+
args.push("-e", `${k}=${v}`);
55+
}
56+
57+
// /job is read-only (INT-CONTAINER-JOB-INPUTS): the agent cannot rewrite its own instructions.
58+
// /workspace is the only writable mount.
59+
if (jobPiDir) args.push("-v", `${jobPiDir}:/job/pi:ro`);
60+
args.push("-v", `${workspace}:/workspace`);
61+
62+
args.push(image);
63+
return args;
64+
}

worker/test/docker-run.test.mjs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import { buildDockerRunArgs, ISOLATION_FLAGS } from "../src/docker-run.mjs";
4+
5+
const base = {
6+
image: "pi-job:pinned",
7+
env: { PI_PROVIDER: "anthropic", ANTHROPIC_API_KEY: "sk-real" },
8+
jobPiDir: "/srv/jobs/abc/pi",
9+
workspace: "/srv/jobs/abc/workspace",
10+
name: "pi-job-abc",
11+
};
12+
13+
test("carries every isolation flag -- these ARE the boundary", () => {
14+
const args = buildDockerRunArgs(base);
15+
const s = args.join(" ");
16+
for (const flag of ["--rm", "--init", "--cap-drop=ALL", "no-new-privileges", "--pids-limit=512", "--shm-size=1g"]) {
17+
assert.ok(args.includes(flag) || s.includes(flag), `missing isolation flag: ${flag}`);
18+
}
19+
// The dangerous one we must NEVER add.
20+
assert.ok(!s.includes("--ipc=host"), "--ipc=host shares the host IPC namespace with adversarial code");
21+
assert.ok(!s.includes("--privileged"), "--privileged");
22+
});
23+
24+
test("/job is read-only, /workspace is writable", () => {
25+
const args = buildDockerRunArgs(base);
26+
assert.ok(args.includes("/srv/jobs/abc/pi:/job/pi:ro"), "/job/pi must be :ro");
27+
assert.ok(args.includes("/srv/jobs/abc/workspace:/workspace"), "/workspace must be writable");
28+
assert.ok(!args.some((a) => a.includes("/workspace:ro")), "/workspace must not be read-only");
29+
});
30+
31+
test("env is an explicit -e NAME=VALUE allowlist, never a pass-through or --env-file", () => {
32+
const args = buildDockerRunArgs(base);
33+
assert.ok(args.includes("-e") && args.includes("ANTHROPIC_API_KEY=sk-real"));
34+
assert.ok(!args.includes("--env-file"), "must never use --env-file");
35+
// No bare `-e NAME` (which would inherit the host value) -- every -e is followed by NAME=VALUE.
36+
for (let i = 0; i < args.length; i++) {
37+
if (args[i] === "-e") assert.match(args[i + 1], /=/, `bare -e ${args[i + 1]} would inherit from host`);
38+
}
39+
});
40+
41+
test("an undefined env value is skipped, not passed as empty", () => {
42+
const args = buildDockerRunArgs({ ...base, env: { PI_MODEL: "m", GITHUB_TOKEN: undefined } });
43+
assert.ok(!args.some((a) => a.startsWith("GITHUB_TOKEN")), "absent token must not appear at all");
44+
});
45+
46+
test("a local-folder job can add a Linux-only --user via extraFlags", () => {
47+
const args = buildDockerRunArgs({ ...base, extraFlags: ["--user", "1000:1000"] });
48+
assert.ok(args.includes("--user") && args.includes("1000:1000"));
49+
});
50+
51+
test("refuses to build without image / name / workspace", () => {
52+
assert.throws(() => buildDockerRunArgs({ ...base, image: undefined }), /image/);
53+
assert.throws(() => buildDockerRunArgs({ ...base, name: undefined }), /name/);
54+
assert.throws(() => buildDockerRunArgs({ ...base, workspace: undefined }), /workspace/);
55+
});
56+
57+
test("ISOLATION_FLAGS is frozen intent -- the exact set the spec pins", () => {
58+
// A change here is a change to the security boundary and must be deliberate.
59+
assert.deepEqual(ISOLATION_FLAGS, [
60+
"--rm",
61+
"--init",
62+
"--cap-drop=ALL",
63+
"--security-opt",
64+
"no-new-privileges",
65+
"--pids-limit=512",
66+
"--shm-size=1g",
67+
]);
68+
});

0 commit comments

Comments
 (0)