diff --git a/tests/runner-e2e/chat-catalog-domains/identity-auth.ts b/tests/runner-e2e/chat-catalog-domains/identity-auth.ts index 0595e64474c..0753205ce4a 100644 --- a/tests/runner-e2e/chat-catalog-domains/identity-auth.ts +++ b/tests/runner-e2e/chat-catalog-domains/identity-auth.ts @@ -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>, 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(); diff --git a/tests/runner-e2e/chat-catalog-domains/identity-invite-prerequisite.test.ts b/tests/runner-e2e/chat-catalog-domains/identity-invite-prerequisite.test.ts new file mode 100644 index 00000000000..2476ac568a1 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/identity-invite-prerequisite.test.ts @@ -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, 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>; + 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(); + }); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/identity-invite-prerequisite.ts b/tests/runner-e2e/chat-catalog-domains/identity-invite-prerequisite.ts new file mode 100644 index 00000000000..048121173e8 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/identity-invite-prerequisite.ts @@ -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; + evidence(name: string, receipt: InvitationPrerequisiteReceipt): Promise; +} +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(); +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) { + 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); +} diff --git a/tests/runner-e2e/chat-catalog-domains/identity-invite-source-proof.test.ts b/tests/runner-e2e/chat-catalog-domains/identity-invite-source-proof.test.ts new file mode 100644 index 00000000000..473f06315a5 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/identity-invite-source-proof.test.ts @@ -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"); + }); +}); diff --git a/tests/runner-e2e/chat-catalog-domains/identity-invite-source-proof.ts b/tests/runner-e2e/chat-catalog-domains/identity-invite-source-proof.ts new file mode 100644 index 00000000000..df6297c2169 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/identity-invite-source-proof.ts @@ -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>, + 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 & { 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 { + 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 }; +} diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index 27f1def2473..c1585c44756 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -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"; diff --git a/tests/runner-e2e/server.ts b/tests/runner-e2e/server.ts index a60c3a5d6fe..fe36f6f3b69 100644 --- a/tests/runner-e2e/server.ts +++ b/tests/runner-e2e/server.ts @@ -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`,