From ea6dc42d2b1ccc5ddfed61c53837f65dd45cb127 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 27 Oct 2026 01:31:43 -0500 Subject: [PATCH] Add finite owned public HTTPS origin handoff for chat evals --- tests/runner-e2e/chat-catalog-fingerprint.ts | 7 +- tests/runner-e2e/chat-public-origin.test.ts | 62 ++++++++ tests/runner-e2e/chat-public-origin.ts | 148 +++++++++++++++++++ tests/runner-e2e/launch.ts | 10 +- tests/runner-e2e/public-mcp-flow.ts | 32 +++- tests/runner-e2e/server.ts | 10 ++ 6 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 tests/runner-e2e/chat-public-origin.test.ts create mode 100644 tests/runner-e2e/chat-public-origin.ts diff --git a/tests/runner-e2e/chat-catalog-fingerprint.ts b/tests/runner-e2e/chat-catalog-fingerprint.ts index fe8673f9506..5e05b05fa69 100644 --- a/tests/runner-e2e/chat-catalog-fingerprint.ts +++ b/tests/runner-e2e/chat-catalog-fingerprint.ts @@ -1,3 +1,4 @@ +import { publicOriginFingerprintIdentity } from "./chat-public-origin.js"; import { databaseFingerprintIdentity } from "./docker-postgres.js"; import { verifyRetainedChatOracleIdentity } from "./chat-private-inputs.js"; import { installedChatFingerprintIdentity, installedChatScenarioIds } from "./chat-installed-lane.js"; @@ -112,11 +113,13 @@ export function chatCatalogCaseFingerprint(descriptor: ChatCatalogDescriptor, se const attachmentFormatter = descriptor.queryAttachments?.length ? ["ui/src/components/IssueChatThread.tsx", "ui/src/components/task-chat/TaskChatComposer.tsx"].map(file => ({ file, sha256: hash(readFileSync(new URL(`../../${file}`, import.meta.url), "utf8")), })) : null; + const originIdentity = publicOriginFingerprintIdentity((selectedExecution as { executionId: string }).executionId, descriptor.scenarioId); + const originReaders = originIdentity.selected ? ["server/src/config.ts", "server/src/auth/better-auth.ts", "server/src/routes/tool-access.ts", "server/src/services/chat-task-url.ts", "server/src/services/muse-setup.ts", "server/src/services/workspace-runtime.ts"] : []; const result = fingerprintChatDependencyClosure({ root, entries: [selectedModule, "tests/runner-e2e/chat-catalog-flow.ts", "tests/runner-e2e/chat-catalog-fingerprint.ts", "tests/runner-e2e/chat-catalog-dynamic-resolutions.json", "tests/runner-e2e/chat-feature-flow.ts", "tests/runner-e2e/runner.spec.ts", "tests/runner-e2e/launch.ts", - "tests/runner-e2e/chat-scenarios/defaults.yaml", ...descriptor.sourceFiles.map(file => `tests/runner-e2e/${file}`)], - selected: { descriptor, selectedExecution, lane, attachmentFormatter, privateInputRequirements: chatPrivateInputRequirements(descriptor), privateInputNameIdentity, installedIdentity, scenario: { id, capabilities, context, query, expected_outcome } }, + "tests/runner-e2e/chat-scenarios/defaults.yaml", ...originReaders, ...descriptor.sourceFiles.map(file => `tests/runner-e2e/${file}`)], + selected: { descriptor, selectedExecution, lane, publicOriginIdentity: originIdentity, attachmentFormatter, privateInputRequirements: chatPrivateInputRequirements(descriptor), privateInputNameIdentity, installedIdentity, scenario: { id, capabilities, context, query, expected_outcome } }, reviewedDynamicResolutions, selectedDispatchFile: "tests/runner-e2e/chat-catalog-registry.ts", normalizedMetadata: { "tests/runner-e2e/chat-catalog-descriptors.json": descriptor, diff --git a/tests/runner-e2e/chat-public-origin.test.ts b/tests/runner-e2e/chat-public-origin.test.ts new file mode 100644 index 00000000000..267a773ffc8 --- /dev/null +++ b/tests/runner-e2e/chat-public-origin.test.ts @@ -0,0 +1,62 @@ +import { mkdtemp, writeFile, chmod, rm, symlink, realpath } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { publicHttpsOrigin, publicOriginReceiptMatches, publicProxySocketMatches, publicOriginReleaseMatches, samePublicProxyIdentity, publicOriginServerOverrides, publicOriginFingerprintIdentity, prepareOwnedPublicOrigin, readValidatedPublicOriginReceipt, type PublicOriginRequest, type ValidatedPublicOriginReceipt } from "./chat-public-origin.js"; +const request: PublicOriginRequest = { schema: "paperclip.chat-public-origin-request.v1", executionId: "chat-catalog.agents.existing-grok-bot.codex", scenarioId: "agents.existing-grok-bot", attemptId: "a".repeat(32), backendOrigin: "http://127.0.0.1:42123", nonce: "b".repeat(48) }; +const receipt: ValidatedPublicOriginReceipt = { ...request, schema: "paperclip.chat-public-origin.v1", publicOrigin: "https://attempt.example.test", routeVerifiedAt: "2026-10-27T12:00:00.000Z", proxy: { pid: 999999, uid: process.getuid!(), startedAt: "2026-10-27T11:59:00.000Z", executable: "/owned/proxy", executableSha256: "c".repeat(64), commandSha256: "d".repeat(64), workingDirectory: "/owned/private/control" } }; +describe("finite owned public-origin handoff", () => { + it("accepts exact independently recorded attempt binding, rejects stale attempt/nonce/source", () => { + expect(publicOriginReceiptMatches(receipt, request)).toBe(true); + for (const mutation of [{ attemptId: "e".repeat(32) }, { nonce: "e".repeat(48) }, { backendOrigin: "http://127.0.0.1:42124" }, { executionId: "another.agents.existing-grok-bot" }, { scenarioId: "goals.list" }]) expect(publicOriginReceiptMatches({ ...receipt, ...mutation }, request)).toBe(false); + expect(publicOriginReceiptMatches({ ...receipt, undeclared: "input" } as typeof receipt, request)).toBe(false); + }); + it("forbids HTTP, embedded credentials, alternate paths and loopback public origins", () => { + expect(publicHttpsOrigin(receipt.publicOrigin)).toBe(true); + for (const origin of ["http://attempt.example.test", "https://user:password@attempt.example.test", "https://attempt.example.test/path", "https://127.0.0.1", "https://localhost", "https://sub.localhost", "not a URL"]) expect(publicHttpsOrigin(origin)).toBe(false); + }); + it("pins process start, owner, executable, command and owned cwd rather than PID alone", () => { + expect(samePublicProxyIdentity({ ...receipt.proxy }, receipt.proxy)).toBe(true); + for (const mutation of [{ startedAt: "2026-10-27T12:00:01.000Z" }, { uid: receipt.proxy.uid + 1 }, { executableSha256: "e".repeat(64) }, { commandSha256: "f".repeat(64) }, { workingDirectory: "/other" }]) expect(samePublicProxyIdentity({ ...receipt.proxy, ...mutation }, receipt.proxy)).toBe(false); + expect(samePublicProxyIdentity(undefined, receipt.proxy)).toBe(false); + expect(publicOriginReceiptMatches({ ...receipt, proxy: { ...receipt.proxy, pid: process.pid } }, request)).toBe(false); + }); + it("requires the actual proxy's exact established loopback socket", () => { + expect(publicProxySocketMatches("p9123\nn127.0.0.1:53555->127.0.0.1:42123\n", 53555, 42123)).toBe(true); + for (const line of ["n127.0.0.1:53556->127.0.0.1:42123", "n127.0.0.1:53555->127.0.0.1:42124", "n10.0.0.1:53555->127.0.0.1:42123", "n127.0.0.1:42123->127.0.0.1:53555"]) expect(publicProxySocketMatches(line, 53555, 42123)).toBe(false); + }); + it("rejects release without exact route and proxy teardown attestation", () => { + const release = { schema: "paperclip.chat-public-origin-release.v1", executionId: request.executionId, attemptId: request.attemptId, nonce: request.nonce, publicRouteReleased: true, proxyReleased: true }; + expect(publicOriginReleaseMatches(release, request)).toBe(true); + for (const mutation of [{ attemptId: "f".repeat(32) }, { nonce: "f".repeat(48) }, { publicRouteReleased: false }, { proxyReleased: false }, { extra: true }]) expect(publicOriginReleaseMatches({ ...release, ...mutation }, request)).toBe(false); + }); + it("refuses forged/stale private receipts before inspecting an unrelated process", async () => { + const directory = await realpath(await mkdtemp(path.join(tmpdir(), "origin-control-"))); + try { + await chmod(directory, 0o700); + const filename = path.join(directory, "public-origin.json"); + await writeFile(filename, JSON.stringify({ ...receipt, attemptId: "f".repeat(32) }), { mode: 0o600 }); + await expect(readValidatedPublicOriginReceipt({ ...request, receiptPath: filename })).rejects.toThrow("no longer belongs"); + await chmod(filename, 0o644); + await expect(readValidatedPublicOriginReceipt({ ...request, receiptPath: filename })).rejects.toThrow("Invalid private"); + const alias = path.join(directory, "alias.json"); await symlink(filename, alias); + await expect(readValidatedPublicOriginReceipt({ ...request, receiptPath: alias })).rejects.toThrow(); + } finally { await rm(directory, { recursive: true, force: true }); } + }); + it("refuses a configured public route on a nonallowlisted case before writing a request", async () => { + const directory = await realpath(await mkdtemp(path.join(tmpdir(), "origin-control-"))); + try { + await chmod(directory, 0o700); + await expect(prepareOwnedPublicOrigin({ ...request, executionId: "goals.list", scenarioId: "goals.list", controlDirectory: directory, temporaryRoot: directory, privateSecrets: [] })).rejects.toThrow("forbidden outside"); + } finally { await rm(directory, { recursive: true, force: true }); } + }); + it("preserves default source isolation and changes only finite public advertisements", async () => { + expect(await prepareOwnedPublicOrigin({ ...request, temporaryRoot: "/never-used", privateSecrets: [] })).toBeUndefined(); + expect(publicOriginServerOverrides(undefined)).toEqual({}); + expect(publicOriginServerOverrides(receipt)).toEqual({ PAPERCLIP_PUBLIC_URL: receipt.publicOrigin, PAPERCLIP_AUTH_PUBLIC_BASE_URL: receipt.publicOrigin, PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL: receipt.publicOrigin, PAPERCLIP_ALLOWED_HOSTNAMES: "127.0.0.1" }); + expect(publicOriginFingerprintIdentity("goals.list", "goals.list", {}).selected).toBe(false); + const identity = publicOriginFingerprintIdentity(request.executionId, request.scenarioId, { PAPERCLIP_CHAT_EVAL_PUBLIC_ORIGIN_CONTROL_DIR: "/private/never-retained" }); + expect(identity.selected && identity.configured).toBe(true); + expect(JSON.stringify(identity)).not.toContain("/private/never-retained"); + }); +}); diff --git a/tests/runner-e2e/chat-public-origin.ts b/tests/runner-e2e/chat-public-origin.ts new file mode 100644 index 00000000000..08ce20c8ecb --- /dev/null +++ b/tests/runner-e2e/chat-public-origin.ts @@ -0,0 +1,148 @@ +import { constants } from "node:fs"; +import { lstat, open, realpath, readFile, writeFile } from "node:fs/promises"; +import { createHash, randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import path from "node:path"; +export class PublicOriginCleanupError extends Error {} +export const PUBLIC_ORIGIN_CONTROL_ENV = "PAPERCLIP_CHAT_EVAL_PUBLIC_ORIGIN_CONTROL_DIR"; +export const PUBLIC_ORIGIN_RECEIPT_ENV = "PAPERCLIP_RUNNER_E2E_OWNED_PUBLIC_ORIGIN_RECEIPT"; +export const ownedPublicOriginScenarios = ["agents.existing-grok-bot", "assistant-connections.connect-external-assistant", "connection-setup.notion-missing", "connection-setup.notion-wrong-account", "connection-setup.notion-expired", "connection-setup.return-from-login", "connection-setup.save-and-resume"] as const; +export interface PublicOriginIdentity { pid: number; uid: number; startedAt: string; executable: string; executableSha256: string; commandSha256: string; workingDirectory: string } +export interface PublicOriginRequest { schema: "paperclip.chat-public-origin-request.v1"; executionId: string; scenarioId: string; attemptId: string; backendOrigin: string; nonce: string } +export interface ValidatedPublicOriginReceipt { schema: "paperclip.chat-public-origin.v1"; executionId: string; scenarioId: string; attemptId: string; backendOrigin: string; publicOrigin: string; nonce: string; proxy: PublicOriginIdentity; routeVerifiedAt: string } +const hash = (value: string | Buffer) => createHash("sha256").update(value).digest("hex"); +const exactKeys = (value: unknown, keys: readonly string[]) => !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).sort().join() === [...keys].sort().join(); +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +export function publicHttpsOrigin(value: string) { try { const u = new URL(value); return u.protocol === "https:" && u.origin === value && !u.username && !u.password && !["localhost", "127.0.0.1", "[::1]"].includes(u.hostname) && !u.hostname.endsWith(".localhost"); } catch { return false; } } +function validRequest(r: PublicOriginRequest) { try { const url = new URL(r.backendOrigin); return exactKeys(r, ["schema", "executionId", "scenarioId", "attemptId", "backendOrigin", "nonce"]) && r.schema === "paperclip.chat-public-origin-request.v1" && ownedPublicOriginScenarios.includes(r.scenarioId as typeof ownedPublicOriginScenarios[number]) && r.executionId.includes(r.scenarioId) && /^[a-f0-9]{32}$/.test(r.attemptId) && /^[a-f0-9]{48}$/.test(r.nonce) && url.protocol === "http:" && url.hostname === "127.0.0.1" && url.origin === r.backendOrigin && Number(url.port) > 1024; } catch { return false; } } +export function publicOriginReceiptMatches(r: ValidatedPublicOriginReceipt, expected: Pick & Partial>) { + const p = r?.proxy; + return exactKeys(r, ["schema", "executionId", "scenarioId", "attemptId", "backendOrigin", "publicOrigin", "nonce", "proxy", "routeVerifiedAt"]) && r.schema === "paperclip.chat-public-origin.v1" && validRequest({ schema: "paperclip.chat-public-origin-request.v1", executionId: r.executionId, scenarioId: r.scenarioId, attemptId: r.attemptId, backendOrigin: r.backendOrigin, nonce: r.nonce }) + && r.executionId === expected.executionId && r.attemptId === expected.attemptId && r.backendOrigin === expected.backendOrigin && (!expected.scenarioId || r.scenarioId === expected.scenarioId) && (!expected.nonce || r.nonce === expected.nonce) + && publicHttpsOrigin(r.publicOrigin) && Number.isFinite(Date.parse(r.routeVerifiedAt)) && exactKeys(p, ["pid", "uid", "startedAt", "executable", "executableSha256", "commandSha256", "workingDirectory"]) + && Number.isSafeInteger(p.pid) && p.pid > 1 && p.pid !== process.pid && p.uid === process.getuid?.() && Number.isFinite(Date.parse(p.startedAt)) && path.isAbsolute(p.executable) && path.isAbsolute(p.workingDirectory) && /^[a-f0-9]{64}$/.test(p.executableSha256) && /^[a-f0-9]{64}$/.test(p.commandSha256); +} +async function privateDirectory(directory: string) { const info = await lstat(directory); if (!info.isDirectory() || info.isSymbolicLink() || info.mode & 0o077 || info.uid !== process.getuid?.() || await realpath(directory) !== directory) throw new Error("Public origin control requires an owned canonical private0700 directory"); } +async function privateJson(filename: string): Promise { + await privateDirectory(path.dirname(filename)); + const fd = await open(filename, constants.O_RDONLY | constants.O_NOFOLLOW); + try { const before = await fd.stat(); if (!before.isFile() || before.nlink !== 1 || before.mode & 0o077 || before.uid !== process.getuid?.() || before.size > 32768 || await realpath(filename) !== filename) throw new Error("Invalid private public-origin receipt file"); + const bytes = await fd.readFile(), after = await fd.stat(), named = await lstat(filename); + if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.mtimeMs !== after.mtimeMs || named.ino !== before.ino || named.dev !== before.dev) throw new Error("Public-origin receipt changed during read"); + return JSON.parse(bytes.toString("utf8")) as T; + } finally { await fd.close(); } +} +/** No raw process arguments or provider credentials leave this local reader. */ +export async function readPublicProxyIdentity(pid: number): Promise { + if (!Number.isSafeInteger(pid) || pid <= 1 || pid === process.pid || !process.getuid) throw new Error("Invalid public proxy process identity"); + try { process.kill(pid, 0); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return undefined; throw new Error("Public proxy ownership could not be independently read"); } + const field = async (name: string) => (await promisify(execFile)("/bin/ps", ["-p", String(pid), "-o", `${name}=`], { timeout: 5000, maxBuffer: 65536 })).stdout.trim(); + try { + const [uid, started, command, executableName] = await Promise.all([field("uid"), field("lstart"), field("args"), field("comm")]); + if (!command || !Number.isFinite(Date.parse(started))) throw new Error("Missing process identity"); + const executable = await realpath(executableName), stat = await lstat(executable); if (!stat.isFile() || stat.size > 134217728) throw new Error("Unbounded public proxy executable"); + const workingDirectory = process.platform === "linux" ? await realpath(`/proc/${pid}/cwd`) : (await promisify(execFile)("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { timeout: 5000, maxBuffer: 16384 })).stdout.split("\n").find(line => line.startsWith("n"))?.slice(1); + if (!workingDirectory) throw new Error("Missing actual proxy working directory"); + return { pid, uid: Number(uid), startedAt: new Date(started).toISOString(), executable, executableSha256: hash(await readFile(executable)), commandSha256: hash(command), workingDirectory: await realpath(workingDirectory) }; + } catch { throw new Error("Public proxy identity inspection failed; private process diagnostics withheld"); } +} +export const samePublicProxyIdentity = (a: PublicOriginIdentity | undefined, b: PublicOriginIdentity) => !!a && Object.keys(b).every(key => a[key as keyof PublicOriginIdentity] === b[key as keyof PublicOriginIdentity]); +/** Finite coordinator handoff. Caller owns configuring/releasing the actual + * already-authorized route/process; this helper never creates a public tunnel. */ +export async function publishPublicOriginRoute(input: { controlDirectory: string; publicOrigin: string; proxyPid: number }) { + const request = await privateJson(path.join(input.controlDirectory, "request.json")); + if (!validRequest(request) || !publicHttpsOrigin(input.publicOrigin)) throw new Error("Invalid selected owned public route request"); + const proxy = await readPublicProxyIdentity(input.proxyPid); + if (!proxy || proxy.uid !== process.getuid?.() || !proxy.workingDirectory.startsWith(`${input.controlDirectory}${path.sep}`) && proxy.workingDirectory !== input.controlDirectory || !samePublicProxyIdentity(await readPublicProxyIdentity(input.proxyPid), proxy)) throw new Error("Public proxy must be the exact coordinator-owned process rooted in the private handoff directory"); + await writeFile(path.join(input.controlDirectory, "route.json"), JSON.stringify({ ...request, publicOrigin: input.publicOrigin, proxy }), { mode: 0o600, flag: "wx" }); + return { published: true, executionId: request.executionId, attemptId: request.attemptId }; +} +/** After actual separately owned proxy/route teardown, publish its closed + * completion. This helper never signals a process. The launcher also rechecks. */ +export async function publishPublicOriginRelease(input: { controlDirectory: string; publicRouteReleased: true }) { + const request = await privateJson(path.join(input.controlDirectory, "request.json")); + const release = await privateJson(path.join(input.controlDirectory, "release-request.json")); + const route = await privateJson(path.join(input.controlDirectory, "route.json")); + if (!validRequest(request) || release.backendShutdown !== true || !exactKeys(release, [...Object.keys(request), "backendShutdown"]) || Object.entries(request).some(([key, value]) => release[key as keyof typeof release] !== value || route[key as keyof typeof route] !== value) || !exactKeys(route, [...Object.keys(request), "publicOrigin", "proxy"]) || !publicHttpsOrigin(route.publicOrigin) || input.publicRouteReleased !== true) throw new Error("Public-origin release does not belong to the completed backend attempt"); + const live = await readPublicProxyIdentity(route.proxy.pid); + if (live?.startedAt === route.proxy.startedAt) throw new Error("The actual owned public proxy has not exited"); + const backend = await fetch(`${request.backendOrigin}/api/health`, { signal: AbortSignal.timeout(2000), redirect: "error" }).catch(() => undefined); + if (backend) { await backend.body?.cancel(); throw new Error("Refusing a public route release receipt before backend shutdown"); } + await writeFile(path.join(input.controlDirectory, "released.json"), JSON.stringify({ schema: "paperclip.chat-public-origin-release.v1", executionId: request.executionId, attemptId: request.attemptId, nonce: request.nonce, publicRouteReleased: true, proxyReleased: true }), { mode: 0o600, flag: "wx" }); + return { released: true, executionId: request.executionId, attemptId: request.attemptId }; +} +export async function readValidatedPublicOriginReceipt(input: { receiptPath: string; executionId: string; attemptId: string; backendOrigin: string }): Promise { + const receipt = await privateJson(input.receiptPath); + if (!publicOriginReceiptMatches(receipt, input) || !samePublicProxyIdentity(await readPublicProxyIdentity(receipt.proxy.pid), receipt.proxy)) throw new Error("Validated public origin no longer belongs to this exact live attempt/proxy"); + return receipt; +} +async function responseFile(filename: string, deadline: number): Promise { while (Date.now() < deadline) { try { return await privateJson(filename); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } await sleep(250); } throw new Error("Owned public-origin coordinator handoff timed out before backend exposure"); } +export function publicProxySocketMatches(text: string, remotePort: number, backendPort: number) { + return Number.isSafeInteger(remotePort) && remotePort > 0 && Number.isSafeInteger(backendPort) && backendPort > 1024 && text.split("\n").includes(`n127.0.0.1:${remotePort}->127.0.0.1:${backendPort}`); +} +export function publicOriginReleaseMatches(release: unknown, request: PublicOriginRequest) { + const r = release as Record | undefined; + return exactKeys(r, ["schema", "executionId", "attemptId", "nonce", "publicRouteReleased", "proxyReleased"]) && r?.schema === "paperclip.chat-public-origin-release.v1" && r.executionId === request.executionId && r.attemptId === request.attemptId && r.nonce === request.nonce && r.publicRouteReleased === true && r.proxyReleased === true; +} +async function boundedResponse(response: Response) { + const reader = response.body?.getReader(); if (!reader) return ""; + const chunks: Uint8Array[] = []; let size = 0; + try { while (true) { const next = await reader.read(); if (next.done) break; size += next.value.byteLength; if (size > 256) throw new Error("Public-origin challenge response exceeded its finite bound"); chunks.push(next.value); } return Buffer.concat(chunks).toString("utf8"); } + finally { await reader.cancel(); } +} +async function confirmProxySocket(proxy: PublicOriginIdentity, remotePort: number, backendPort: number) { + const text = (await promisify(execFile)("lsof", ["-nP", "-a", "-p", String(proxy.pid), "-iTCP", "-sTCP:ESTABLISHED", "-Fn"], { timeout: 5000, maxBuffer: 65536 })).stdout; + if (!publicProxySocketMatches(text, remotePort, backendPort) || !samePublicProxyIdentity(await readPublicProxyIdentity(proxy.pid), proxy)) throw new Error("Actual public request did not traverse the exact owned proxy process/socket"); +} +export async function prepareOwnedPublicOrigin(input: { controlDirectory?: string; executionId: string; scenarioId: string; attemptId: string; backendOrigin: string; temporaryRoot: string; privateSecrets: string[]; timeoutMs?: number }) { + if (!input.controlDirectory) return undefined; + const root = path.resolve(input.controlDirectory); await privateDirectory(root); + const request: PublicOriginRequest = { schema: "paperclip.chat-public-origin-request.v1", executionId: input.executionId, scenarioId: input.scenarioId, attemptId: input.attemptId, backendOrigin: input.backendOrigin, nonce: randomBytes(24).toString("hex") }; + if (!validRequest(request)) throw new Error("Public origin handoff is forbidden outside its finite selected-case execution"); + input.privateSecrets.push(request.nonce); + await writeFile(path.join(root, "request.json"), JSON.stringify(request), { mode: 0o600, flag: "wx" }); + let receipt: ValidatedPublicOriginReceipt | undefined, released = false; + const close = async () => { + if (released) return; + const backend = await fetch(`${request.backendOrigin}/api/health`, { signal: AbortSignal.timeout(2000), redirect: "error" }).catch(() => undefined); + if (backend) { await backend.body?.cancel(); throw new Error("Public route cleanup requires independently observed backend shutdown"); } + await writeFile(path.join(root, "release-request.json"), JSON.stringify({ ...request, backendShutdown: true }), { mode: 0o600, flag: "wx" }); + const release = await responseFile<{ schema: string; executionId: string; attemptId: string; nonce: string; publicRouteReleased: boolean; proxyReleased: boolean }>(path.join(root, "released.json"), Date.now() + (input.timeoutMs ?? 90000)); + if (!publicOriginReleaseMatches(release, request) || !receipt) throw new Error("Owned public proxy release was not confirmed for this exact attempt"); + const live = await readPublicProxyIdentity(receipt.proxy.pid); if (live && live.startedAt === receipt.proxy.startedAt) throw new Error("Owned per-attempt public proxy remains alive after reported release"); + const response = await fetch(`${receipt.publicOrigin}/.well-known/paperclip-eval-route/${request.nonce}`, { redirect: "error", signal: AbortSignal.timeout(5000) }).catch(() => undefined); + if (response) { const body = await boundedResponse(response); if (body === request.nonce) throw new Error("Old public route remains reachable after reported release"); } + released = true; + }; + try { + const route = await responseFile(path.join(root, "route.json"), Date.now() + (input.timeoutMs ?? 90000)); + if (!exactKeys(route, ["schema", "executionId", "scenarioId", "attemptId", "backendOrigin", "nonce", "publicOrigin", "proxy"]) || route.schema !== request.schema) throw new Error("Public proxy route receipt exceeds its exact request contract"); + receipt = { schema: "paperclip.chat-public-origin.v1", executionId: route.executionId, scenarioId: route.scenarioId, attemptId: route.attemptId, backendOrigin: route.backendOrigin, nonce: route.nonce, publicOrigin: route.publicOrigin, proxy: route.proxy, routeVerifiedAt: new Date().toISOString() }; + if (!publicOriginReceiptMatches(receipt, request) || !samePublicProxyIdentity(await readPublicProxyIdentity(receipt.proxy.pid), receipt.proxy) || !(receipt.proxy.workingDirectory === root || receipt.proxy.workingDirectory.startsWith(`${root}${path.sep}`))) throw new Error("Owned public proxy receipt has stale execution, route or process identity"); + input.privateSecrets.push(receipt.publicOrigin); + const port = Number(new URL(input.backendOrigin).port); let routeObserved = false, requestClaimed = false; + const responder = createServer((req, res) => { + const expected = `/.well-known/paperclip-eval-route/${request.nonce}`; + if (requestClaimed || req.method !== "GET" || req.url !== expected || !["127.0.0.1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress ?? "") || !req.socket.remotePort) { res.writeHead(404); res.end(); return; } + requestClaimed = true; + void confirmProxySocket(receipt!.proxy, req.socket.remotePort, port).then(() => { routeObserved = true; res.writeHead(200, { "content-type": "text/plain", "cache-control": "no-store" }); res.end(request.nonce); }).catch(() => { res.writeHead(403); res.end(); }); + }); + responder.requestTimeout = 10000; responder.headersTimeout = 10000; + try { + await new Promise((resolve, reject) => { responder.once("error", reject); responder.listen(port, "127.0.0.1", resolve); }); + const response = await fetch(`${receipt.publicOrigin}/.well-known/paperclip-eval-route/${request.nonce}`, { redirect: "error", signal: AbortSignal.timeout(15000) }); + if (!response.ok || await boundedResponse(response) !== request.nonce || !routeObserved) throw new Error("Actual HTTPS route failed its independently observed exact-process loopback challenge"); + } finally { responder.closeAllConnections(); await new Promise(resolve => responder.close(() => resolve())); } + if (!samePublicProxyIdentity(await readPublicProxyIdentity(receipt.proxy.pid), receipt.proxy)) throw new Error("Public proxy changed identity after route verification"); + receipt.routeVerifiedAt = new Date().toISOString(); const filename = path.join(input.temporaryRoot, "public-origin.json"); await writeFile(filename, JSON.stringify(receipt), { mode: 0o600, flag: "wx" }); + return { filename, receipt, close }; + } catch (error) { try { await close(); } catch { throw new PublicOriginCleanupError("Public-origin preparation failed and owned proxy/route cleanup is unconfirmed; private control state retained"); } throw error; } +} +export function publicOriginServerOverrides(receipt: ValidatedPublicOriginReceipt | undefined) { + return receipt ? { PAPERCLIP_PUBLIC_URL: receipt.publicOrigin, PAPERCLIP_AUTH_PUBLIC_BASE_URL: receipt.publicOrigin, PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL: receipt.publicOrigin, PAPERCLIP_ALLOWED_HOSTNAMES: "127.0.0.1" } : {}; +} +export function publicOriginFingerprintIdentity(executionId: string, scenarioId: string, env: NodeJS.ProcessEnv = process.env) { + return { selected: ownedPublicOriginScenarios.includes(scenarioId as typeof ownedPublicOriginScenarios[number]), configured: !!env[PUBLIC_ORIGIN_RECEIPT_ENV] || !!env[PUBLIC_ORIGIN_CONTROL_ENV], referenceNames: [PUBLIC_ORIGIN_CONTROL_ENV, PUBLIC_ORIGIN_RECEIPT_ENV], executionId, protocol: "private-attempt-request-exact-proxy-route-and-release-v1" }; +} diff --git a/tests/runner-e2e/launch.ts b/tests/runner-e2e/launch.ts index 6a925dba268..d80bb5cc7f5 100644 --- a/tests/runner-e2e/launch.ts +++ b/tests/runner-e2e/launch.ts @@ -1,3 +1,4 @@ +import { prepareOwnedPublicOrigin, PublicOriginCleanupError, PUBLIC_ORIGIN_CONTROL_ENV, PUBLIC_ORIGIN_RECEIPT_ENV } from "./chat-public-origin.js"; import { DATABASE_LANE_ENV, DOCKER_DATABASE_ENV, dockerDatabaseLaunchState, publicDockerDatabaseIdentity, selectedDatabaseLane, startOwnedDockerDatabase } from "./docker-postgres.js"; import { ARCHIVE_CONTROL_ENV, ARCHIVE_SCENARIO, startArchiveFaultControl } from "./chat-archive-fault-control.js"; import { abnormalFinalizationResult } from "./abnormal-finalization.js"; @@ -515,6 +516,7 @@ async function runAttempt(input: { let processCleanupFailed = false; let startupReceiptPath: string | undefined; let dockerDatabase: Awaited> | undefined; + let ownedPublicOrigin: Awaited>; let archiveControl: Awaited> | undefined; let gateway: Awaited> | undefined; const rawCleanupResults: Array<{ cleanup: string; synthetic: boolean; status: string }> = []; @@ -568,6 +570,8 @@ async function runAttempt(input: { if (needsPrivateInputs && executions.length !== 1) throw new Error("Private case inputs require one explicitly selected execution"); await mkdir(selectedPrivateInputsDirectory, { mode: 0o700 }); const privateInputs = needsPrivateInputs ? stageChatPrivateInputs({ execution, descriptor: selectedDescriptor, privateDir: selectedPrivateInputsDirectory, env: process.env, secrets: credentials }) : undefined; + ownedPublicOrigin = await prepareOwnedPublicOrigin({ controlDirectory: process.env[PUBLIC_ORIGIN_CONTROL_ENV], executionId: execution.id, scenarioId: selectedDescriptor?.scenarioId ?? "", attemptId: accountingAttemptId, backendOrigin: `http://127.0.0.1:${port}`, temporaryRoot, privateSecrets: credentials }); + delete sourceEnvironment[PUBLIC_ORIGIN_CONTROL_ENV]; delete sourceEnvironment[PUBLIC_ORIGIN_RECEIPT_ENV]; if (selectedDescriptor?.scenarioId === ARCHIVE_SCENARIO) archiveControl = await startArchiveFaultControl({ selected: executions.map(row => row.id), temporaryRoot, backendOrigin: `http://127.0.0.1:${port}`, privateSecrets: credentials }); if (databaseLane === "owned-docker-postgres") dockerDatabase = await startOwnedDockerDatabase({ temporaryRoot, privateSecrets: credentials }); const diagnosticRegistry = stagePrivateDiagnostics(temporaryRoot, executions.map(row => row.id), credentials); @@ -580,6 +584,7 @@ async function runAttempt(input: { const childEnv: NodeJS.ProcessEnv = { ...buildRunnerE2EProcessEnvironment(sourceEnvironment, executions), [CHAT_PRIVATE_INPUTS_ENV]: privateInputs?.filename, + [PUBLIC_ORIGIN_RECEIPT_ENV]: ownedPublicOrigin?.filename, [ARCHIVE_CONTROL_ENV]: archiveControl?.filename, [DATABASE_LANE_ENV]: dockerDatabase ? "owned-docker-postgres" : undefined, [DOCKER_DATABASE_ENV]: dockerDatabase?.filename, @@ -913,6 +918,7 @@ async function runAttempt(input: { } return [...publishedResults]; } catch (error) { + if (error instanceof PublicOriginCleanupError) processCleanupFailed = true; // Packaging/diagnostic failures must not erase an actually launched attempt // or its primary startup failure. Retain only closed sanitized metadata; // raw reports stay private when their normal packaging path failed. @@ -939,6 +945,8 @@ async function runAttempt(input: { return [...publishedResults]; } finally { // runProcess has completed its owned Playwright/server process shutdown. + let publicOriginCleanupError: unknown; + try { await ownedPublicOrigin?.close(); } catch (error) { publicOriginCleanupError = error; processCleanupFailed = true; } let archiveCleanupError: unknown; try { await archiveControl?.close(); } catch (error) { archiveCleanupError = error; processCleanupFailed = true; } let dockerCleanupError: unknown; @@ -992,7 +1000,7 @@ async function runAttempt(input: { `Refusing to remove unexpected temporary path ${temporaryRoot}`, ); } - if (gatewayCleanupError || archiveCleanupError || dockerCleanupError) cleanupError = new AggregateError([cleanupError, gatewayCleanupError, archiveCleanupError, dockerCleanupError].filter(Boolean), "Eval owned gateway/transport cleanup failed"); + if (gatewayCleanupError || archiveCleanupError || dockerCleanupError || publicOriginCleanupError) cleanupError = new AggregateError([cleanupError, gatewayCleanupError, archiveCleanupError, dockerCleanupError, publicOriginCleanupError].filter(Boolean), "Eval owned gateway/transport cleanup failed"); if (startupReceiptPath) { const receipt = JSON.parse(await readFile(startupReceiptPath, "utf8")); receipt.status = cleanupError ? "installed_launch_chain_cleanup_failed" : "installed_launch_chain_passed"; diff --git a/tests/runner-e2e/public-mcp-flow.ts b/tests/runner-e2e/public-mcp-flow.ts index caaa5f5b632..772e83919b7 100644 --- a/tests/runner-e2e/public-mcp-flow.ts +++ b/tests/runner-e2e/public-mcp-flow.ts @@ -1,3 +1,8 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { BrowserContext } from "@playwright/test"; +import { readValidatedPublicOriginReceipt, PUBLIC_ORIGIN_RECEIPT_ENV } from "./chat-public-origin.js"; +import { privatePublicSignIn } from "./chat-catalog-domains/external-public-signin.js"; import { redactTransferEvidence } from "./public-mcp-transfer-evidence.js"; import { runExpandedMcpScenario } from "./public-mcp-expanded-flow.js"; import { type AssistantTool } from "./public-mcp-model.js"; @@ -11,13 +16,35 @@ import { isReadOnlyMcpCall, gradeEventFollowUp, gradeDelegation, gradePausedAgen import { eventReceiver, mcpEventRpc, connect, mcp, oauthPost, api as browserApi, type Team, type Task, type Run, type Document, type Comment } from "./public-mcp-client.js"; import { runPublicMcpInvitationFlow } from "./public-mcp-invitation-flow.js"; +const publicFixtureCredentials = new WeakMap(); +/** The same API object is created once in runner.spec, bootstrapped before fixture + * setup, and passed unchanged into ChatFlowInput. A replacement client has no + * authority to retrieve or submit this person's private credentials. */ +export async function signInPublicFixturePerson(api: RunnerApi, target: BrowserContext) { + const person = publicFixtureCredentials.get(api); + if (!person) throw new Error("Public sign-in requires the exact registered fixture API/person object"); + const receiptPath = process.env[PUBLIC_ORIGIN_RECEIPT_ENV], attemptId = process.env.PAPERCLIP_E2E_ACCOUNTING_ATTEMPT_ID; + const executions = JSON.parse(process.env.PAPERCLIP_RUNNER_E2E_EXECUTION_IDS ?? "[]") as string[]; + if (!receiptPath || !attemptId || executions.length !== 1) throw new Error("Public fixture sign-in requires its exact selected attempt's validated origin receipt"); + const receipt = await readValidatedPublicOriginReceipt({ receiptPath, attemptId, executionId: executions[0]!, backendOrigin: api.baseURL }); + return privatePublicSignIn({ receiptPath, attemptId, executionId: receipt.executionId, backendOrigin: api.baseURL, publicOrigin: receipt.publicOrigin, userId: person.userId, email: person.email, password: person.password }, person.secrets, async file => { + const incoming = JSON.parse(await readFile(file, "utf8")) as Awaited>; + const existing = await target.storageState(), hostname = new URL(receipt.publicOrigin).hostname; + // Preserve existing jars as-is. Only a REAL public sign-in contributes + // public-host cookies; no loopback value is renamed/copied to HTTPS. + const merged = { cookies: [...existing.cookies.filter(cookie => cookie.domain.replace(/^\./, "") !== hostname), ...incoming.cookies], origins: [...existing.origins.filter(origin => origin.origin !== receipt.publicOrigin), ...incoming.origins] }; + const mergedFile = path.join(path.dirname(file), "merged-public-session.json"); await writeFile(mergedFile, JSON.stringify(merged), { mode: 0o600, flag: "wx" }); + await target.setStorageState(mergedFile); + }); +} + /** Real first-admin browser bootstrap, no direct fixture DB writes. */ export async function establishPublicMcpSession(api: RunnerApi, page: Page, secrets: string[]) { - const password = randomBytes(32).toString("base64url"); + const password = randomBytes(32).toString("base64url"), email = `mcp-${randomUUID()}@example.test`; secrets.push(password); const response = await fetch(api.baseURL + "/api/auth/sign-up/email", { method: "POST", headers: { "Content-Type": "application/json", Origin: api.baseURL }, - body: JSON.stringify({ name: "MCP evaluation person", email: `mcp-${randomUUID()}@example.test`, password }), + body: JSON.stringify({ name: "MCP evaluation person", email, password }), }); if (!response.ok) throw new Error(`Browser signup failed with HTTP ${response.status}; body withheld`); const user = await response.json() as { user: { id: string } }; @@ -28,6 +55,7 @@ export async function establishPublicMcpSession(api: RunnerApi, page: Page, secr await page.context().addCookies(cookies.map(cookie => ({ name: cookie.slice(0, cookie.indexOf("=")), value: cookie.slice(cookie.indexOf("=") + 1), url: api.baseURL, httpOnly: true, sameSite: "Lax" }))); await api.post("/api/bootstrap/claim"); await api.patch("/api/instance/settings/experimental", { enablePublicMcp: true }); + publicFixtureCredentials.set(api, { userId: user.user.id, email, password, secrets }); return user.user.id; } diff --git a/tests/runner-e2e/server.ts b/tests/runner-e2e/server.ts index 643063ba7fa..a60c3a5d6fe 100644 --- a/tests/runner-e2e/server.ts +++ b/tests/runner-e2e/server.ts @@ -1,3 +1,5 @@ +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"; import { archiveServerEnvironment } from "./chat-archive-fault-control.js"; import { CHAT_INSTALL_PLAN, refreshInstalledChatPins, publishInstalledChatReceipt } from "./chat-installed-lane.js"; @@ -55,6 +57,12 @@ const installedRelease = process.env.PAPERCLIP_RUNNER_E2E_INSTALLED_CLI ? installedReleaseLaunch(process.env.PAPERCLIP_RUNNER_E2E_INSTALLED_CLI) : null; const dockerEnvironment = await dockerDatabaseSupervisorEnvironment({ temporaryRoot, env: process.env }); const archiveEnvironment = await archiveServerEnvironment({ selected: executionIds, temporaryRoot, backendOrigin: `http://127.0.0.1:${port}`, env: dockerEnvironment.source }); +const publicOriginReceipt = process.env[PUBLIC_ORIGIN_RECEIPT_ENV] ? await readValidatedPublicOriginReceipt({ receiptPath: required(PUBLIC_ORIGIN_RECEIPT_ENV), executionId: executionIds.length === 1 ? executionIds[0]! : "", attemptId: required("PAPERCLIP_E2E_ACCOUNTING_ATTEMPT_ID"), backendOrigin: `http://127.0.0.1:${port}` }) : undefined; +if (publicOriginReceipt && process.env.PAPERCLIP_RUNNER_E2E_PUBLIC_MCP !== "1") throw new Error("Owned public origin requires the exact authenticated selected case"); +// Only the validated handoff can advertise a public origin. Neither private +// receipt paths nor ambient public/auth configuration reach the product child. +for (const key of [PUBLIC_ORIGIN_RECEIPT_ENV, PUBLIC_ORIGIN_CONTROL_ENV, "PAPERCLIP_PUBLIC_URL", "PAPERCLIP_AUTH_PUBLIC_BASE_URL", "PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL", "PAPERCLIP_ALLOWED_HOSTNAMES"]) delete archiveEnvironment.source[key]; +const vaultEnvironment = await readSelectedVaultServerEnvironment({ executionIds, temporaryRoot, backendOrigin: `http://127.0.0.1:${port}`, env: process.env }); const serverEnvironment = buildPaperclipServerEnvironment(installedRelease ? installedReleaseEnvironment(archiveEnvironment.source, repositoryRoot, path.join(temporaryRoot, "provider-bin")) : archiveEnvironment.source, { @@ -82,6 +90,7 @@ const serverEnvironment = buildPaperclipServerEnvironment(installedRelease PAPERCLIP_PUBLIC_URL: `http://127.0.0.1:${port}`, PAPERCLIP_AUTH_PUBLIC_BASE_URL: `http://127.0.0.1:${port}`, PAPERCLIP_AUTH_BASE_URL_MODE: "explicit", + ...publicOriginServerOverrides(publicOriginReceipt), // Use empty provider configuration as the managed homes' seed. A local // operator's installed plugins, MCP connections and auth are not fixtures. CODEX_HOME: path.join(temporaryRoot, "provider-config", "codex"), @@ -98,6 +107,7 @@ const serverEnvironment = buildPaperclipServerEnvironment(installedRelease // Onboarding normally opens the app after listen. Browser ownership belongs // to Playwright in this harness, so never create a developer desktop tab. PAPERCLIP_OPEN_ON_LISTEN: "false", + ...vaultEnvironment, }); assertIsolatedServerEnvironment(serverEnvironment, { temporaryRoot,