mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Add finite owned public HTTPS origin handoff for chat evals
This commit is contained in:
1 parent
a0c5ad9805
commit
ea6dc42d2b
6 files changed
+264
-5
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<PublicOriginRequest, "executionId" | "attemptId" | "backendOrigin"> & Partial<Pick<PublicOriginRequest, "scenarioId" | "nonce">>) {
|
||||
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<T>(filename: string): Promise<T> {
|
||||
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<PublicOriginIdentity | undefined> {
|
||||
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<PublicOriginRequest>(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<PublicOriginRequest>(path.join(input.controlDirectory, "request.json"));
|
||||
const release = await privateJson<PublicOriginRequest & { backendShutdown: boolean }>(path.join(input.controlDirectory, "release-request.json"));
|
||||
const route = await privateJson<PublicOriginRequest & { publicOrigin: string; proxy: PublicOriginIdentity }>(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<ValidatedPublicOriginReceipt> {
|
||||
const receipt = await privateJson<ValidatedPublicOriginReceipt>(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<T>(filename: string, deadline: number): Promise<T> { while (Date.now() < deadline) { try { return await privateJson<T>(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<string, unknown> | 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<PublicOriginRequest & { publicOrigin: string; proxy: PublicOriginIdentity }>(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<void>((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<void>(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" };
|
||||
}
|
||||
@@ -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<ReturnType<typeof startOwnedDockerDatabase>> | undefined;
|
||||
let ownedPublicOrigin: Awaited<ReturnType<typeof prepareOwnedPublicOrigin>>;
|
||||
let archiveControl: Awaited<ReturnType<typeof startArchiveFaultControl>> | undefined;
|
||||
let gateway: Awaited<ReturnType<EvalOperations["start"]>> | 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";
|
||||
|
||||
@@ -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<RunnerApi, { userId: string; email: string; password: string; secrets: string[] }>();
|
||||
/** 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<ReturnType<BrowserContext["storageState"]>>;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user