Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/guides/portable-bootstrap-qualification.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ available container capabilities, and any skips. Review the tested Install
script and Start skill before publishing the private environment. A local or
ordinary Linux-container result does not qualify Codex Cloud by itself.

## Negative controls in Docker CI

The hosted `docker-e2e` job also runs the `portable-bootstrap-negatives` scenario.
On the slim path, it checks that a missing key, a wrong key, an overlay sealed
under another key, a stale pinned image, a lockfile that no longer matches its
manifest and a corrupted dependency cache each stop the bootstrap without
changing a running app. It also checks that a second identical bootstrap leaves
no diff and does not reinstall. Each stage records its time and byte counts in a
value-free receipt. See
[the e2e harness guide](../../tests/e2e/README.md#portable-bootstrap-negative-controls)
for the stages and what they do not cover.

## Repeat maintenance

`scripts/maintain-codex-slim.sh` installs dependencies with
Expand Down
5 changes: 4 additions & 1 deletion scripts/portable-bootstrap-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
* v1:base64(iv):base64(tag):base64(ciphertext), AES-256-GCM, SHA-256(key text).
* The synthetic key and plaintext never become fixture files or CLI arguments.
*/
function encryptSynthetic(opts: { key: string; secret: string }): string {
export function encryptSynthetic(opts: {
key: string;
secret: string;
}): string {
const iv = randomBytes(12);
const cipher = createCipheriv(
"aes-256-gcm",
Expand Down
221 changes: 221 additions & 0 deletions tests/e2e-portable-bootstrap-controls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { describe, expect, test } from "bun:test";
import { createDecipheriv, createHash } from "node:crypto";
import { encryptSynthetic } from "../scripts/portable-bootstrap-smoke.ts";
import {
type AppIdentity,
checkStageExpectation,
classifyAppTransition,
containsForbiddenValue,
PORTABLE_BOOTSTRAP_EXPECTATIONS,
PORTABLE_BOOTSTRAP_RECEIPT_SCHEMA,
PORTABLE_BOOTSTRAP_STAGES,
renderPortableBootstrapReceipt,
type StageReceipt,
unexpectedWorktreeChanges,
} from "./e2e/portable-bootstrap-controls.ts";
import { ALL_SCENARIOS } from "./e2e/run.ts";
import { E2E_DURATION_HINTS_SECONDS } from "./e2e/shard.ts";

const running = (id: string, startedAt = "t1"): AppIdentity => ({
id,
startedAt,
running: true,
});

function receipt(
stage: (typeof PORTABLE_BOOTSTRAP_STAGES)[number]
): StageReceipt {
const expected = PORTABLE_BOOTSTRAP_EXPECTATIONS[stage];
return {
stage,
outcome: expected.outcome,
app: expected.app,
durationMs: 1200,
stdoutBytes: 10,
stderrBytes: 20,
trackedBytes: 4096,
cacheBytes: expected.app === "absent" ? null : 128,
cacheVolumes: 1,
};
}

const complete = () => PORTABLE_BOOTSTRAP_STAGES.map(receipt);

describe("portable bootstrap negative controls", () => {
test("is registered for the hosted Docker tier with a shard hint", () => {
const scenario = ALL_SCENARIOS.find(
(entry) => entry.name === "portable-bootstrap-negatives"
);
expect(scenario?.tier).toBe("docker");
expect(scenario?.requiresExplicitSelection).toBeUndefined();
expect(
Object.hasOwn(E2E_DURATION_HINTS_SECONDS, "portable-bootstrap-negatives")
).toBe(true);
});

test("every refusal leaves the consumer absent or unchanged", () => {
for (const stage of PORTABLE_BOOTSTRAP_STAGES) {
const expected = PORTABLE_BOOTSTRAP_EXPECTATIONS[stage];
expect(stage.startsWith("refuse-")).toBe(expected.outcome === "refused");
if (expected.outcome === "refused") {
expect(["absent", "unchanged"]).toContain(expected.app);
}
}
expect(PORTABLE_BOOTSTRAP_EXPECTATIONS["bootstrap-repeat"].app).toBe(
"unchanged"
);
});

test("classifies consumer identity by container ID and start time", () => {
expect(classifyAppTransition({ before: null, after: null })).toBe("absent");
expect(classifyAppTransition({ before: null, after: running("a") })).toBe(
"created"
);
expect(
classifyAppTransition({ before: running("a"), after: running("a") })
).toBe("unchanged");
expect(
classifyAppTransition({ before: running("a"), after: running("b") })
).toBe("replaced");
// A restart in place is not "unchanged": the refusal touched the consumer.
expect(
classifyAppTransition({
before: running("a"),
after: running("a", "t2"),
})
).toBe("replaced");
expect(classifyAppTransition({ before: running("a"), after: null })).toBe(
"removed"
);
});

test("checks exit, consumer transition and liveness per stage", () => {
const ok = {
stage: "refuse-stale-image" as const,
exitCode: 1,
timedOut: false,
transition: "unchanged" as const,
running: true,
};
expect(checkStageExpectation(ok)).toBeNull();
expect(checkStageExpectation({ ...ok, exitCode: 0 })).toBe(
"expected refused, observed bootstrapped"
);
expect(checkStageExpectation({ ...ok, transition: "replaced" })).toBe(
"expected consumer unchanged, observed replaced"
);
expect(checkStageExpectation({ ...ok, running: false })).toBe(
"consumer must be running after the stage"
);
expect(checkStageExpectation({ ...ok, timedOut: true })).toBe(
"command timed out"
);
expect(
checkStageExpectation({
stage: "refuse-missing-key",
exitCode: 1,
timedOut: false,
transition: "absent",
running: false,
})
).toBeNull();
expect(
checkStageExpectation({
stage: "bootstrap-repeat",
exitCode: 0,
timedOut: false,
transition: "replaced",
running: true,
})
).toBe("expected consumer unchanged, observed replaced");
});

test("reports worktree paths outside the allowed set", () => {
const porcelain = [
"?? .hack/.gitignore",
" M package.json",
"R old.txt -> new.txt",
"",
].join("\n");
expect(unexpectedWorktreeChanges({ porcelain: "", allowed: [] })).toEqual(
[]
);
expect(
unexpectedWorktreeChanges({
porcelain,
allowed: [".hack/.gitignore", "package.json", "new.txt"],
})
).toEqual([]);
expect(
unexpectedWorktreeChanges({
porcelain,
allowed: [".hack/.gitignore"],
})
).toEqual(["new.txt", "package.json"]);
});

test("detects forbidden values and ignores empty entries", () => {
expect(
containsForbiddenValue({ texts: ["ok", "has-key-x"], forbidden: ["key"] })
).toBe(true);
expect(
containsForbiddenValue({ texts: ["ok"], forbidden: ["", "absent"] })
).toBe(false);
});

test("renders a complete value-free receipt", () => {
const parsed: unknown = JSON.parse(
renderPortableBootstrapReceipt({ stages: complete(), imageBytes: 7 })
);
expect(parsed).toEqual({
schema: PORTABLE_BOOTSTRAP_RECEIPT_SCHEMA,
imageBytes: 7,
stages: complete(),
});
});

test("refuses incomplete, reordered or open-ended receipts", () => {
const render =
(stages: readonly unknown[], imageBytes: unknown = 1) =>
() =>
renderPortableBootstrapReceipt({ stages, imageBytes });
const message = /Invalid portable bootstrap receipt/;
expect(render(complete().slice(1))).toThrow(message);
expect(render(complete().reverse())).toThrow(message);
expect(render(complete(), -1)).toThrow(message);
expect(render(complete(), 1.5)).toThrow(message);
const mutate = (patch: Record<string, unknown>) =>
complete().map((entry, index) =>
index === 4 ? { ...entry, ...patch } : entry
);
expect(render(mutate({ outcome: "skipped" }))).toThrow(message);
expect(render(mutate({ app: "restarted" }))).toThrow(message);
expect(render(mutate({ durationMs: -1 }))).toThrow(message);
expect(render(mutate({ stdoutBytes: 0.5 }))).toThrow(message);
expect(render(mutate({ cacheBytes: "128" }))).toThrow(message);
expect(render(mutate({ detail: "free text" }))).toThrow(message);
const missing = complete().map(({ cacheVolumes: _, ...entry }) => entry);
expect(render(missing)).toThrow(message);
expect(render([...complete().slice(0, -1), null])).toThrow(message);
});

test("synthetic env values use the project v1 envelope", () => {
const key = "synthetic-key";
const sealed = encryptSynthetic({ key, secret: "synthetic-value" });
const [prefix, iv, tag, ciphertext] = sealed.split(":");
expect(prefix).toBe("v1");
const decipher = createDecipheriv(
"aes-256-gcm",
createHash("sha256").update(key).digest(),
Buffer.from(iv ?? "", "base64")
);
decipher.setAuthTag(Buffer.from(tag ?? "", "base64"));
expect(
Buffer.concat([
decipher.update(Buffer.from(ciphertext ?? "", "base64")),
decipher.final(),
]).toString("utf8")
).toBe("synthetic-value");
expect(sealed).not.toContain("synthetic-value");
});
});
58 changes: 58 additions & 0 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,64 @@ data survive that cleanup. The canary is then removed separately. PostgreSQL use
fixture-only trust on this unexposed network. This qualifies the actual execution
host, not Codex Cloud capabilities, browser access or native VM support elsewhere.

## Portable bootstrap negative controls

`portable-bootstrap-negatives` (HACK-1143) adds negative controls to the portable
bootstrap fixture. It runs the selected CLI in slim mode with cached `alpine:3.20`
pinned by immutable ID, `network_mode: none`, no published ports and no pulls.
A `deps` producer fills a `locked-v1` dependency cache. Its install is frozen: it
fails when the lockfile's manifest digest no longer matches `package.json`, and the
workspace is mounted read-only. The `app` consumer mounts the cache read-only. It
copies the cache artifact only after a synthetic encrypted token, scoped to `app`,
matches the expected digest, so a loaded artifact proves env injection without the
scenario reading the value.

Every stage runs `hack up --detach app` and must give this result:

| Stage | Change before the stage | Required result |
| --- | --- | --- |
| `refuse-missing-key` | no key | refused; no owned container, network or volume; only the managed `.hack/.gitignore` may appear |
| `refuse-wrong-key` | wrong key | refused; nothing created; no worktree diff |
| `refuse-wrong-overlay` | `--env drift`, an overlay sealed under another key | refused; nothing created; no worktree diff |
| `bootstrap-cold` | correct key | consumer created; one initialization; one ready cache; no diff |
| `bootstrap-repeat` | none | same consumer ID and start time; same cache; still one initialization; same index; no diff |
| `refuse-wrong-key-running` | wrong key | refused; consumer unchanged |
| `refuse-stale-image` | both services pinned to an absent image ID | refused; consumer unchanged; the stale ID is not pulled and the cached tag keeps its ID |
| `refuse-frozen-lockfile` | `package.json` no longer matches the lockfile | refused; consumer unchanged; lockfile bytes kept; the new cache has an attempt but no ready marker |
| `bootstrap-changed-lockfile` | committed lockfile revision | consumer replaced on a new cache initialized once; the previous ready cache is kept |
| `refuse-corrupted-cache` | the current ready marker is overwritten | refused; consumer unchanged; no reinstall; the corrupted marker is not rewritten |
| `bootstrap-new-generation` | `hack.dependencies.cache-generation` bumped | consumer replaced on a new cache; the corrupted cache is kept for inspection |

Unchanged means the same full container ID and `State.StartedAt`. CLI output and
Git-tracked files must never contain the keys or token values, and the scenario
reads only fixed container fields because the consumer's environment holds the
token. Cleanup checks exact owner and Compose project labels, and an adjacent
canary volume must survive it.

The scenario ends with one value-free line, `portable-bootstrap receipt {...}`. For
each stage it records the outcome, the consumer transition, the command duration,
stdout/stderr bytes, Git-tracked fixture bytes, the bytes in the consumer's mounted
cache and the number of owned cache volumes. It also records the pinned image
size. It contains no env values, keys, resource IDs, image IDs or paths;
`tests/e2e/portable-bootstrap-controls.ts` refuses any other shape.

```sh
HACK_E2E_CLI_BIN=./dist/hack HACK_E2E_DOCKER=1 HACK_E2E_REQUIRE_DOCKER=1 bun tests/e2e/run.ts --only=portable-bootstrap-negatives
```

Two local tests need no Docker engine. `tests/portable-bootstrap-env-refusal.test.ts`
runs the source CLI on the same fixture against a recording Docker stub: a missing
key, a wrong key and a wrong overlay key must fail before any Compose `create`,
`run`, `start` or `up`, and the correct key must reach the `deps` bootstrap first.
`tests/e2e-portable-bootstrap-controls.test.ts` covers the stage expectations,
consumer classification, worktree diff check and receipt validation.

This does not cover WU12 storage: mount and free-space admission, generation holds,
reclamation and archives. Old cache volumes are kept, never reclaimed. A stale
image here is a pinned ID that is absent locally, not a baked-tool manifest
mismatch. A pass qualifies the hosted Linux Docker runner, not native VM support or
a cloud provider.

## Pattern fixture qualification

`pattern-p01-dependency-bootstrap` (HACK-1145), `pattern-p02-portable-toolchain`
Expand Down
Loading
Loading