test: quarantine invitation enrollment before unsafe token-path acceptance

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-27 06:42:29 -05:00
1 parent 155cf4ce15
commit 06ddb87a9a
7 files changed
+310 -2

No files matched your search

@@ -1,6 +1,7 @@
import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { requireSafeInvitationEnrollment } from "./identity-invite-prerequisite.js";
import { privateIdentityBrowser } from "./identity-private-browser.js";
import { randomBytes, randomUUID } from "node:crypto";
import { expect, type Browser, type BrowserContext, type Page } from "@playwright/test";
@@ -84,6 +85,7 @@ export async function createAuthenticatedPerson(input: {
export async function enrollAuthenticatedPerson(api: RunnerApi, companyId: string, person: Awaited<ReturnType<typeof createAuthenticatedPerson>>, secrets: string[], role: "operator" | "viewer" = "operator") {
const invite = await api.postSensitive<{ id: string; token: string }>(`/api/companies/${companyId}/invites`, { allowedJoinTypes: "human", humanRole: role });
secrets.push(invite.token);
await requireSafeInvitationEnrollment(api, companyId, invite.id, role, () => person.dispose());
const response = await person.request(`/api/invites/${encodeURIComponent(invite.token)}/accept`, "POST", { requestType: "human" });
if (!response.ok) { await response.body?.cancel(); throw new Error(`Public join request returned HTTP ${response.status}; details withheld`); }
await response.body?.cancel();
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import type { RunnerApi } from "../api.js";
import { enrollAuthenticatedPerson, type createAuthenticatedPerson } from "./identity-auth.js";
import { bindInvitationEnrollmentPrerequisite, invitationIncident, invitationRuntimeDisposition, type InvitationRuntimeProof } from "./identity-invite-prerequisite.js";
const companyId = "11111111-1111-4111-8111-111111111111", inviteId = "22222222-2222-4222-8222-222222222222";
const proof: InvitationRuntimeProof = { apiOrigin: "http://127.0.0.1:3100", processStartedAt: "2026-10-27T11:00:00Z", healthCommit: "6".repeat(40),
redactorSha256: invitationIncident.affectedRedactorSha256, serverEntrySha256: "a".repeat(64), supervisorReceiptSha256: "b".repeat(64), priorProofSha256: invitationIncident.priorProofSha256 };
const health = { deploymentMode: "authenticated", commit: proof.healthCommit, serverInfo: { processStartedAt: proof.processStartedAt } };
function fixture(override?: Partial<InvitationRuntimeProof>, bind = true) {
const calls: string[] = []; const evidence = vi.fn();
const api = { baseURL: proof.apiOrigin,
postSensitive: vi.fn(async (url: string) => { calls.push(url); return url.endsWith("/revoke") ? {} : { id: inviteId, token: "synthetic-private-invite-canary" }; }),
get: vi.fn(async (url: string) => { calls.push(url);
if (url === "/api/health") return health;
if (url.includes("/join-requests")) return [{ id: "join", requestingUserId: "person", inviteId, status: "approved" }];
return { invites: [{ id: inviteId, companyId, humanRole: "operator", allowedJoinTypes: "human", state: url.includes("state=revoked") ? "revoked" : "active" }] };
}),
} as unknown as RunnerApi;
const request = vi.fn(async (url: string) => new Response(JSON.stringify(url === "/api/companies" ? [{ id: companyId }] : {})));
const person = { id: "person", request, dispose: vi.fn(async () => {}) } as unknown as Awaited<ReturnType<typeof createAuthenticatedPerson>>;
if (bind) bindInvitationEnrollmentPrerequisite(api, { verifyRuntime: async () => ({ ...proof, ...override }), evidence });
return { api, person, request, evidence, calls, secrets: [] as string[] };
}
describe("invitation enrollment prerequisite quarantine", () => {
it("proves an actual issued/read/revoked public prerequisite and stops before affected acceptance", async () => {
const f = fixture(); await expect(enrollAuthenticatedPerson(f.api, companyId, f.person, f.secrets)).rejects.toMatchObject({ receipt: {
disposition: "blocked-core-prerequisite", inviteReadback: true, inviteRevoked: true, acceptanceCalled: false, nativeDispatchFromPrerequisite: false } });
expect(f.request).not.toHaveBeenCalled(); expect(f.calls.some(url => url.includes("synthetic-private-invite-canary"))).toBe(false);
expect(f.secrets).toEqual(["synthetic-private-invite-canary"]); expect(JSON.stringify(f.evidence.mock.calls)).not.toContain(f.secrets[0]);
expect(f.calls).toContain(`/api/invites/${inviteId}/revoke`);
});
it("keeps the full public enrollment flow available only with qualified fixed runtime proof", async () => {
const f = fixture({ redactorSha256: invitationIncident.fixedRedactorSha256 });
await expect(enrollAuthenticatedPerson(f.api, companyId, f.person, f.secrets)).resolves.toMatchObject({ inviteId, companyId });
expect(f.request.mock.calls[0]?.[0]).toContain("/accept"); expect(f.evidence).not.toHaveBeenCalled();
});
it.each([undefined, { processStartedAt: "2026-10-27T10:00:00Z" }, { healthCommit: "7".repeat(40) },
{ redactorSha256: "c".repeat(64) }, { priorProofSha256: "d".repeat(64) }, { serverEntrySha256: "not-a-hash" }])("fails closed for missing, foreign or unverified runtime proof %j", async change => {
const f = fixture(change, change !== undefined);
await expect(enrollAuthenticatedPerson(f.api, companyId, f.person, f.secrets)).rejects.toMatchObject({ receipt: { disposition: "blocked-runtime-identity", inviteRevoked: true } });
expect(f.request).not.toHaveBeenCalled();
});
it("does not treat a null public commit alone as product identity", () => {
expect(invitationRuntimeDisposition(undefined, proof.apiOrigin, { ...health, commit: null })).toBe("unknown");
expect(invitationRuntimeDisposition({ ...proof, healthCommit: null }, proof.apiOrigin, { ...health, commit: null })).toBe("affected");
});
it("never accepts when readback is missing and still revokes its owned invite", async () => {
const f = fixture(); f.api.get = vi.fn(async (url: string) => url === "/api/health" ? health : { invites: [] }) as RunnerApi["get"];
await expect(enrollAuthenticatedPerson(f.api, companyId, f.person, f.secrets)).rejects.toThrow("probe incomplete");
expect(f.calls).toContain(`/api/invites/${inviteId}/revoke`); expect(f.request).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,105 @@
import type { RunnerApi } from "../api.js";
export const invitationIncident = Object.freeze({
corePr: "https://github.com/paperclipai/paperclip/pull/17848",
priorProof: "tests/runner-e2e/results/chat-catalog-campaign/operations/agent-feedback-privacy-cause-68e.json",
priorProofSha256: "9f0fa4432438411567e51987c0c239f0e0ef9513c0dce80623707486978bfe16",
affectedBaseline: "8373d7867963674ff960d2345d0d28086f799bc7",
affectedRedactorSha256: "f1fde1590128fe2fc95a6752e1daf4fe8e0a1478328a50944945597a6aa21315",
fixedRedactorSha256: "dcc6274253d8954d190c3c43f40a2d1ce6c8c3817a85176f8bd264b677f7ae73",
});
export interface InvitationRuntimeProof {
apiOrigin: string;
processStartedAt: string;
healthCommit: string | null;
redactorSha256: string;
serverEntrySha256: string;
supervisorReceiptSha256: string;
priorProofSha256: string;
}
export interface InvitationPrerequisiteBinding {
/** Supervisor owner must independently verify the actual spawned source entry,
* loaded logger bytes, owned process and retained receipt before returning this.
* Eval source SHA or an asserted production revision is not sufficient. */
verifyRuntime(): Promise<InvitationRuntimeProof>;
evidence(name: string, receipt: InvitationPrerequisiteReceipt): Promise<unknown>;
}
export interface InvitationPrerequisiteReceipt {
schema: "invitation-enrollment-prerequisite.v1";
disposition: "blocked-core-prerequisite" | "blocked-runtime-identity" | "incomplete-public-prerequisite";
phase: "before-invitation-acceptance";
corePr: string;
priorProofSha256: string;
observedRedactorSha256?: string;
supervisorReceiptSha256?: string;
inviteId?: string;
companyId?: string;
inviteReadback: boolean;
inviteRevoked: boolean;
personSessionDisposed: boolean;
acceptanceCalled: false;
nativeDispatchFromPrerequisite: false;
}
const bindings = new WeakMap<RunnerApi, InvitationPrerequisiteBinding>();
export function bindInvitationEnrollmentPrerequisite(api: RunnerApi, binding: InvitationPrerequisiteBinding) {
if (bindings.has(api)) throw new Error("Invitation prerequisite binding is already owned by this attempt");
bindings.set(api, binding);
}
const hash = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
export function invitationRuntimeDisposition(proof: InvitationRuntimeProof | undefined, origin: string,
health: { deploymentMode?: string; commit?: string | null; serverInfo?: { processStartedAt?: string } }): "affected" | "qualified-fixed" | "unknown" {
if (!proof || health.deploymentMode !== "authenticated" || proof.apiOrigin !== origin
|| !proof.processStartedAt || !Number.isFinite(Date.parse(proof.processStartedAt))
|| health.serverInfo?.processStartedAt !== proof.processStartedAt
|| health.commit !== proof.healthCommit || !(proof.healthCommit === null || /^[a-f0-9]{40}$/.test(proof.healthCommit))
|| !hash(proof.serverEntrySha256) || !hash(proof.supervisorReceiptSha256)
|| proof.priorProofSha256 !== invitationIncident.priorProofSha256) return "unknown";
if (proof.redactorSha256 === invitationIncident.affectedRedactorSha256) return "affected";
if (proof.redactorSha256 === invitationIncident.fixedRedactorSha256) return "qualified-fixed";
return "unknown";
}
export class InvitationEnrollmentPrerequisiteBlocked extends Error {
constructor(readonly receipt: InvitationPrerequisiteReceipt) {
super(`Invitation enrollment setup blocked before token-path acceptance: ${JSON.stringify(receipt)}`);
this.name = "InvitationEnrollmentPrerequisiteBlocked";
}
}
/** Returns only for the independently qualified fixed logger. Otherwise the
* issued private capability is verified and revoked without ever using its URL. */
export async function requireSafeInvitationEnrollment(api: RunnerApi, companyId: string, inviteId: string, role: "operator" | "viewer", disposePerson: () => Promise<void>) {
const binding = bindings.get(api);
let proof: InvitationRuntimeProof | undefined;
try { proof = await binding?.verifyRuntime(); } catch { /* Unknown identity never authorizes acceptance. */ }
let disposition: "affected" | "qualified-fixed" | "unknown" = "unknown";
try {
const health = await api.get<{ deploymentMode: string; commit: string | null; serverInfo?: { processStartedAt?: string } }>("/api/health");
disposition = invitationRuntimeDisposition(proof, api.baseURL, health);
} catch { /* Failure to observe identity cannot authorize token-path access. */ }
if (disposition === "qualified-fixed") return;
const receipt: InvitationPrerequisiteReceipt = {
schema: "invitation-enrollment-prerequisite.v1", disposition: "incomplete-public-prerequisite",
phase: "before-invitation-acceptance", corePr: invitationIncident.corePr, priorProofSha256: invitationIncident.priorProofSha256,
...(disposition === "affected" ? { observedRedactorSha256: proof!.redactorSha256, supervisorReceiptSha256: proof!.supervisorReceiptSha256 } : {}),
inviteReadback: false, inviteRevoked: false, personSessionDisposed: false, acceptanceCalled: false, nativeDispatchFromPrerequisite: false,
};
if (!/^[a-f0-9-]{36}$/.test(inviteId) || !/^[a-f0-9-]{36}$/.test(companyId)) {
try { await disposePerson(); receipt.personSessionDisposed = true; } catch { /* Keep incomplete cleanup visible. */ }
await binding?.evidence("identity-invitation-enrollment-prerequisite.json", receipt);
throw new Error(`Invitation prerequisite probe incomplete: ${JSON.stringify(receipt)}`);
}
receipt.inviteId = inviteId; receipt.companyId = companyId;
try {
const page = await api.get<{ invites: Array<{ id: string; companyId: string; allowedJoinTypes: string; humanRole: string; state: string }> }>(`/api/companies/${companyId}/invites?limit=100`);
receipt.inviteReadback = page.invites.some(row => row.id === inviteId && row.companyId === companyId && row.allowedJoinTypes === "human" && row.humanRole === role && row.state === "active");
} catch { /* The safe receipt records an incomplete public prerequisite. */ }
try {
await api.postSensitive(`/api/invites/${inviteId}/revoke`, {});
const page = await api.get<{ invites: Array<{ id: string; state: string }> }>(`/api/companies/${companyId}/invites?state=revoked&limit=100`);
receipt.inviteRevoked = page.invites.some(row => row.id === inviteId && row.state === "revoked");
} catch { /* Cleanup failure must remain visible and cannot count as blocked proof. */ }
try { await disposePerson(); receipt.personSessionDisposed = true; } catch { /* An incomplete cleanup is never credited as closed proof. */ }
if (receipt.inviteReadback && receipt.inviteRevoked && receipt.personSessionDisposed) receipt.disposition = disposition === "affected" ? "blocked-core-prerequisite" : "blocked-runtime-identity";
await binding?.evidence("identity-invitation-enrollment-prerequisite.json", receipt);
if (!receipt.inviteReadback || !receipt.inviteRevoked || !receipt.personSessionDisposed) throw new Error(`Invitation prerequisite probe incomplete: ${JSON.stringify(receipt)}`);
throw new InvitationEnrollmentPrerequisiteBlocked(receipt);
}
@@ -0,0 +1,62 @@
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, realpath, readFile, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { publishInvitationSourceProof, invitationSourceProofPath, verifyInvitationSourceProof, validateInvitationSourceProof } from "./identity-invite-source-proof.js";
import { runnerE2ETypeScriptProcessArgs } from "../web-server-command.js";
const root = "/owned/source";
const bytes = { serverEntry: path.join(root, "server/src/index.ts"), serverEntrySha256: "a".repeat(64), redactorSha256: "b".repeat(64) };
const process = { pid: 100, parentPid: 99, processGroupId: 100, started: "Tue Oct 27 11:00:00 2026", kind: "node" };
const expected = { temporaryRoot: "/owned/private", sourceRoot: root, apiOrigin: "http://127.0.0.1:3100", attemptId: "attempt-1", executionId: "case-1" };
const proof = { schema: "invitation-source-process.v1", generation: "generation-1", ...expected, executionIds: ["case-1"],
...bytes, argv: runnerE2ETypeScriptProcessArgs(root, path.join(root, "tests/runner-e2e/server-entry.ts"), ["onboard", "--yes", "--run"]),
pid: 100, parentPid: 99, processStarted: process.started, spawnedAt: "2026-10-27T11:00:00Z" };
const health = { commit: null, serverInfo: { processStartedAt: "2026-10-27T11:00:01Z" } };
describe("invitation supervisor source qualification", () => {
it("qualifies observed source bytes, actual child/start, attempt and API boot without guessing a production SHA", () => {
expect(() => validateInvitationSourceProof(proof, expected, bytes, process, health, "generation-1")).not.toThrow();
});
it.each([{ attemptId: "foreign-attempt" }, { executionIds: ["foreign-case"] }, { apiOrigin: "http://127.0.0.1:9999" },
{ sourceRoot: "/foreign/source" }, { redactorSha256: "forged-fixed-hash" }, { serverEntrySha256: "forged-entry" },
{ argv: ["arbitrary-server"] }, { pid: 101 }, { parentPid: 98 }, { processStarted: "old PID start" }])("rejects a forged or mismatched receipt %j", change => {
expect(() => validateInvitationSourceProof({ ...proof, ...change }, expected, bytes, process, health)).toThrow("unqualified");
});
it("rejects replay of an old restart generation and old API boot", () => {
expect(() => validateInvitationSourceProof(proof, expected, bytes, process, health, "generation-2")).toThrow("unqualified");
expect(() => validateInvitationSourceProof(proof, expected, bytes, undefined, health)).toThrow("unqualified");
expect(() => validateInvitationSourceProof(proof, expected, bytes, process, { serverInfo: { processStartedAt: "2026-10-27T10:00:00Z" } })).toThrow("unqualified");
});
});
const processReader = vi.hoisted(() => vi.fn());
vi.mock("../process-tree.js", () => ({ readProcessTable: processReader }));
const directories: string[] = [];
afterEach(async () => { await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))); });
async function sourceFixture() {
const directory = await realpath(await mkdtemp(path.join(tmpdir(), "invitation-source-proof-test-"))); directories.push(directory);
const sourceRoot = path.join(directory, "source"), temporaryRoot = path.join(directory, "private");
await mkdir(path.join(sourceRoot, "server/src/middleware"), { recursive: true }); await mkdir(path.join(temporaryRoot, "control"), { recursive: true, mode: 0o700 });
await writeFile(path.join(sourceRoot, "server/src/index.ts"), "export {};\n");
await writeFile(path.join(sourceRoot, "server/src/middleware/redact-sensitive.ts"), "export const fixture = true;\n");
processReader.mockResolvedValue([process]);
const expected = { temporaryRoot, sourceRoot, apiOrigin: "http://127.0.0.1:3100", attemptId: "attempt-1", executionId: "case-1" };
const spawnedAt = new Date(Date.now() - 1000).toISOString();
await publishInvitationSourceProof({ ...expected, executionIds: [expected.executionId], pid: 100,
argv: runnerE2ETypeScriptProcessArgs(sourceRoot, path.join(sourceRoot, "tests/runner-e2e/server-entry.ts"), ["onboard", "--yes", "--run"]), spawnedAt });
return { ...expected, filename: invitationSourceProofPath(temporaryRoot), health: async () => ({ commit: null, serverInfo: { processStartedAt: new Date().toISOString() } }) };
}
describe("attempt-private supervisor receipt custody", () => {
it("re-reads real receipt bytes and rejects a forged fixed-logger claim", async () => {
const f = await sourceFixture(); await expect(verifyInvitationSourceProof(f)).resolves.toMatchObject({ apiOrigin: f.apiOrigin });
const receipt = JSON.parse(await readFile(f.filename, "utf8")); receipt.redactorSha256 = "forged-fixed-logger";
await writeFile(f.filename, JSON.stringify(receipt), { mode: 0o600 });
await expect(verifyInvitationSourceProof(f)).rejects.toThrow("unqualified");
});
it("rejects another attempt, a dead/reused process and an older restart receipt", async () => {
const f = await sourceFixture(); await expect(verifyInvitationSourceProof({ ...f, attemptId: "foreign-attempt" })).rejects.toThrow("unqualified");
processReader.mockResolvedValue([{ ...process, started: "a different process start" }]); await expect(verifyInvitationSourceProof(f)).rejects.toThrow("unqualified");
processReader.mockResolvedValue([process]);
await writeFile(path.join(f.temporaryRoot, "control/server-restart.ack.json"), JSON.stringify({ status: "ready", invitationSourceGeneration: "newer-generation" }));
await expect(verifyInvitationSourceProof(f)).rejects.toThrow("unqualified");
});
});
@@ -0,0 +1,67 @@
import { createHash, randomUUID } from "node:crypto";
import { lstat, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { runnerE2EServerControlPaths } from "../harness-env.js";
import { readProcessTable, type ProcessObservation } from "../process-tree.js";
import { runnerE2ETypeScriptProcessArgs } from "../web-server-command.js";
import { invitationIncident, type InvitationRuntimeProof } from "./identity-invite-prerequisite.js";
const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
export const invitationSourceProofPath = (root: string) => path.join(runnerE2EServerControlPaths(root).controlDirectory, "invitation-source-process.json");
interface SourceProof {
schema: "invitation-source-process.v1"; generation: string; attemptId: string; executionIds: string[]; apiOrigin: string;
sourceRoot: string; serverEntry: string; serverEntrySha256: string; redactorSha256: string;
argv: string[]; pid: number; parentPid: number; processStarted: string; spawnedAt: string;
}
interface Expected { temporaryRoot: string; sourceRoot: string; apiOrigin: string; attemptId: string; executionId: string }
async function sourceBytes(root: string) {
const serverEntry = await realpath(path.join(root, "server/src/index.ts"));
return { serverEntry, serverEntrySha256: digest(await readFile(serverEntry)),
redactorSha256: digest(await readFile(path.join(root, "server/src/middleware/redact-sensitive.ts"))) };
}
export function validateInvitationSourceProof(value: unknown, expected: Expected, bytes: Awaited<ReturnType<typeof sourceBytes>>,
process: ProcessObservation | undefined, health: { commit?: string | null; serverInfo?: { processStartedAt?: string } }, restartGeneration?: string): asserts value is SourceProof {
const p = value as SourceProof;
const boot = Date.parse(health.serverInfo?.processStartedAt ?? ""), spawn = Date.parse(p?.spawnedAt ?? "");
if (!expected.attemptId || !expected.executionId || !p || p.schema !== "invitation-source-process.v1" || !p.generation || restartGeneration !== undefined && p.generation !== restartGeneration || p.attemptId !== expected.attemptId
|| p.apiOrigin !== expected.apiOrigin || p.sourceRoot !== expected.sourceRoot || p.serverEntry !== bytes.serverEntry
|| p.serverEntrySha256 !== bytes.serverEntrySha256 || p.redactorSha256 !== bytes.redactorSha256
|| !Array.isArray(p.executionIds) || p.executionIds.length !== 1 || p.executionIds[0] !== expected.executionId
|| JSON.stringify(p.argv) !== JSON.stringify(runnerE2ETypeScriptProcessArgs(expected.sourceRoot, path.join(expected.sourceRoot, "tests/runner-e2e/server-entry.ts"), ["onboard", "--yes", "--run"]))
|| !process || process.pid !== p.pid || process.parentPid !== p.parentPid || process.started !== p.processStarted
|| !Number.isSafeInteger(p.pid) || p.pid <= 1 || p.parentPid <= 1 || !Number.isFinite(boot) || !Number.isFinite(spawn)
|| boot < spawn - 2000 || boot > Date.now() || spawn > Date.now()) throw new Error("Invitation source process proof is unqualified");
}
/** The supervisor alone writes this private receipt from its actual spawn. */
export async function publishInvitationSourceProof(input: Omit<Expected, "executionId"> & { executionIds: string[]; pid: number; argv: string[]; spawnedAt: string }) {
const filename = invitationSourceProofPath(input.temporaryRoot);
await rm(filename, { force: true });
const processes = await readProcessTable(); const process = processes?.find(row => row.pid === input.pid);
if (!process) return;
const sourceRoot = await realpath(input.sourceRoot); const bytes = await sourceBytes(sourceRoot);
const receipt: SourceProof = { schema: "invitation-source-process.v1", generation: randomUUID(), attemptId: input.attemptId,
executionIds: input.executionIds, apiOrigin: input.apiOrigin, sourceRoot, ...bytes, argv: input.argv, pid: process.pid,
parentPid: process.parentPid, processStarted: process.started, spawnedAt: input.spawnedAt };
const temporary = `${filename}.${randomUUID()}.tmp`;
try { await writeFile(temporary, JSON.stringify(receipt), { mode: 0o600 }); await rename(temporary, filename); }
finally { await rm(temporary, { force: true }); }
return receipt.generation;
}
/** Every enrollment re-reads the current generation, process and source bytes. */
export async function verifyInvitationSourceProof(input: Expected & { health(): Promise<{ commit?: string | null; serverInfo?: { processStartedAt?: string } }> }): Promise<InvitationRuntimeProof> {
const filename = invitationSourceProofPath(input.temporaryRoot); const metadata = await lstat(filename);
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.mode & 0o077 || process.getuid && metadata.uid !== process.getuid()) throw new Error("Invitation source proof custody is invalid");
const encoded = await readFile(filename); const value = JSON.parse(encoded.toString("utf8")) as unknown;
const sourceRoot = await realpath(input.sourceRoot); const bytes = await sourceBytes(sourceRoot);
const table = await readProcessTable(); const health = await input.health();
let restartGeneration: string | undefined;
try {
const ack = JSON.parse(await readFile(runnerE2EServerControlPaths(input.temporaryRoot).restartAcknowledgementPath, "utf8")) as { status?: string; invitationSourceGeneration?: string };
if (ack.status !== "ready" || !ack.invitationSourceGeneration) throw new Error("Invitation restart proof is unqualified");
restartGeneration = ack.invitationSourceGeneration;
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
validateInvitationSourceProof(value, { ...input, sourceRoot }, bytes, table?.find(row => row.pid === (value as SourceProof)?.pid), health, restartGeneration);
if (digest(await readFile(filename)) !== digest(encoded)) throw new Error("Invitation source proof changed during verification");
return { apiOrigin: value.apiOrigin, processStartedAt: health.serverInfo!.processStartedAt!, healthCommit: health.commit ?? null,
redactorSha256: value.redactorSha256, serverEntrySha256: value.serverEntrySha256,
supervisorReceiptSha256: digest(encoded), priorProofSha256: invitationIncident.priorProofSha256 };
}
+8
View File
@@ -1,3 +1,5 @@
import { bindInvitationEnrollmentPrerequisite } from "./chat-catalog-domains/identity-invite-prerequisite.js";
import { verifyInvitationSourceProof } from "./chat-catalog-domains/identity-invite-source-proof.js";
import { readChatPrivateInputs, chatPrivateSetupBlockers, chatPrivateInputRequirements } from "./chat-private-inputs.js";
import { trackedPrivateSecrets } from "./private-diagnostics.js";
import { findChatCatalogCase } from "./chat-catalog-registry.js";
@@ -929,6 +931,12 @@ for (const execution of executions) {
await saveTrace();
const catalogDescriptor = execution.suite.id === "chat-features" ? findChatCatalogCase(execution.task.id) : undefined;
if (catalogDescriptor) {
bindInvitationEnrollmentPrerequisite(api, {
verifyRuntime: () => verifyInvitationSourceProof({ temporaryRoot, sourceRoot: path.resolve(import.meta.dirname, "../.."),
apiOrigin: api.baseURL, attemptId: process.env.PAPERCLIP_E2E_ACCOUNTING_ATTEMPT_ID ?? "", executionId: execution.id,
health: () => api.get("/api/health") }),
evidence: completionEvidence,
});
const blockers = chatPrivateSetupBlockers(privateInputs, catalogDescriptor);
if (blockers.length) {
failureClassOverride = "permanent_infrastructure";
+14 -2
View File
@@ -1,3 +1,4 @@
import { publishInvitationSourceProof, invitationSourceProofPath } from "./chat-catalog-domains/identity-invite-source-proof.js";
import { readSelectedVaultServerEnvironment } from "./chat-vault-private-server.js";
import { readValidatedPublicOriginReceipt, publicOriginServerOverrides, PUBLIC_ORIGIN_RECEIPT_ENV, PUBLIC_ORIGIN_CONTROL_ENV } from "./chat-public-origin.js";
import { dockerDatabaseSupervisorEnvironment } from "./docker-postgres.js";
@@ -17,7 +18,7 @@ import { qualifyLegacyClaudeCli } from "./legacy-claude-cli.js";
import { prepareSourceServerDependencies } from "./source-startup.js";
import { spawn, type ChildProcess } from "node:child_process";
import { createWriteStream } from "node:fs";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { prepareRunnerE2EServerConfig } from "./server-config.js";
import { installedReleaseEnvironment, installedReleaseLaunch } from "./installed-release.js";
@@ -194,6 +195,7 @@ function describeChildExit(candidate: ChildProcess) {
return `server exited code=${String(candidate.exitCode)} signal=${String(candidate.signalCode)}`;
}
let invitationSourceGeneration: string | undefined;
async function startServer() {
if (shutdownRequested()) {
throw new Error("Refusing to start Paperclip after wrapper shutdown");
@@ -201,9 +203,13 @@ async function startServer() {
if (process.env[CHAT_INSTALL_PLAN]) refreshInstalledChatPins(process.env, selectedExecutions[0]!.id);
// Recheck bytes and dependency resolution on every controller restart.
const installed = await verifyInstalledCli(process.env, selectedExecutions);
invitationSourceGeneration = undefined;
await rm(invitationSourceProofPath(temporaryRoot), { force: true });
const sourceArgs = runnerE2ETypeScriptProcessArgs(repositoryRoot, paperclipCli, ["onboard", "--yes", "--run"]);
const sourceSpawnedAt = new Date().toISOString();
const candidate = spawn(
process.execPath,
installed ? [installed.entry, "onboard", "--yes", "--run"] : runnerE2ETypeScriptProcessArgs(repositoryRoot, paperclipCli, ["onboard", "--yes", "--run"]),
installed ? [installed.entry, "onboard", "--yes", "--run"] : sourceArgs,
{
cwd: installedRelease?.cwd ?? repositoryRoot,
env: definedServerEnvironment,
@@ -248,6 +254,11 @@ async function startServer() {
void stopWithFeedback(candidate, shutdownSignal).catch(() => {});
}
await stopServer.watch(candidate);
const invitationAttemptId = process.env.PAPERCLIP_E2E_ACCOUNTING_ATTEMPT_ID;
if (!installed && candidate.pid && invitationAttemptId) {
invitationSourceGeneration = await publishInvitationSourceProof({ temporaryRoot, sourceRoot: repositoryRoot, apiOrigin: `http://127.0.0.1:${port}`,
attemptId: invitationAttemptId, executionIds, pid: candidate.pid, argv: sourceArgs, spawnedAt: sourceSpawnedAt });
}
return candidate;
}
@@ -365,6 +376,7 @@ async function writeRestartAck(
requestId,
status,
completedAt: new Date().toISOString(),
...(invitationSourceGeneration ? { invitationSourceGeneration } : {}),
...(message ? { message } : {}),
...(crashBoundary ? { crashBoundary } : {}),
})}\n`,