fix: run the provider sandbox and admit a release to a named environment
The provider sandbox never ran. bubblewrap 0.9.0 stops parsing an `--args` file at the first non-option and never hands the remainder back, so the command written into that file was silently dropped: bwrap printed its usage text, exited 1, and the provider produced no evidence at all. The options still travel in the args file — that is what keeps host paths and credentials out of `/proc/<pid>/cmdline` — but the command now rides on real argv, and `encodeProviderBwrapInput` refuses a `--` so the drop cannot come back. The scope wrapper then could not exit. It read the supervisor's liveness pipe through `fs`, which runs a blocking `read(2)` on a threadpool thread; the supervisor holds that pipe open for the scope's whole life, so the read never returned and closing the descriptor did not interrupt it. Once bubblewrap finished the wrapper deadlocked in `process.exit`, the scope outlived the provider, and a completed run was reported as a timeout kill. The channel is now read through the event loop, so teardown is observable and terminal. Creation modes were left to the ambient umask. `mkdir(mode)` and `open(mode)` are requests the kernel subtracts the umask from, so a runner exporting a restrictive umask produced directories it could not enter and handed `tar` a file it could not re-open. Private modes are pinned instead of inherited. Promotion cleanup deleted before it checked. Removals run through a pinned descriptor, so a leaf substituted after validation had this promotion's exact five destroyed first and the substitution reported afterwards, leaving a half-emptied directory a retry could not tell from a completed one. The name is re-bound to the inode before anything is removed, so the failure is total. Separately, release coherence proved the artifacts agreed with each other but never that they belonged where they were going: a build whose runtime document said `APP_ENV: local`, `AUTH_MODE: demo` and a loopback API is coherent with itself and passed every gate. `public/` is copied verbatim into `dist/`, so that local document shipped with every build regardless of what the build was for. Runtime configuration now comes from a declared profile, and FE-GATE-027 refuses to admit an artifact to an environment it does not match — including refusing an undeclared destination, so nothing is admitted by omission. `REQUEST_TIMEOUT_MS` and `VITE_ROUTER_BASE_PATH` were validated and then dropped: the V3 executor ran every operation on its contract's own deadline, and Vite emitted root-absolute assets for a sub-path deployment. The timeout is now a ceiling that may tighten a contract but never loosen one, and one base path feeds the router, the Service Worker scope and the asset base together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a0fbafb77b
commit
dfb7734674
@@ -842,12 +842,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 +1275,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 +1524,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 () => {
|
||||
@@ -1681,8 +1695,19 @@ async function readCgroupPids(cgroupRoot: string): Promise<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);
|
||||
let listing: string;
|
||||
try {
|
||||
listing = await readFile(childrenPath, "utf8");
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) {
|
||||
throw new Error(
|
||||
"provider supervisor exited before its children could be observed",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const children = listing.trim().split(/\s+/u).filter(Boolean).map(Number);
|
||||
const arguments_ = children.flatMap((pid) => {
|
||||
try {
|
||||
return [showProcessArguments(pid)];
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEPLOYMENT_TARGETS,
|
||||
findAdmissionViolations,
|
||||
isDeploymentTarget,
|
||||
type AdmissionInput,
|
||||
} from "../../src/contracts/deployment-admission.ts";
|
||||
import { runtimeConfigV2ArtifactSchema } from "../../src/contracts/release-artifacts.ts";
|
||||
import { generateRuntimeConfig } from "../../scripts/generate-runtime-config.ts";
|
||||
|
||||
const PRODUCTION_ARTIFACT: AdmissionInput = Object.freeze({
|
||||
APP_ENV: "production",
|
||||
API_BASE_URL: "https://api.example.com/",
|
||||
REQUEST_TIMEOUT_MS: 10_000,
|
||||
MAX_RETRY_ATTEMPTS: 2,
|
||||
TELEMETRY_ENABLED: false,
|
||||
AUTH_MODE: "external",
|
||||
CONFIG_SCHEMA_VERSION: "2.0",
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
BUILD_ID: "20260815.42",
|
||||
RELEASE_ID: "r-2026.08.15-1",
|
||||
CAPABILITY_OVERRIDES: Object.freeze({
|
||||
REALTIME: "DEFAULT",
|
||||
WEB_WORKER: "DEFAULT",
|
||||
SERVICE_WORKER: "DEFAULT",
|
||||
OFFLINE_COMMANDS: "DEFAULT",
|
||||
}),
|
||||
}) as AdmissionInput;
|
||||
|
||||
const LOCAL_ARTIFACT: AdmissionInput = Object.freeze({
|
||||
...PRODUCTION_ARTIFACT,
|
||||
APP_ENV: "local",
|
||||
API_BASE_URL: "http://localhost:8080/",
|
||||
AUTH_MODE: "demo",
|
||||
BUILD_ID: "local-build",
|
||||
RELEASE_ID: "local-release",
|
||||
}) as AdmissionInput;
|
||||
|
||||
describe("deployment admission", () => {
|
||||
it("admits an artifact only to the environment it declares", () => {
|
||||
expect(findAdmissionViolations("production", PRODUCTION_ARTIFACT)).toEqual([]);
|
||||
expect(findAdmissionViolations("local", LOCAL_ARTIFACT)).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses the exact local build that release coherence used to approve", () => {
|
||||
// The review's strongest reproduction: FE-GATE-015 passed on a build whose
|
||||
// runtime document was APP_ENV=local / AUTH_MODE=demo / loopback API. Each
|
||||
// of those is now an independent refusal, so fixing one does not admit it.
|
||||
const violations = findAdmissionViolations("production", LOCAL_ARTIFACT);
|
||||
const fields = violations.map((violation) => violation.field);
|
||||
expect(fields).toContain("APP_ENV");
|
||||
expect(fields).toContain("AUTH_MODE");
|
||||
expect(fields).toContain("API_BASE_URL");
|
||||
expect(fields).toContain("BUILD_ID");
|
||||
expect(fields).toContain("RELEASE_ID");
|
||||
});
|
||||
|
||||
it("refuses endpoints a browser on the public internet cannot reach", () => {
|
||||
for (const host of [
|
||||
"http://api.example.com/",
|
||||
"https://localhost/",
|
||||
"https://127.0.0.1/",
|
||||
"https://10.0.0.5/",
|
||||
"https://192.168.1.10/",
|
||||
"https://172.16.4.4/",
|
||||
"https://169.254.169.254/",
|
||||
"https://[::1]/",
|
||||
]) {
|
||||
const violations = findAdmissionViolations("production", {
|
||||
...PRODUCTION_ARTIFACT,
|
||||
API_BASE_URL: host,
|
||||
} as AdmissionInput);
|
||||
expect(violations.map((violation) => violation.field), host).toContain(
|
||||
"API_BASE_URL",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("permits a routable public host", () => {
|
||||
expect(
|
||||
findAdmissionViolations("production", {
|
||||
...PRODUCTION_ARTIFACT,
|
||||
API_BASE_URL: "https://api.172.16.example.com/",
|
||||
} as AdmissionInput),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a placeholder identity on a public target", () => {
|
||||
for (const buildId of ["local-build", "local", "dev", "unknown", ""]) {
|
||||
const violations = findAdmissionViolations("production", {
|
||||
...PRODUCTION_ARTIFACT,
|
||||
BUILD_ID: buildId,
|
||||
} as AdmissionInput);
|
||||
expect(violations.map((violation) => violation.field), buildId).toContain(
|
||||
"BUILD_ID",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats an unknown target as not a target at all", () => {
|
||||
for (const value of ["prod", "PRODUCTION", "", undefined, null, 1]) {
|
||||
expect(isDeploymentTarget(value), String(value)).toBe(false);
|
||||
}
|
||||
for (const target of DEPLOYMENT_TARGETS) {
|
||||
expect(isDeploymentTarget(target)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime config profiles", () => {
|
||||
it("ships one valid profile per deployment target", async () => {
|
||||
const files = (await readdir("config/runtime")).sort();
|
||||
expect(files).toEqual(
|
||||
[...DEPLOYMENT_TARGETS].map((target) => `${target}.json`).sort(),
|
||||
);
|
||||
for (const target of DEPLOYMENT_TARGETS) {
|
||||
const source: unknown = JSON.parse(
|
||||
await readFile(path.join("config/runtime", `${target}.json`), "utf8"),
|
||||
);
|
||||
const parsed = runtimeConfigV2ArtifactSchema.safeParse({
|
||||
...(source as Record<string, unknown>),
|
||||
BUILD_ID: "20260815.42",
|
||||
RELEASE_ID: "r-1",
|
||||
});
|
||||
expect(parsed.success, `${target}: ${JSON.stringify(parsed.error?.issues)}`).toBe(
|
||||
true,
|
||||
);
|
||||
expect((source as Record<string, unknown>)["APP_ENV"]).toBe(target);
|
||||
}
|
||||
});
|
||||
|
||||
it("produces an admissible document for every public target", async () => {
|
||||
for (const target of ["staging", "production"] as const) {
|
||||
const config = await generateRuntimeConfig(target, {
|
||||
VITE_BUILD_ID: "20260815.42",
|
||||
RELEASE_ID: "r-2026.08.15-1",
|
||||
});
|
||||
expect(
|
||||
findAdmissionViolations(target, config as unknown as AdmissionInput),
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a deployment override that would make the document unservable", async () => {
|
||||
await expect(
|
||||
generateRuntimeConfig("production", {
|
||||
VITE_BUILD_ID: "20260815.42",
|
||||
RELEASE_ID: "r-1",
|
||||
RUNTIME_API_BASE_URL: "http://api.example.com/",
|
||||
}),
|
||||
).rejects.toThrow(/runtime config is invalid/u);
|
||||
});
|
||||
|
||||
it("keeps a developer build from carrying a released identity by default", async () => {
|
||||
const config = await generateRuntimeConfig("local", {});
|
||||
expect(config["BUILD_ID"]).toBe("local-build");
|
||||
expect(
|
||||
findAdmissionViolations("production", config as unknown as AdmissionInput)
|
||||
.length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,76 @@ async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("runtime request deadline ceiling", () => {
|
||||
/**
|
||||
* `REQUEST_TIMEOUT_MS` was validated by the runtime config schema and then
|
||||
* never handed to the V3 executor, so the deployment dial did nothing and
|
||||
* every operation ran on its contract's own deadline. It is a ceiling: it may
|
||||
* tighten an operation, never loosen one.
|
||||
*/
|
||||
async function settlesWithin(
|
||||
contractDeadlineMs: number,
|
||||
ceilingMs: number | undefined,
|
||||
advanceMs: number,
|
||||
): Promise<boolean> {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const executor = createContractHttpExecutor({
|
||||
baseUrl: "https://api.example/",
|
||||
maxRetryAttempts: 0,
|
||||
...(ceilingMs === undefined ? {} : { requestDeadlineCeilingMs: ceilingMs }),
|
||||
attachCredentials: () => ({ kind: "READY", headers: {} }),
|
||||
// A request that only ever ends by being cut off, so what settles it is
|
||||
// exactly the deadline under test.
|
||||
fetcher: (_input, init) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
const signal = (init as RequestInit | undefined)?.signal;
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("aborted", "AbortError")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
let settled = false;
|
||||
const pending = executor
|
||||
.execute(operation({ deadlineMs: contractDeadlineMs }), {}, {
|
||||
routeId: ROUTE_ID,
|
||||
scope,
|
||||
})
|
||||
.then(
|
||||
() => { settled = true; },
|
||||
() => { settled = true; },
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(advanceMs);
|
||||
await flushMicrotasks();
|
||||
const observed = settled;
|
||||
if (!observed) await vi.advanceTimersByTimeAsync(contractDeadlineMs + 1_000);
|
||||
await pending;
|
||||
return observed;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
}
|
||||
|
||||
it("applies the tighter of the contract and deployment bounds", async () => {
|
||||
await expect(settlesWithin(5_000, 500, 800)).resolves.toBe(true);
|
||||
await expect(settlesWithin(5_000, undefined, 800)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("never extends a contract deadline", async () => {
|
||||
await expect(settlesWithin(500, 60_000, 800)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("ignores a ceiling that is not a usable duration", async () => {
|
||||
for (const ceiling of [0, -1, Number.NaN]) {
|
||||
await expect(settlesWithin(5_000, ceiling, 800), String(ceiling)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
it("normalizes a read-side 429 to the non-applicable effect vocabulary", async () => {
|
||||
const executor = createContractHttpExecutor({
|
||||
|
||||
@@ -70,8 +70,19 @@ describe("selective Task 3 contract closure", () => {
|
||||
"--signal=SIGKILL",
|
||||
unit,
|
||||
]);
|
||||
// `bwrap --args FD` stops parsing at the first non-option and never hands
|
||||
// the remainder back, so a command placed in the args file is dropped and
|
||||
// bubblewrap exits with its usage text. Refusing `--` in the option stream
|
||||
// is what keeps that silent no-sandbox launch from returning.
|
||||
expect(() =>
|
||||
encodeProviderBwrapInput(
|
||||
["--unshare-net", "--", "/usr/bin/prlimit"],
|
||||
{ PROVIDER_COMMAND: command },
|
||||
),
|
||||
).toThrow(/terminate the option stream/u);
|
||||
const frame = encodeProviderScopeFrame({
|
||||
bwrapInput: Buffer.from("private-bwrap-vector\0"),
|
||||
bwrapCommand: ["/usr/bin/prlimit", "--nofile=64:64", "--", "/bin/sh", "-eu", "-c", 'exec /bin/sh -eu -c "$PROVIDER_COMMAND"'],
|
||||
reportPath: "/exact/report.json",
|
||||
reportDev: 12,
|
||||
reportIno: 34,
|
||||
@@ -81,6 +92,27 @@ describe("selective Task 3 contract closure", () => {
|
||||
Buffer.from("private-bwrap-vector\0").toString("base64"),
|
||||
);
|
||||
expect(launch.join("\0")).not.toContain("private-bwrap-vector");
|
||||
// The command vector rides on real argv, so it must never be able to carry
|
||||
// the secret that the args file exists to hide.
|
||||
expect(frame.subarray(4).toString("utf8")).not.toContain(credential);
|
||||
expect(() =>
|
||||
encodeProviderScopeFrame({
|
||||
bwrapInput: Buffer.from("x\0"),
|
||||
bwrapCommand: [],
|
||||
reportPath: "/exact/report.json",
|
||||
reportDev: 12,
|
||||
reportIno: 34,
|
||||
}),
|
||||
).toThrow(/bwrap command is invalid/u);
|
||||
expect(() =>
|
||||
encodeProviderScopeFrame({
|
||||
bwrapInput: Buffer.from("x\0"),
|
||||
bwrapCommand: ["prlimit"],
|
||||
reportPath: "/exact/report.json",
|
||||
reportDev: 12,
|
||||
reportIno: 34,
|
||||
}),
|
||||
).toThrow(/bwrap command is invalid/u);
|
||||
});
|
||||
|
||||
it("removes only the pinned raw inode during parent-loss cleanup", async () => {
|
||||
|
||||
Reference in New Issue
Block a user