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
@@ -269,6 +269,17 @@ export type ContractHttpExecutorDependencies = Readonly<{
|
||||
baseUrl: string;
|
||||
/** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */
|
||||
maxRetryAttempts: number;
|
||||
/**
|
||||
* §6.1 / §8.5. `REQUEST_TIMEOUT_MS` from Runtime Config, as a ceiling only.
|
||||
*
|
||||
* The contract owns each operation's deadline, because the deadline is part
|
||||
* of what the operation promises. A deployment still has to be able to hold
|
||||
* the whole app to something stricter than the sum of its contracts, so this
|
||||
* value may only shorten a deadline, never extend one — the same direction
|
||||
* `CAPABILITY_OVERRIDES` is allowed to move in. Absent, contracts stand
|
||||
* exactly as written.
|
||||
*/
|
||||
requestDeadlineCeilingMs?: number;
|
||||
/** The installed profile registry; the executor never invents a profile. */
|
||||
authProfiles?: InstalledRestAuthProfiles;
|
||||
attachCredentials(
|
||||
@@ -373,6 +384,13 @@ export function createContractHttpExecutor(
|
||||
dependencies.readBoundedResponseBytes ?? readBoundedBytes;
|
||||
const now = dependencies.monotonicNow ?? (() => performance.now());
|
||||
const random = dependencies.random ?? Math.random;
|
||||
const deadlineCeilingMs = dependencies.requestDeadlineCeilingMs;
|
||||
const effectiveDeadlineMs = (contractDeadlineMs: number): number =>
|
||||
typeof deadlineCeilingMs === "number" &&
|
||||
Number.isFinite(deadlineCeilingMs) &&
|
||||
deadlineCeilingMs > 0
|
||||
? Math.min(contractDeadlineMs, deadlineCeilingMs)
|
||||
: contractDeadlineMs;
|
||||
const sleep =
|
||||
dependencies.sleep ??
|
||||
((ms: number, signal: AbortSignal) =>
|
||||
@@ -400,7 +418,8 @@ export function createContractHttpExecutor(
|
||||
// §8.5. One monotonic deadline covers credential resolution, encoding,
|
||||
// backoff, every physical attempt, body read and validation.
|
||||
const startedAt = now();
|
||||
const deadlineAt = startedAt + policy.totalDeadlineMs;
|
||||
const totalDeadlineMs = effectiveDeadlineMs(policy.totalDeadlineMs);
|
||||
const deadlineAt = startedAt + totalDeadlineMs;
|
||||
const remaining = () => deadlineAt - now();
|
||||
|
||||
let attemptState: PhysicalAttemptState = "PREPARING";
|
||||
@@ -451,7 +470,7 @@ export function createContractHttpExecutor(
|
||||
const lifetimeDeadlineTimer = setTimeout(() => {
|
||||
terminalCancellation ??= "DEADLINE";
|
||||
lifetimeController.abort();
|
||||
}, policy.totalDeadlineMs);
|
||||
}, totalDeadlineMs);
|
||||
let lifetimeDisposed = false;
|
||||
const disposeLifetime = () => {
|
||||
if (lifetimeDisposed) return;
|
||||
|
||||
@@ -394,6 +394,11 @@ export async function createRuntimeAdapters(
|
||||
const contractHttp = createContractHttpExecutor({
|
||||
baseUrl: config.API_BASE_URL,
|
||||
maxRetryAttempts: config.MAX_RETRY_ATTEMPTS,
|
||||
// §6.1. `REQUEST_TIMEOUT_MS` was declared, validated and then dropped on the
|
||||
// floor here: every V3 operation ran on its contract's own 10s deadline and
|
||||
// the deployment dial did nothing. It is a ceiling, so it can tighten an
|
||||
// operation but never loosen one.
|
||||
requestDeadlineCeilingMs: config.REQUEST_TIMEOUT_MS,
|
||||
fetcher: context.fetcher,
|
||||
// §7.7. The installed registry owns Fetch credentials and the exact
|
||||
// credential-header sets; this collaborator only supplies proof headers.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { RuntimeConfigArtifact } from "./release-artifacts.ts";
|
||||
|
||||
/**
|
||||
* §6.4. Which environment an artifact is allowed to be deployed to.
|
||||
*
|
||||
* Release coherence answers "do these artifacts describe each other?". It does
|
||||
* not answer "is this the artifact production should receive?", and the two are
|
||||
* not the same question: a build whose runtime document says `APP_ENV: local`,
|
||||
* `AUTH_MODE: demo` and `API_BASE_URL: http://localhost:8080/` is perfectly
|
||||
* coherent with itself. Without an admission step such a build is a valid
|
||||
* release candidate, and the only thing standing between it and production is
|
||||
* that nobody happened to promote it.
|
||||
*
|
||||
* Admission is therefore a separate, declared decision: a caller states the
|
||||
* target it intends, and this module says whether the artifact may go there.
|
||||
* Every rule below is a refusal, so an unrecognised target or an unreadable
|
||||
* field fails closed rather than passing by omission.
|
||||
*/
|
||||
|
||||
export const DEPLOYMENT_TARGETS = Object.freeze([
|
||||
"local",
|
||||
"development",
|
||||
"staging",
|
||||
"production",
|
||||
] as const);
|
||||
|
||||
export type DeploymentTarget = (typeof DEPLOYMENT_TARGETS)[number];
|
||||
|
||||
/**
|
||||
* Targets that serve real users over the public internet. They carry the full
|
||||
* rule set; `local` and `development` only have to be honest about what they
|
||||
* are.
|
||||
*/
|
||||
const PUBLIC_TARGETS: ReadonlySet<DeploymentTarget> = new Set([
|
||||
"staging",
|
||||
"production",
|
||||
]);
|
||||
|
||||
/** Placeholder identifiers a developer build emits when nothing supplied one. */
|
||||
const PLACEHOLDER_IDENTIFIERS: ReadonlySet<string> = new Set([
|
||||
"local-build",
|
||||
"local-release",
|
||||
"local",
|
||||
"dev",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
export type AdmissionViolation = Readonly<{ field: string; reason: string }>;
|
||||
|
||||
export type AdmissionInput = RuntimeConfigArtifact &
|
||||
Readonly<{ BUILD_ID?: string; RELEASE_ID?: string }>;
|
||||
|
||||
export function isDeploymentTarget(value: unknown): value is DeploymentTarget {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(DEPLOYMENT_TARGETS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every reason this artifact may not be deployed to `target`. An empty list is
|
||||
* the only admission.
|
||||
*/
|
||||
export function findAdmissionViolations(
|
||||
target: DeploymentTarget,
|
||||
config: AdmissionInput,
|
||||
): readonly AdmissionViolation[] {
|
||||
const violations: AdmissionViolation[] = [];
|
||||
if (config.APP_ENV !== target) {
|
||||
violations.push({
|
||||
field: "APP_ENV",
|
||||
reason: `artifact declares ${config.APP_ENV} but is being admitted to ${target}`,
|
||||
});
|
||||
}
|
||||
if (!PUBLIC_TARGETS.has(target)) return Object.freeze(violations);
|
||||
|
||||
if (config.AUTH_MODE !== "external") {
|
||||
violations.push({
|
||||
field: "AUTH_MODE",
|
||||
reason: `${target} requires an external identity provider, not ${config.AUTH_MODE}`,
|
||||
});
|
||||
}
|
||||
violations.push(...publicEndpointViolations("API_BASE_URL", config.API_BASE_URL));
|
||||
if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) {
|
||||
violations.push({
|
||||
field: "TELEMETRY_ENDPOINT",
|
||||
reason: "telemetry is enabled without an endpoint",
|
||||
});
|
||||
}
|
||||
if (config.TELEMETRY_ENDPOINT) {
|
||||
violations.push(
|
||||
...publicEndpointViolations("TELEMETRY_ENDPOINT", config.TELEMETRY_ENDPOINT),
|
||||
);
|
||||
}
|
||||
for (const field of ["BUILD_ID", "RELEASE_ID"] as const) {
|
||||
const value = config[field];
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
violations.push({ field, reason: `${target} requires a build identity` });
|
||||
continue;
|
||||
}
|
||||
if (PLACEHOLDER_IDENTIFIERS.has(value.toLowerCase())) {
|
||||
violations.push({
|
||||
field,
|
||||
reason: `${value} is a developer placeholder, not a released identity`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Object.freeze(violations);
|
||||
}
|
||||
|
||||
function publicEndpointViolations(
|
||||
field: string,
|
||||
value: string,
|
||||
): readonly AdmissionViolation[] {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return [{ field, reason: "is not an absolute URL" }];
|
||||
}
|
||||
const violations: AdmissionViolation[] = [];
|
||||
if (url.protocol !== "https:") {
|
||||
violations.push({ field, reason: `${url.protocol} is not permitted; use https` });
|
||||
}
|
||||
if (isNonPublicHost(url.hostname)) {
|
||||
violations.push({
|
||||
field,
|
||||
reason: `${url.hostname} is not reachable from a user's browser`,
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts that only resolve inside the machine or network that built the
|
||||
* artifact. A deployment pointing at one of these is a developer configuration
|
||||
* that escaped, not a production endpoint.
|
||||
*/
|
||||
function isNonPublicHost(hostname: string): boolean {
|
||||
const host = hostname.toLowerCase().replace(/^\[|\]$/gu, "");
|
||||
if (
|
||||
host === "localhost" ||
|
||||
host.endsWith(".localhost") ||
|
||||
host === "::1" ||
|
||||
host === "0.0.0.0" ||
|
||||
host === "::"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const octets = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(host);
|
||||
if (!octets) return false;
|
||||
const [first, second] = [Number(octets[1]), Number(octets[2])];
|
||||
return (
|
||||
first === 127 ||
|
||||
first === 10 ||
|
||||
(first === 192 && second === 168) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 169 && second === 254)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user