fix: say why a sandbox failed to launch, and observe a run instead of an instant
The cgroup test read the live process tree with one `ps` per pid and asserted while the provider was running. That was a race it used to win only because the sandbox was slow; now a whole run finishes in a few hundred milliseconds and `systemctl show` alone costs longer than the thing it describes. It records the tree from `/proc` every 5ms and asserts on the recording once the run is over, because the assertions were always about what the run contained. That restructuring immediately paid for itself: the supervisor had been failing to launch the sandbox at all, and the test was dying on the observation before it ever checked the exit code. It could not say why, because the supervisor consumed the child's output solely to enforce a byte cap and then discarded it — `exit=1` and nothing else. It now keeps the lines the sandbox tooling itself emits (`bwrap:`, `prlimit:`, `systemd-run:`, `systemctl:`), which cannot carry provider credentials because the provider command and its secrets travel in the args file. The failure now reads: sandboxed external provider failed: exit=1; sandbox reported: bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted which is a host restriction — `kernel.apparmor_restrict_unprivileged_userns=1` — reproducible in two lines of shell containing none of this repository's code, and recorded in the ledger as such rather than carried as a product defect. Suites that spawn processes, build archives and sign evidence were given a 30s budget. The 10s default is sized for pure-JS unit tests; raising it globally would hide a genuinely hung test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
711d61e73f
commit
9ca5c3f668
@@ -10,6 +10,11 @@ import { ApplicationProvider } from "../src/presentation/providers/application-p
|
|||||||
import { SessionProvider } from "../src/presentation/providers/session-provider.tsx";
|
import { SessionProvider } from "../src/presentation/providers/session-provider.tsx";
|
||||||
import { ThemeProvider } from "../src/presentation/providers/theme-provider.tsx";
|
import { ThemeProvider } from "../src/presentation/providers/theme-provider.tsx";
|
||||||
import "../src/presentation/styles/theme.css";
|
import "../src/presentation/styles/theme.css";
|
||||||
|
import { resolveProductFeatures } from "../src/contracts/product-features.ts";
|
||||||
|
import {
|
||||||
|
COMPILED_PRODUCT_FEATURE_IDS,
|
||||||
|
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||||
|
} from "../src/features/installed-product-manifest.ts";
|
||||||
|
|
||||||
const preferences = new Map<string, unknown>();
|
const preferences = new Map<string, unknown>();
|
||||||
const application = createApplication({
|
const application = createApplication({
|
||||||
@@ -45,6 +50,16 @@ const application = createApplication({
|
|||||||
routeChunks: {},
|
routeChunks: {},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
// Storybook renders components, not a product: every declared feature is
|
||||||
|
// shown as active so a story is never blank because of a deployment switch.
|
||||||
|
productFeatures: {
|
||||||
|
getSnapshot: () =>
|
||||||
|
resolveProductFeatures(
|
||||||
|
COMPILED_PRODUCT_FEATURE_IDS,
|
||||||
|
INSTALLED_PRODUCT_FEATURE_IDS,
|
||||||
|
),
|
||||||
|
isActive: () => true,
|
||||||
|
},
|
||||||
runtimeCapabilities: {
|
runtimeCapabilities: {
|
||||||
getSnapshot: () =>
|
getSnapshot: () =>
|
||||||
Object.freeze(
|
Object.freeze(
|
||||||
|
|||||||
@@ -491,21 +491,51 @@ below names the defect, not the symptom.
|
|||||||
| `OPS-18` | architecture | `PARTIAL` | Generic presentation still reads the installed-feature registries. The rule freezes the exact set of modules doing so today; a new edge fails. Lifting the assembly into `bootstrap` is not done. |
|
| `OPS-18` | architecture | `PARTIAL` | Generic presentation still reads the installed-feature registries. The rule freezes the exact set of modules doing so today; a new edge fails. Lifting the assembly into `bootstrap` is not done. |
|
||||||
| `OPS-19` | documentation | `FIXED` | README and the manual accessibility checklist both claimed six routes while ten were registered, leaving four screens outside the declared manual review scope. The list is now derived from the route registry by `verify:documentation`. |
|
| `OPS-19` | documentation | `FIXED` | README and the manual accessibility checklist both claimed six routes while ten were registered, leaving four screens outside the declared manual review scope. The list is now derived from the route registry by `verify:documentation`. |
|
||||||
|
|
||||||
|
### Product feature selection (2026-08-15, second pass)
|
||||||
|
|
||||||
|
| id | disposition | what changed |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `OPS-20` | `FIXED` | Which features a build contains is now a declared manifest rather than five registries spreading a literal. `VITE_PRODUCT_FEATURES` narrows it at build time; a test fails if a new registry forgets to consult it. |
|
||||||
|
| `OPS-21` | `FIXED` | `FEATURE_OVERRIDES` in the runtime document takes an installed feature out of service without a rebuild. The router refuses its routes, not just the navigation, so a typed deep link cannot still mount it. |
|
||||||
|
| `OPS-22` | `FIXED` | Both inputs are subtractive by vocabulary: the override enum has no `ENABLED`, and a build-time selection naming a feature the source tree does not declare is refused rather than ignored. |
|
||||||
|
| `OPS-23` | `FIXED` | A sandbox that fails to launch now reports why. The supervisor consumed the child's output only to enforce a byte cap and discarded it, so a host restriction surfaced as an unexplained `exit=1`. Lines the sandbox tooling itself emits are kept; provider output is still discarded. |
|
||||||
|
|
||||||
|
An env var does **not** shrink the bundle, and the code says so. A static import
|
||||||
|
cannot be undone by a value, and making the import graph depend on a
|
||||||
|
configuration string is what §3.5 exists to prevent. Measured: `none` changes
|
||||||
|
the output by 58 bytes. Physical removal is FE-GATE-020's job.
|
||||||
|
|
||||||
|
### Host restriction discovered during this pass
|
||||||
|
|
||||||
|
`bwrap --unshare-net` no longer works on this machine:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ printf '%s\0' --unshare-net --ro-bind /usr /usr ... | bwrap --args 3 -- /bin/true
|
||||||
|
bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted
|
||||||
|
$ sysctl kernel.apparmor_restrict_unprivileged_userns
|
||||||
|
kernel.apparmor_restrict_unprivileged_userns = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
That reproduction contains none of this repository's code. Earlier in the same
|
||||||
|
session the identical sandbox ran to completion, so the restriction became
|
||||||
|
active partway through. While it holds, 16 of the 108 provider tests cannot run
|
||||||
|
here — they need a sandbox the kernel will not grant. They are not counted as
|
||||||
|
green and not counted as product defects; under a host that permits the
|
||||||
|
namespace the same file was 107/108.
|
||||||
|
|
||||||
### Still red after this pass
|
### Still red after this pass
|
||||||
|
|
||||||
`tests/unit/ci-artifact-contract.test.ts` → *applies effective aggregate cgroup
|
*applies effective aggregate cgroup limits without exposing command or
|
||||||
limits without exposing command or credentials*. It reads the live process tree
|
credentials* was rewritten. It used to read the live process tree with one
|
||||||
and cgroup of a running sandbox, and the supervisor now completes a whole run in
|
`ps` per pid and assert mid-run, which lost a race against a sandbox that now
|
||||||
well under a second while `systemctl show` and `ps` each cost hundreds of
|
completes in a few hundred milliseconds; it records the tree from `/proc` every
|
||||||
milliseconds, so the observation loses the race. It was already red before this
|
5ms and asserts on the recording after the run. That restructuring is also what
|
||||||
work and is not a product defect; the assertions it makes about cgroup limits
|
revealed the host restriction above — the supervisor had been failing to launch
|
||||||
and credential exposure are not currently proven by an automated run.
|
the sandbox and the test was dying on the observation first.
|
||||||
|
|
||||||
Two more time out at their 10s budget when the whole suite runs in parallel on
|
Tests that spawn processes, build archives and sign evidence were given a
|
||||||
a loaded machine and pass in isolation, repeatedly: *expires an uncommitted
|
30s budget instead of the 10s default sized for pure-JS unit tests. The default
|
||||||
lease only after cleaning every owned object* and *requires every external
|
was not raised: that would hide a genuinely hung test.
|
||||||
expected identity variable at the exact promotion CLI*. They are recorded as
|
|
||||||
environment-limited, not as green.
|
|
||||||
|
|
||||||
### FE-GATE-020 after this pass
|
### FE-GATE-020 after this pass
|
||||||
|
|
||||||
|
|||||||
@@ -373,7 +373,25 @@ async function waitForProvider(
|
|||||||
PROVIDER_MAX_OUTPUT_BYTES,
|
PROVIDER_MAX_OUTPUT_BYTES,
|
||||||
() => terminate("output"),
|
() => terminate("output"),
|
||||||
);
|
);
|
||||||
|
/**
|
||||||
|
* Lines the sandbox tooling itself emits, kept so a launch failure can say
|
||||||
|
* why. Everything else the child writes is provider output and may carry
|
||||||
|
* credentials, so it is counted and discarded as before.
|
||||||
|
*
|
||||||
|
* Without this a sandbox that never started reported only `exit=1`, and the
|
||||||
|
* actual cause — `bwrap: loopback: Failed RTM_NEWADDR: Operation not
|
||||||
|
* permitted` on a host with `kernel.apparmor_restrict_unprivileged_userns=1`
|
||||||
|
* — was invisible. That turned a host restriction into an unexplained
|
||||||
|
* product failure.
|
||||||
|
*/
|
||||||
|
const SANDBOX_DIAGNOSTIC = /^(?:bwrap|prlimit|systemd-run|systemctl):\s.*$/gmu;
|
||||||
|
const sandboxDiagnostics: string[] = [];
|
||||||
const capture = (chunk: Buffer | string): void => {
|
const capture = (chunk: Buffer | string): void => {
|
||||||
|
for (const line of String(chunk).matchAll(SANDBOX_DIAGNOSTIC)) {
|
||||||
|
if (sandboxDiagnostics.length < 8 && !sandboxDiagnostics.includes(line[0])) {
|
||||||
|
sandboxDiagnostics.push(line[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (termination) return;
|
if (termination) return;
|
||||||
outputLimiter.consume(chunk);
|
outputLimiter.consume(chunk);
|
||||||
};
|
};
|
||||||
@@ -427,7 +445,13 @@ async function waitForProvider(
|
|||||||
await collection;
|
await collection;
|
||||||
if (result.error) throw result.error;
|
if (result.error) throw result.error;
|
||||||
if (result.code !== 0 || result.signal !== null) {
|
if (result.code !== 0 || result.signal !== null) {
|
||||||
throw new Error(`sandboxed external provider failed: exit=${result.code ?? "none"}, signal=${result.signal ?? "none"}`);
|
throw new Error(
|
||||||
|
`sandboxed external provider failed: exit=${result.code ?? "none"}, ` +
|
||||||
|
`signal=${result.signal ?? "none"}` +
|
||||||
|
(sandboxDiagnostics.length > 0
|
||||||
|
? `; sandbox reported: ${sandboxDiagnostics.join("; ")}`
|
||||||
|
: ""),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (inputError) throw inputError;
|
if (inputError) throw inputError;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||||
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
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 { cp, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -47,6 +47,14 @@ import {
|
|||||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||||
} from "../../scripts/lib/release-candidate.ts";
|
} 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[] = [];
|
const temporaryRoots: string[] = [];
|
||||||
let providerBaseRoot: string | undefined;
|
let providerBaseRoot: string | undefined;
|
||||||
const sha256 = (value: Buffer | string) =>
|
const sha256 = (value: Buffer | string) =>
|
||||||
@@ -757,6 +765,10 @@ describe("candidate archive and provider upload boundaries", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const completionStarted = Date.now();
|
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 unit = await waitForProviderUnit("vulnerability", execution.child.pid);
|
||||||
const properties = showProviderUnit(unit);
|
const properties = showProviderUnit(unit);
|
||||||
expect(properties).toMatchObject({
|
expect(properties).toMatchObject({
|
||||||
@@ -776,10 +788,21 @@ describe("candidate archive and provider upload boundaries", () => {
|
|||||||
await expect(readFile(path.join(cgroupRoot, "cpu.max"), "utf8")).resolves.toBe("100000 100000\n");
|
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(readProviderUnitMetadata(unit)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
||||||
expect(showProcessArguments(execution.child.pid)).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 reportIdentity = await lstat(fixture.reportPath);
|
||||||
const processArguments = cgroupPids.map((pid) => showProcessArguments(pid));
|
const result = await execution.completion;
|
||||||
const directChildPids = await waitForDirectProviderChildren(execution.child.pid!);
|
tree.stop();
|
||||||
const directChildArguments = directChildPids.map((pid) => showProcessArguments(pid));
|
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 = [
|
const observedArguments = [
|
||||||
showProcessArguments(execution.child.pid),
|
showProcessArguments(execution.child.pid),
|
||||||
...directChildArguments,
|
...directChildArguments,
|
||||||
@@ -789,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("/usr/bin/systemd-run"))).toHaveLength(1);
|
||||||
expect(directChildArguments.filter((arguments_) => arguments_.includes("provider-raw-guardian.ts"))).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_) => arguments_.includes("provider-scope-wrapper.ts"))).toHaveLength(1);
|
||||||
expect(processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length)
|
expect(
|
||||||
.toBeGreaterThan(0);
|
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_) =>
|
const infrastructureArguments = [...directChildArguments, ...processArguments].filter((arguments_) =>
|
||||||
/provider-(?:scope-wrapper|raw-guardian)|systemd-run|bwrap|prlimit/u.test(arguments_),
|
/provider-(?:scope-wrapper|raw-guardian)|systemd-run|bwrap|prlimit/u.test(arguments_),
|
||||||
);
|
);
|
||||||
expect(infrastructureArguments.join("\n")).not.toContain(command);
|
expect(infrastructureArguments.join("\n")).not.toContain(command);
|
||||||
expect(processArguments.filter((arguments_) => arguments_.includes(providerScript))).toHaveLength(1);
|
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);
|
expect(Date.now() - completionStarted).toBeLessThan(4_000);
|
||||||
await expectProviderUnitGone(unit);
|
await expectProviderUnitGone(unit);
|
||||||
await expect(lstat(cgroupRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
await expect(lstat(cgroupRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
@@ -808,7 +828,7 @@ describe("candidate archive and provider upload boundaries", () => {
|
|||||||
expect(directChildPids.every((pid) => !processExists(pid))).toBe(true);
|
expect(directChildPids.every((pid) => !processExists(pid))).toBe(true);
|
||||||
await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" });
|
await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
await expect(findProviderRawReferences(fixture.reportPath, reportIdentity)).resolves.toEqual([]);
|
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 () => {
|
it("kills and collects an active provider when its guardian dies", async () => {
|
||||||
const fixture = await createProviderFixture();
|
const fixture = await createProviderFixture();
|
||||||
@@ -1693,39 +1713,31 @@ async function readCgroupPids(cgroupRoot: string): Promise<number[]> {
|
|||||||
.trim().split("\n").filter(Boolean).map(Number);
|
.trim().split("\n").filter(Boolean).map(Number);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForDirectProviderChildren(supervisorPid: number): Promise<number[]> {
|
async function waitForRecordedProviderTree(
|
||||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
tree: ReturnType<typeof recordProviderProcessTree>,
|
||||||
const childrenPath = `/proc/${supervisorPid}/task/${supervisorPid}/children`;
|
providerScript: string,
|
||||||
let listing: string;
|
): Promise<void> {
|
||||||
try {
|
for (let attempt = 0; attempt < 400; attempt += 1) {
|
||||||
listing = await readFile(childrenPath, "utf8");
|
const children = tree.directChildArguments();
|
||||||
} catch (error) {
|
const members = tree.cgroupArguments();
|
||||||
if (hasErrorCode(error, "ENOENT")) {
|
// Every process the assertions below reason about. Returning as soon as
|
||||||
throw new Error(
|
// some of them are present is what left bubblewrap out of the recording:
|
||||||
"provider supervisor exited before its children could be observed",
|
// it enters the scope a few milliseconds after the wrapper does.
|
||||||
{ cause: error },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const children = listing.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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (
|
if (
|
||||||
arguments_.some((value) => value.includes("/usr/bin/systemd-run")) &&
|
children.some((value) => value.includes("/usr/bin/systemd-run")) &&
|
||||||
arguments_.some((value) => value.includes("provider-raw-guardian.ts"))
|
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> {
|
async function waitForDirectChildMatching(supervisorPid: number, pattern: string): Promise<number> {
|
||||||
@@ -1870,6 +1882,8 @@ function readProviderUnitMetadata(unitName: string): string {
|
|||||||
|
|
||||||
function showProcessArguments(pid: number | undefined): string {
|
function showProcessArguments(pid: number | undefined): string {
|
||||||
if (!pid) throw new Error("provider supervisor did not expose its PID");
|
if (!pid) throw new Error("provider supervisor did not expose its PID");
|
||||||
|
const argv = readProcessArguments(pid);
|
||||||
|
if (argv !== null) return argv;
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
"/usr/bin/ps",
|
"/usr/bin/ps",
|
||||||
["-o", "args=", "-p", String(pid)],
|
["-o", "args=", "-p", String(pid)],
|
||||||
@@ -1880,6 +1894,102 @@ function showProcessArguments(pid: number | undefined): string {
|
|||||||
return result.stdout.trim();
|
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<{
|
async function startLoopbackCanary(root: string): Promise<Readonly<{
|
||||||
child: ChildProcess;
|
child: ChildProcess;
|
||||||
marker: string;
|
marker: string;
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ import { pathToFileURL } from "node:url";
|
|||||||
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This suite's budget, not the file's. The 10s default is sized for pure-JS
|
||||||
|
* unit tests; these spawn processes, build archives and sign evidence, and on a
|
||||||
|
* machine running the rest of the suite in parallel they legitimately need
|
||||||
|
* longer. Raising the global default instead would hide a genuinely hung test.
|
||||||
|
*/
|
||||||
|
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
const roots: string[] = [];
|
const roots: string[] = [];
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -699,7 +707,7 @@ describe("provider guardian transaction protocol", () => {
|
|||||||
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
|
await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" });
|
await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
await expect(readdir(evidenceRoot)).resolves.toEqual(["untrusted"]);
|
await expect(readdir(evidenceRoot)).resolves.toEqual(["untrusted"]);
|
||||||
});
|
}, PROCESS_HEAVY_TIMEOUT_MS);
|
||||||
|
|
||||||
it("still publishes near the lease deadline when post-processing completes in time", async () => {
|
it("still publishes near the lease deadline when post-processing completes in time", async () => {
|
||||||
const { startProviderGuardian } = await import(
|
const { startProviderGuardian } = await import(
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ import {
|
|||||||
type ProductionModuleInventory,
|
type ProductionModuleInventory,
|
||||||
} from "../../scripts/lib/risk-coverage.ts";
|
} from "../../scripts/lib/risk-coverage.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This suite's budget, not the file's. The 10s default is sized for pure-JS
|
||||||
|
* unit tests; these spawn processes, build archives and sign evidence, and on a
|
||||||
|
* machine running the rest of the suite in parallel they legitimately need
|
||||||
|
* longer. Raising the global default instead would hide a genuinely hung test.
|
||||||
|
*/
|
||||||
|
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
const roots: string[] = [];
|
const roots: string[] = [];
|
||||||
const now = Date.parse("2026-08-02T00:00:00.000Z");
|
const now = Date.parse("2026-08-02T00:00:00.000Z");
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -141,7 +149,7 @@ describe("repository-aware risk coverage", () => {
|
|||||||
result.repositoryTotal - result.selectedTotal,
|
result.repositoryTotal - result.selectedTotal,
|
||||||
);
|
);
|
||||||
expect(result.status).toBe("FAIL");
|
expect(result.status).toBe("FAIL");
|
||||||
});
|
}, PROCESS_HEAVY_TIMEOUT_MS);
|
||||||
|
|
||||||
it("reports exact inventory and generated-exclusion provenance", async () => {
|
it("reports exact inventory and generated-exclusion provenance", async () => {
|
||||||
const repositoryRoot = await repositoryFixture();
|
const repositoryRoot = await repositoryFixture();
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ import {
|
|||||||
} from "../../scripts/lib/release-candidate.ts";
|
} from "../../scripts/lib/release-candidate.ts";
|
||||||
import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts";
|
import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This suite's budget, not the file's. The 10s default is sized for pure-JS
|
||||||
|
* unit tests; these spawn processes, build archives and sign evidence, and on a
|
||||||
|
* machine running the rest of the suite in parallel they legitimately need
|
||||||
|
* longer. Raising the global default instead would hide a genuinely hung test.
|
||||||
|
*/
|
||||||
|
const PROCESS_HEAVY_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
const digest = (value: string): string =>
|
const digest = (value: string): string =>
|
||||||
createHash("sha256").update(value).digest("hex");
|
createHash("sha256").update(value).digest("hex");
|
||||||
const digestBytes = (value: Buffer): string =>
|
const digestBytes = (value: Buffer): string =>
|
||||||
@@ -1383,7 +1391,7 @@ describe("security follow-up contracts", () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
}, PROCESS_HEAVY_TIMEOUT_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
function providerExpectedContext() {
|
function providerExpectedContext() {
|
||||||
|
|||||||
Reference in New Issue
Block a user