chore: sync the frontend template from a0fbafb to 5434760

Carries eight template commits: the provider sandbox actually running, release
admission to a named environment, the product feature manifest with its runtime
kill switch, architecture and documentation rules that match what is enforced,
the removability fixtures, and the browser, visual and performance evidence.

Product identity is unchanged. `package.json` keeps `tech-log-frontend` and the
catalog keeps the Tech Log naming; the home page was not in the delta. The
visual baselines are this product's own — the template's were excluded from the
transplant and these were regenerated here, where the only difference is the
platform overview's new product-feature section.

What this repository gains operationally: `config/runtime/{local,development,
staging,production}.json` with `FE-GATE-027` refusing an artifact whose runtime
document does not match the environment it is being admitted to, and
`FEATURE_OVERRIDES` for taking an installed feature out of service without a
rebuild.

Verified here: eight gates green, build green, visual 5/5, and 1,858 of 1,859
tests in the suites that do not need a sandbox — the one failure passes in
isolation and is a jsdom lazy-chunk timeout under parallel load. The provider
suites cannot run on this machine at all: `kernel.apparmor_restrict_unprivileged
_userns=1` makes `bwrap --unshare-net` fail, reproducible without any code from
either repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-15 21:34:19 +09:00
co-authored by Claude Opus 5
parent 325a2a0843
commit bdee07a93b
101 changed files with 3116 additions and 448 deletions
+181 -38
View File
@@ -1,6 +1,6 @@
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { constants } from "node:fs";
import { constants, readFileSync } from "node:fs";
import { cp, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -20,6 +20,7 @@ import {
} from "../../scripts/lib/ci-artifact-validator.ts";
import { writeCiGateLogAtomic } from "../../scripts/lib/ci-gate-log.ts";
import { linkFixtureNodeModules } from "../../scripts/lib/fixture-node-modules.ts";
import { copyReleaseEvidenceTree } from "../../scripts/lib/removal-fixture.ts";
import { captureCiCandidateArchive, verifyCiCandidateArchive } from "../../scripts/lib/ci-candidate-archive.ts";
import {
CANDIDATE_ARCHIVE_USAGE,
@@ -46,6 +47,14 @@ import {
RELEASE_CANDIDATE_MANIFEST_PATH,
} from "../../scripts/lib/release-candidate.ts";
/**
* Budget for the provider suites specifically. They spawn a systemd scope, a
* bubblewrap sandbox and a signing provider, and build a release candidate to
* do it; the 10s default is sized for pure-JS unit tests. Raising the global
* default instead would hide a genuinely hung test.
*/
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
const temporaryRoots: string[] = [];
let providerBaseRoot: string | undefined;
const sha256 = (value: Buffer | string) =>
@@ -756,6 +765,10 @@ describe("candidate archive and provider upload boundaries", () => {
},
});
const completionStarted = Date.now();
// Start recording before anything is asserted: the provider's whole life is
// shorter than one `systemctl show`, so the tree has to be sampled, not
// sampled once at whatever moment the assertions happen to arrive.
const tree = recordProviderProcessTree(execution.child.pid!);
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
const properties = showProviderUnit(unit);
expect(properties).toMatchObject({
@@ -775,10 +788,21 @@ describe("candidate archive and provider upload boundaries", () => {
await expect(readFile(path.join(cgroupRoot, "cpu.max"), "utf8")).resolves.toBe("100000 100000\n");
expect(readProviderUnitMetadata(unit)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
expect(showProcessArguments(execution.child.pid)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
const cgroupPids = await readCgroupPids(cgroupRoot);
const processArguments = cgroupPids.map((pid) => showProcessArguments(pid));
const directChildPids = await waitForDirectProviderChildren(execution.child.pid!);
const directChildArguments = directChildPids.map((pid) => showProcessArguments(pid));
const reportIdentity = await lstat(fixture.reportPath);
const result = await execution.completion;
tree.stop();
expect(result.code, result.stderr).toBe(0);
expect(result.stdout).not.toContain(credential);
expect(result.stderr).not.toContain(credential);
// Everything below reads the recording of the whole run rather than a live
// snapshot. The sandbox exists for a few hundred milliseconds; asserting
// while it runs meant racing it, and the assertions are about what the run
// contained, not about what a particular instant looked like.
const cgroupPids = tree.cgroupPids();
const processArguments = tree.cgroupArguments();
const directChildPids = tree.directChildPids();
const directChildArguments = tree.directChildArguments();
const observedArguments = [
showProcessArguments(execution.child.pid),
...directChildArguments,
@@ -788,18 +812,15 @@ describe("candidate archive and provider upload boundaries", () => {
expect(directChildArguments.filter((arguments_) => arguments_.includes("/usr/bin/systemd-run"))).toHaveLength(1);
expect(directChildArguments.filter((arguments_) => arguments_.includes("provider-raw-guardian.ts"))).toHaveLength(1);
expect(processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts"))).toHaveLength(1);
expect(processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length)
.toBeGreaterThan(0);
expect(
processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length,
`sandbox never observed in the scope; recorded: ${processArguments.join(" | ")}`,
).toBeGreaterThan(0);
const infrastructureArguments = [...directChildArguments, ...processArguments].filter((arguments_) =>
/provider-(?:scope-wrapper|raw-guardian)|systemd-run|bwrap|prlimit/u.test(arguments_),
);
expect(infrastructureArguments.join("\n")).not.toContain(command);
expect(processArguments.filter((arguments_) => arguments_.includes(providerScript))).toHaveLength(1);
const reportIdentity = await lstat(fixture.reportPath);
const result = await execution.completion;
expect(result.code, result.stderr).toBe(0);
expect(result.stdout).not.toContain(credential);
expect(result.stderr).not.toContain(credential);
expect(Date.now() - completionStarted).toBeLessThan(4_000);
await expectProviderUnitGone(unit);
await expect(lstat(cgroupRoot)).rejects.toMatchObject({ code: "ENOENT" });
@@ -807,7 +828,7 @@ describe("candidate archive and provider upload boundaries", () => {
expect(directChildPids.every((pid) => !processExists(pid))).toBe(true);
await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(findProviderRawReferences(fixture.reportPath, reportIdentity)).resolves.toEqual([]);
}, 10_000);
}, PROCESS_HEAVY_TIMEOUT_MS);
it("kills and collects an active provider when its guardian dies", async () => {
const fixture = await createProviderFixture();
@@ -842,12 +863,15 @@ describe("candidate archive and provider upload boundaries", () => {
expect((await readdir(path.dirname(sealedPath))).filter((leaf) => leaf.includes(".guardian-")))
.toEqual([]);
// `providerWriter` is only a path; the retry has to materialise the script
// it names or the clean-retry claim is proven by a module-not-found error.
await writeFile(fixture.providerWriter, providerV2WriterSource());
const retried = runProviderSupervisor(fixture, {
command: `node ${JSON.stringify(fixture.providerWriter)}`,
sealedPath,
});
expect(retried.status, retried.stderr).toBe(0);
}, 15_000);
}, 20_000);
it("collects the whole provider scope when its supervisor dies", async () => {
const fixture = await createProviderFixture();
@@ -1272,9 +1296,17 @@ describe("verified promotion finalizer", () => {
"provider-verification.json": valid["promotion-verification.json"],
"promotion-verification.json": valid["provider-verification.json"],
};
// `{}` never reaches the digest comparison: it fails the report schema
// first, so this case asserted a decode error while claiming to cover the
// digest branch. The substitution has to be a structurally valid report
// that simply is not the one the verification records committed to.
const divergentReport = JSON.parse(
valid["vulnerability-report.json"].toString("utf8"),
) as Record<string, any>;
divergentReport.provider = "divergent-provider";
const reportMismatch = {
...valid,
"vulnerability-report.json": Buffer.from("{}\n"),
"vulnerability-report.json": jsonBytes(divergentReport),
};
const absent = { ...valid } as Partial<typeof valid>;
delete absent["provider-verification.json"];
@@ -1513,7 +1545,10 @@ describe("verified promotion finalizer", () => {
}),
).rejects.toThrow(/leaf.*identity|staging leaf/u);
await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n");
await expect(readdir(saved)).resolves.toEqual([]);
// A substituted leaf aborts the cleanup before anything is removed, so the
// promotion this call owned is still intact and a retry sees a coherent
// directory rather than a half-emptied one.
expect((await readdir(saved)).sort()).toEqual([...PROMOTED_FILE_NAMES].sort());
}, 30_000);
it("never deletes an unrelated leaf substituted after cleanup validation", async () => {
@@ -1678,28 +1713,31 @@ async function readCgroupPids(cgroupRoot: string): Promise<number[]> {
.trim().split("\n").filter(Boolean).map(Number);
}
async function waitForDirectProviderChildren(supervisorPid: number): Promise<number[]> {
for (let attempt = 0; attempt < 120; attempt += 1) {
const childrenPath = `/proc/${supervisorPid}/task/${supervisorPid}/children`;
const children = (await readFile(childrenPath, "utf8"))
.trim().split(/\s+/u).filter(Boolean).map(Number);
const arguments_ = children.flatMap((pid) => {
try {
return [showProcessArguments(pid)];
} catch (error) {
if (!processExists(pid)) return [];
throw error;
}
});
async function waitForRecordedProviderTree(
tree: ReturnType<typeof recordProviderProcessTree>,
providerScript: string,
): Promise<void> {
for (let attempt = 0; attempt < 400; attempt += 1) {
const children = tree.directChildArguments();
const members = tree.cgroupArguments();
// Every process the assertions below reason about. Returning as soon as
// some of them are present is what left bubblewrap out of the recording:
// it enters the scope a few milliseconds after the wrapper does.
if (
arguments_.some((value) => value.includes("/usr/bin/systemd-run")) &&
arguments_.some((value) => value.includes("provider-raw-guardian.ts"))
children.some((value) => value.includes("/usr/bin/systemd-run")) &&
children.some((value) => value.includes("provider-raw-guardian.ts")) &&
members.some((value) => value.includes("provider-scope-wrapper.ts")) &&
members.some((value) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(value)) &&
members.some((value) => value.includes(providerScript))
) {
return children;
return;
}
await delay(25);
await delay(10);
}
throw new Error("provider supervisor children were not simultaneously observable");
throw new Error(
"the provider run never contained a systemd-run child, a guardian child, " +
"a scope wrapper, a sandbox and the provider itself in its cgroup",
);
}
async function waitForDirectChildMatching(supervisorPid: number, pattern: string): Promise<number> {
@@ -1844,6 +1882,8 @@ function readProviderUnitMetadata(unitName: string): string {
function showProcessArguments(pid: number | undefined): string {
if (!pid) throw new Error("provider supervisor did not expose its PID");
const argv = readProcessArguments(pid);
if (argv !== null) return argv;
const result = spawnSync(
"/usr/bin/ps",
["-o", "args=", "-p", String(pid)],
@@ -1854,6 +1894,102 @@ function showProcessArguments(pid: number | undefined): string {
return result.stdout.trim();
}
/**
* Reads a process's argv straight from `/proc`.
*
* Spawning `ps` per pid costs milliseconds each, and the provider sandbox now
* completes a whole run in well under a second — the observation was losing a
* race against the thing it was observing. Returns null for a process that is
* already gone, which the sampler treats as "nothing more to record".
*/
function readProcessArguments(pid: number): string | null {
try {
return readFileSync(`/proc/${pid}/cmdline`, "utf8")
.split("\0")
.filter(Boolean)
.join(" ")
.trim();
} catch {
return null;
}
}
/**
* Records the provider's process tree for the whole life of the run.
*
* The assertions below are about what the sandbox looked like while it was
* running, and a single snapshot taken afterwards can only ever be a guess at
* that. Sampling from the moment the supervisor starts turns "did we look at
* the right instant?" into "what did this run actually contain?".
*/
function recordProviderProcessTree(supervisorPid: number) {
const directChildren = new Map<number, string>();
const cgroupMembers = new Map<number, string>();
let stopped = false;
const remember = (into: Map<number, string>, pid: number): void => {
if (into.has(pid)) return;
const argv = readProcessArguments(pid);
if (argv !== null && argv.length > 0) into.set(pid, argv);
};
const childrenOf = (pid: number): number[] => {
try {
return readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8")
.trim()
.split(/\s+/u)
.filter(Boolean)
.map(Number);
} catch {
return [];
}
};
/**
* Scope membership is read from the process itself rather than from
* `systemctl show`. Waiting for the unit to be described before watching its
* cgroup meant bubblewrap had usually already exited by the time the first
* sample was taken — the recording missed exactly the process the assertions
* are about.
*/
const inProviderScope = (pid: number): boolean => {
try {
return readFileSync(`/proc/${pid}/cgroup`, "utf8").includes("ca-provider-");
} catch {
return false;
}
};
const sample = (): void => {
if (stopped) return;
const direct = childrenOf(supervisorPid);
for (const pid of direct) remember(directChildren, pid);
// Walk the whole subtree: the scope wrapper, bubblewrap and the provider
// itself sit below systemd-run, not beside it.
const pending = [...direct];
const seen = new Set(direct);
while (pending.length > 0 && seen.size < 512) {
const pid = pending.pop()!;
if (inProviderScope(pid)) remember(cgroupMembers, pid);
for (const child of childrenOf(pid)) {
if (seen.has(child)) continue;
seen.add(child);
pending.push(child);
}
}
};
const timer = setInterval(sample, 5);
timer.unref();
sample();
return Object.freeze({
stop() {
stopped = true;
clearInterval(timer);
},
directChildPids: () => [...directChildren.keys()],
directChildArguments: () => [...directChildren.values()],
cgroupPids: () => [...cgroupMembers.keys()],
cgroupArguments: () => [...cgroupMembers.values()],
});
}
async function startLoopbackCanary(root: string): Promise<Readonly<{
child: ChildProcess;
marker: string;
@@ -2128,10 +2264,17 @@ async function ensureProviderBaseFixture(): Promise<string> {
return ![".release", "artifacts", "dist", "node_modules"].includes(first ?? "");
},
});
await cp(path.join(sourceRoot, "artifacts"), path.join(root, "artifacts"), {
recursive: true,
});
await rm(path.join(root, "artifacts/release"), { recursive: true, force: true });
// The release evidence, minus the trace and Storybook trees. Copying the
// whole `artifacts/` directory pulled ~28MB of test output into every
// provider fixture; sharing the copier with the removal fixture is also what
// makes both fixtures contain the same evidence.
// `artifacts/release` travels with the fixture rather than being deleted and
// rebuilt. Supply-chain generation runs before the candidate is created and
// validates the release evidence paths, so deleting them made that step
// depend on a previous local run having left them behind — which is why this
// fixture only worked in a workspace that had already built a candidate. The
// build chain overwrites them anyway.
await copyReleaseEvidenceTree(sourceRoot, root);
await linkFixtureNodeModules(root, sourceRoot);
const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], {
cwd: sourceRoot,