From 405a4a450667ebc4fc7b4ad3f553c1d10bdb27ee Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 8 Aug 2026 05:43:13 +0000 Subject: [PATCH] fix(runner): secure phase4b browser transport Co-Authored-By: Paperclip --- packages/paperclip-runner/README.md | 4 + .../devtools/browser/src/live/client.ts | 7 + .../browser/src/live/use-live-console.ts | 11 + .../docs/phase-04b-live-console.md | 15 +- .../docs/phase-04b-protocol-server.md | 24 ++ .../tutorials/phase-04b-protocol-server.md | 27 +- .../scripts/phase4b-browser-server.mjs | 74 ++--- .../scripts/phase4b-browser-server.test.mjs | 54 +++ .../scripts/record-phase4b-evidence.mjs | 12 +- .../src/mock-core/phase4b-demo-server.test.ts | 314 ++++++++++++++++-- .../src/mock-core/phase4b-demo-server.ts | 303 +++++++++++++++-- packages/paperclip-runner/vitest.config.ts | 1 + 12 files changed, 730 insertions(+), 116 deletions(-) create mode 100644 packages/paperclip-runner/scripts/phase4b-browser-server.test.mjs diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 7f03e6c14e2..319d684b43f 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -69,6 +69,10 @@ pnpm --filter @paperclipai/paperclip-runner console:phase4b pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179 ``` +Phase 4b provider-backed routes are loopback-only and reject wildcard/LAN +binds. Browser mutations require same-origin Fetch Metadata, matching Origin, +and JSON content; see the protocol-server tutorial for direct `curl` examples. + ## Package-owned commands | Command | Purpose | diff --git a/packages/paperclip-runner/devtools/browser/src/live/client.ts b/packages/paperclip-runner/devtools/browser/src/live/client.ts index 0021456c332..cd384f07d52 100644 --- a/packages/paperclip-runner/devtools/browser/src/live/client.ts +++ b/packages/paperclip-runner/devtools/browser/src/live/client.ts @@ -127,6 +127,13 @@ export async function reconnectSession(sessionId: string): Promise { + await send(`/sessions/${sessionId}/close`, { + method: "POST", + body: JSON.stringify({}), + }); +} + export interface EventStreamHandle { close(): void; } diff --git a/packages/paperclip-runner/devtools/browser/src/live/use-live-console.ts b/packages/paperclip-runner/devtools/browser/src/live/use-live-console.ts index 66c678edee3..16532cc4da5 100644 --- a/packages/paperclip-runner/devtools/browser/src/live/use-live-console.ts +++ b/packages/paperclip-runner/devtools/browser/src/live/use-live-console.ts @@ -475,7 +475,18 @@ export function useLiveConsole(): LiveConsole { }, [adopt, guard, subscribe]); const reset = useCallback(async () => { + const sessionId = sessionIdRef.current; closeStream(); + if (sessionId !== null) { + try { + await client.closeSession(sessionId); + } catch (cause) { + setConnection("disconnected"); + setError(errorMessage(cause)); + setAnnouncement("Demo reset failed"); + return; + } + } window.sessionStorage.removeItem(SESSION_STORAGE_KEY); sessionIdRef.current = null; cursorRef.current = 0; diff --git a/packages/paperclip-runner/docs/phase-04b-live-console.md b/packages/paperclip-runner/docs/phase-04b-live-console.md index 9cb3567db1a..6b4af9e467d 100644 --- a/packages/paperclip-runner/docs/phase-04b-live-console.md +++ b/packages/paperclip-runner/docs/phase-04b-live-console.md @@ -19,11 +19,9 @@ pnpm --filter @paperclipai/paperclip-runner console:phase4b Then open `http://127.0.0.1:4180/` and choose **Live console**. -To reach it from another host, pass a bind address to the same script: - -```sh -pnpm --filter @paperclipai/paperclip-runner console:phase4b -- --host 0.0.0.0 -``` +Phase 4b is deliberately loopback-only. A wildcard or LAN `--host` fails +startup because this developer console can start provider-backed turns and +resolve approvals. There is no unauthenticated remote mode. Two environment variables change the driver behind the console: @@ -107,6 +105,13 @@ the driver, the working directory, and any provider login. Every JSON and event frame passes the demo server's redaction layer, and the inspector renders redaction markers verbatim without attempting to reconstruct them. +The live console and standalone server share one transport admission guard: +exact loopback Host/port, loopback sockets, same-origin Origin and Fetch +Metadata, plus JSON-only mutations. Browser reset closes the server session; +active sessions and SSE subscribers are hard-capped. Local untrusted processes +remain a residual risk, and the Codex sandbox/approval policy is the final +execution boundary. + ## Components Adapted from shadcn/ui and Vercel AI Elements **source**, rewritten in the diff --git a/packages/paperclip-runner/docs/phase-04b-protocol-server.md b/packages/paperclip-runner/docs/phase-04b-protocol-server.md index 1c77f87bec6..542aca5c60d 100644 --- a/packages/paperclip-runner/docs/phase-04b-protocol-server.md +++ b/packages/paperclip-runner/docs/phase-04b-protocol-server.md @@ -83,6 +83,14 @@ pnpm --filter @paperclipai/paperclip-runner demo:phase4b -- \ --host 127.0.0.1 --port 4174 ``` +The transport fails before binding unless `--host` is an explicit loopback +address. The standalone server and Vite middleware use the same admission +guard. It requires loopback local and remote sockets, the exact configured +loopback `Host` plus listening port, same-origin Fetch Metadata, a matching +browser `Origin` on mutations and streams, and `application/json` on every +mutation. Server-side automation uses a random per-launch bearer capability; +it is never returned by an API response or browser state. + Important routes: - `GET /api/phase4b/health` @@ -95,11 +103,27 @@ Important routes: - `POST /api/phase4b/sessions/:id/requests/:requestId/resolve` - `POST /api/phase4b/sessions/:id/goal/:operation` - `POST /api/phase4b/sessions/:id/reconnect` +- `POST /api/phase4b/sessions/:id/close` The server chooses the working directory. A create body cannot override it. +Provider, Paperclip, cookie, and bearer credential fields in create bodies are +rejected. Runtime-request resolution must match the pending request, its turn, +and the session named by the route; stale, cross-scope, and duplicate responses +fail with `409` before reaching the driver. Every JSON and event response passes a second bounded redaction layer. Replay uses the same validated PRP events and reducer as the live view. +The demo keeps at most 16 active sessions and four SSE subscribers per session +by default. Call the close route (the browser does this on reset) to release a +session immediately; server shutdown closes every remaining session and +subscriber. Configuration may lower these limits and is hard-capped at 64 +sessions and 16 subscribers. + +Residual risk: a loopback service is still reachable by untrusted local +processes, which can reproduce browser headers. The Codex sandbox and approval +policy remain the final execution boundary; this demo transport is not a +multi-user authorization boundary. + ## Evidence Run deterministic conformance first, then the real Codex boundary: diff --git a/packages/paperclip-runner/docs/tutorials/phase-04b-protocol-server.md b/packages/paperclip-runner/docs/tutorials/phase-04b-protocol-server.md index 6333da2fdf9..eed6b0fa69c 100644 --- a/packages/paperclip-runner/docs/tutorials/phase-04b-protocol-server.md +++ b/packages/paperclip-runner/docs/tutorials/phase-04b-protocol-server.md @@ -42,7 +42,9 @@ port, and server-owned working directory. It also says In a second terminal, run: ```sh -curl -s http://127.0.0.1:4174/api/phase4b/health | jq +curl -s http://127.0.0.1:4174/api/phase4b/health \ + -H 'Origin: http://127.0.0.1:4174' \ + -H 'Sec-Fetch-Site: same-origin' | jq ``` Expected facts: @@ -56,19 +58,24 @@ Expected facts: ```sh curl -s -X POST http://127.0.0.1:4174/api/phase4b/sessions \ + -H 'Origin: http://127.0.0.1:4174' \ + -H 'Sec-Fetch-Site: same-origin' \ -H 'content-type: application/json' \ --data '{"objective":"Create demo.txt with exactly: phase4b demo","message":"Complete and verify the exact file task."}' \ | tee "$phase4b_workspace/session.json" | jq ``` -Copy `sessionId` and `activeTurnId` from the reply. The server ignores any -browser-supplied working-directory or credential field. +Copy `sessionId` and `activeTurnId` from the reply. The server ignores a +browser-supplied working directory and rejects browser-supplied credential +fields. ## Step 5: Read live or replayed events ```sh session_id="$(jq -r .sessionId "$phase4b_workspace/session.json")" -curl -s "http://127.0.0.1:4174/api/phase4b/sessions/$session_id/events?after=0" | jq +curl -s "http://127.0.0.1:4174/api/phase4b/sessions/$session_id/events?after=0" \ + -H 'Origin: http://127.0.0.1:4174' \ + -H 'Sec-Fetch-Site: same-origin' | jq ``` The reply contains canonical PRP events and a cursor. A later request can pass @@ -78,7 +85,9 @@ view and replay. For a live stream, run: ```sh -curl -N "http://127.0.0.1:4174/api/phase4b/sessions/$session_id/stream?after=0" +curl -N "http://127.0.0.1:4174/api/phase4b/sessions/$session_id/stream?after=0" \ + -H 'Origin: http://127.0.0.1:4174' \ + -H 'Sec-Fetch-Site: same-origin' ``` ## Step 6: Reconnect the same session @@ -86,12 +95,20 @@ curl -N "http://127.0.0.1:4174/api/phase4b/sessions/$session_id/stream?after=0" ```sh curl -s -X POST \ "http://127.0.0.1:4174/api/phase4b/sessions/$session_id/reconnect" \ + -H 'Origin: http://127.0.0.1:4174' \ + -H 'Sec-Fetch-Site: same-origin' \ -H 'content-type: application/json' --data '{}' | jq ``` Confirm that `runId`, `normalizedSessionId`, `driverSessionId`, and `providerSessionId` did not change. The new events include `session.resumed`. +The repeated browser headers are intentional admission evidence. DNS-rebinding +Hosts, cross-origin or missing Fetch Metadata, wildcard binds, and simple +`text/plain` mutations are rejected. This protects the browser boundary, but +untrusted local processes can still make loopback requests; Codex sandbox and +approval policy remain the final execution boundary. + ## Step 7: Record real evidence Stop the manual server. Then run: diff --git a/packages/paperclip-runner/scripts/phase4b-browser-server.mjs b/packages/paperclip-runner/scripts/phase4b-browser-server.mjs index b0f3e564a46..6b6c60bc090 100644 --- a/packages/paperclip-runner/scripts/phase4b-browser-server.mjs +++ b/packages/paperclip-runner/scripts/phase4b-browser-server.mjs @@ -11,46 +11,6 @@ import { resolve } from "node:path"; * `PAPERCLIP_PHASE4B_DRIVER=codex` swaps in the real Codex app-server driver * behind exactly the same routes. */ -const LOOPBACK_ONLY = "Phase 4b is available only over loopback"; - -function isLoopbackAddress(address) { - if (typeof address !== "string") return false; - const normalized = address.toLowerCase().replace(/^::ffff:/, ""); - if (normalized === "::1" || normalized === "[::1]" || normalized === "localhost") return true; - const octets = normalized.split("."); - return ( - octets.length === 4 && - octets[0] === "127" && - octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) - ); -} - -function rejectUntrusted(request, response) { - if ( - !isLoopbackAddress(request.socket.localAddress) || - !isLoopbackAddress(request.socket.remoteAddress) - ) { - response.statusCode = 403; - response.setHeader("content-type", "application/json; charset=utf-8"); - response.end(JSON.stringify({ error: "forbidden", message: LOOPBACK_ONLY })); - return true; - } - const fetchSite = request.headers["sec-fetch-site"]; - if ( - Array.isArray(fetchSite) || - (typeof fetchSite === "string" && !["same-origin", "none"].includes(fetchSite)) - ) { - response.statusCode = 403; - response.setHeader("content-type", "application/json; charset=utf-8"); - response.end(JSON.stringify({ - error: "forbidden", - message: "Phase 4b cross-site requests are forbidden", - })); - return true; - } - return false; -} - async function loadRunner() { return import(new URL("../dist/index.js", import.meta.url).href); } @@ -69,14 +29,18 @@ export function createPhase4bBrowserMiddleware(options = {}) { 10, ); let bootstrap = null; + let bindHost = options.bindHost ?? "127.0.0.1"; - async function ready() { + async function ready(requestedBindHost = bindHost) { + bindHost = requestedBindHost; if (bootstrap !== null) return bootstrap; bootstrap = (async () => { const runner = await load(); + runner.assertPhase4bLoopbackBindHost(bindHost); const workingDirectory = options.workingDirectory ?? (await createWorkingDirectory()); const server = new runner.Phase4bDemoServer({ + host: bindHost, workingDirectory, manifests: runner.phase4bDemoManifestCatalogue(), driverFactory: (taskEnvelope, manifestId) => @@ -101,7 +65,6 @@ export function createPhase4bBrowserMiddleware(options = {}) { next(); return; } - if (rejectUntrusted(request, response)) return; try { const { handle } = await ready(); handle(request, response); @@ -119,22 +82,31 @@ export function createPhase4bBrowserMiddleware(options = {}) { const { server } = await bootstrap; await server.close(); }; + middleware.prepare = ready; return middleware; } export function phase4bBrowserServerPlugin(options = {}) { - const middleware = createPhase4bBrowserMiddleware(options); + const middlewares = new Set(); + async function mount(server, host) { + const middleware = createPhase4bBrowserMiddleware({ ...options, bindHost: host }); + await middleware.prepare(host); + middlewares.add(middleware); + server.middlewares.use(middleware); + server.httpServer?.once("close", () => { + middlewares.delete(middleware); + void middleware.close(); + }); + } return { name: "paperclip-runner-phase4b-live-console-server", - configureServer(server) { - server.middlewares.use(middleware); - server.httpServer?.once("close", () => void middleware.close()); + async configureServer(server) { + const host = server.config.server.host; + await mount(server, typeof host === "string" ? host : host === true ? "0.0.0.0" : "127.0.0.1"); }, - configurePreviewServer(server) { - server.middlewares.use(middleware); - server.httpServer?.once("close", () => void middleware.close()); + async configurePreviewServer(server) { + const host = server.config.preview.host; + await mount(server, typeof host === "string" ? host : host === true ? "0.0.0.0" : "127.0.0.1"); }, }; } - -export const phase4bBrowserServerInternals = { isLoopbackAddress }; diff --git a/packages/paperclip-runner/scripts/phase4b-browser-server.test.mjs b/packages/paperclip-runner/scripts/phase4b-browser-server.test.mjs new file mode 100644 index 00000000000..ec7d40f1dce --- /dev/null +++ b/packages/paperclip-runner/scripts/phase4b-browser-server.test.mjs @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { createPhase4bBrowserMiddleware } from "./phase4b-browser-server.mjs"; + +describe("Phase 4b Vite transport bootstrap", () => { + it("uses the runner's shared bind guard before constructing middleware", async () => { + const calls = []; + const middleware = createPhase4bBrowserMiddleware({ + workingDirectory: "/server-owned-workspace", + loadRunner: async () => ({ + assertPhase4bLoopbackBindHost(host) { + calls.push(host); + throw new Error("shared guard rejected non-loopback bind"); + }, + }), + }); + + await expect(middleware.prepare("0.0.0.0")).rejects.toThrow( + "shared guard rejected non-loopback bind", + ); + expect(calls).toEqual(["0.0.0.0"]); + }); + + it("passes the validated Vite bind host into the shared demo server", async () => { + let serverOptions; + const middleware = createPhase4bBrowserMiddleware({ + workingDirectory: "/server-owned-workspace", + loadRunner: async () => ({ + assertPhase4bLoopbackBindHost(host) { + expect(host).toBe("127.0.0.1"); + }, + phase4bDemoManifestCatalogue: () => [], + Phase4bScriptedDriver: class {}, + Phase4bDemoServer: class { + constructor(options) { + serverOptions = options; + } + + middleware() { + return () => undefined; + } + + async close() {} + }, + }), + }); + + await middleware.prepare("127.0.0.1"); + expect(serverOptions).toMatchObject({ + host: "127.0.0.1", + workingDirectory: "/server-owned-workspace", + }); + }); +}); diff --git a/packages/paperclip-runner/scripts/record-phase4b-evidence.mjs b/packages/paperclip-runner/scripts/record-phase4b-evidence.mjs index eab2867890a..5eff4cd8b5a 100644 --- a/packages/paperclip-runner/scripts/record-phase4b-evidence.mjs +++ b/packages/paperclip-runner/scripts/record-phase4b-evidence.mjs @@ -13,9 +13,10 @@ const workspace = await mkdtemp(resolve( process.env.PAPERCLIP_RUN_SCRATCH_DIR ?? tmpdir(), "phase4b-real-codex-", )); +const requestedOutput = process.argv.slice(2).find((argument) => argument !== "--"); const output = resolve( packageRoot, - process.argv[2] ?? "knowledge/evidence/phase-04b-real-codex-server.json", + requestedOutput ?? "knowledge/evidence/phase-04b-real-codex-server.json", ); const server = new Phase4bDemoServer({ @@ -29,11 +30,18 @@ const server = new Phase4bDemoServer({ }); const address = await server.start(); +const directApiAuthorization = server.directApiAuthorization(); const commandLog = []; async function request(path, init) { commandLog.push({ method: init?.method ?? "GET", path }); - const response = await fetch(`${address.url}${path}`, init); + const response = await fetch(`${address.url}${path}`, { + ...init, + headers: { + ...init?.headers, + authorization: directApiAuthorization, + }, + }); const body = await response.json(); if (!response.ok) throw new Error(`${path} returned ${response.status}: ${JSON.stringify(body)}`); return body; diff --git a/packages/paperclip-runner/src/mock-core/phase4b-demo-server.test.ts b/packages/paperclip-runner/src/mock-core/phase4b-demo-server.test.ts index eca9663a509..0b5a2884a87 100644 --- a/packages/paperclip-runner/src/mock-core/phase4b-demo-server.test.ts +++ b/packages/paperclip-runner/src/mock-core/phase4b-demo-server.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; +import { request as httpRequest } from "node:http"; import type { HarnessDriver, @@ -260,11 +261,246 @@ class StubDriver implements HarnessDriver { const servers: Phase4bDemoServer[] = []; +function browserHeaders(url: string, headers: HeadersInit = {}): Headers { + const result = new Headers(headers); + result.set("origin", new URL(url).origin); + result.set("sec-fetch-site", "same-origin"); + return result; +} + +function browserRequest( + url: string, + path: string, + init: RequestInit = {}, +): Promise { + return fetch(`${url}${path}`, { + ...init, + headers: browserHeaders(url, init.headers), + }); +} + +function jsonBrowserRequest( + url: string, + path: string, + body: Record, +): Promise { + return browserRequest(url, path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function rawRequest( + url: string, + headers: Record, +): Promise<{ status: number; body: Record }> { + return new Promise((resolve, reject) => { + const request = httpRequest(`${url}/api/phase4b/health`, { headers }, (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + response.once("end", () => resolve({ + status: response.statusCode ?? 0, + body: JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record, + })); + }); + request.once("error", reject); + request.end(); + }); +} + +async function openEventStream( + url: string, + sessionId: string, +): Promise> { + const origin = new URL(url).origin; + return new Promise((resolve, reject) => { + const request = httpRequest(`${url}/api/phase4b/sessions/${sessionId}/stream?after=0`, { + headers: { origin, "sec-fetch-site": "same-origin" }, + }, (response) => { + response.once("data", () => resolve(request)); + }); + request.once("error", reject); + request.end(); + }); +} + afterEach(async () => { await Promise.all(servers.splice(0).map((server) => server.close())); }); describe("Phase 4b package-local demo server", () => { + it("rejects DNS-rebinding Host values even with same-origin browser metadata", async () => { + const server = new Phase4bDemoServer({ + workingDirectory: "/safe/server-owned-workspace", + driverFactory: () => new StubDriver(), + }); + servers.push(server); + const { url, port } = await server.start(); + + const response = await rawRequest(url, { + host: `attacker.test:${port}`, + origin: `http://attacker.test:${port}`, + "sec-fetch-site": "same-origin", + }); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ error: "invalid_host" }); + }); + + it("fails closed before binding a standalone server to a non-loopback host", async () => { + const server = new Phase4bDemoServer({ + host: "0.0.0.0", + workingDirectory: "/safe/server-owned-workspace", + driverFactory: () => new StubDriver(), + }); + servers.push(server); + + await expect(server.start()).rejects.toThrow("available only over loopback"); + }); + + it("rejects cross-origin and missing or hostile Fetch Metadata", async () => { + const server = new Phase4bDemoServer({ + workingDirectory: "/safe/server-owned-workspace", + driverFactory: () => new StubDriver(), + }); + servers.push(server); + const { url } = await server.start(); + + const crossOrigin = await fetch(`${url}/api/phase4b/health`, { + headers: { origin: "http://attacker.test:4174", "sec-fetch-site": "same-origin" }, + }); + expect(crossOrigin.status).toBe(403); + await expect(crossOrigin.json()).resolves.toMatchObject({ error: "invalid_origin" }); + + const missing = await fetch(`${url}/api/phase4b/health`); + expect(missing.status).toBe(403); + await expect(missing.json()).resolves.toMatchObject({ error: "invalid_fetch_metadata" }); + + const hostile = await fetch(`${url}/api/phase4b/health`, { + headers: { origin: new URL(url).origin, "sec-fetch-site": "cross-site" }, + }); + expect(hostile.status).toBe(403); + await expect(hostile.json()).resolves.toMatchObject({ error: "invalid_fetch_metadata" }); + }); + + it("rejects text/plain simple mutations and accepts a valid loopback browser request", async () => { + const driver = new StubDriver(); + const server = new Phase4bDemoServer({ + workingDirectory: "/safe/server-owned-workspace", + driverFactory: () => driver, + }); + servers.push(server); + const { url } = await server.start(); + + const simplePost = await browserRequest(url, "/api/phase4b/sessions", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }); + expect(simplePost.status).toBe(415); + await expect(simplePost.json()).resolves.toMatchObject({ error: "json_content_type_required" }); + + const valid = await jsonBrowserRequest(url, "/api/phase4b/sessions", { + objective: "Exercise the browser transport.", + startTurn: false, + }); + expect(valid.status).toBe(201); + await expect(valid.json()).resolves.toMatchObject({ + providerAuthentication: "server-side", + credentialsExposed: false, + }); + expect(driver.openInputs).toHaveLength(1); + }); + + it("uses an unguessable direct capability while keeping provider credentials server-side", async () => { + const driver = new StubDriver(); + const server = new Phase4bDemoServer({ + workingDirectory: "/safe/server-owned-workspace", + driverFactory: () => driver, + }); + servers.push(server); + const { url } = await server.start(); + + const invalid = await fetch(`${url}/api/phase4b/health`, { + headers: { authorization: "Bearer invalid" }, + }); + expect(invalid.status).toBe(401); + + const response = await fetch(`${url}/api/phase4b/sessions`, { + method: "POST", + headers: { + authorization: server.directApiAuthorization(), + "content-type": "application/json", + }, + body: JSON.stringify({ objective: "Use the server-owned provider path." }), + }); + expect(response.status).toBe(201); + const created = await response.json() as Record; + expect(driver.openInputs[0]?.workingDirectory).toBe("/safe/server-owned-workspace"); + expect(JSON.stringify(created)).not.toContain(server.directApiAuthorization()); + expect(JSON.stringify(created)).not.toContain("server-only-secret"); + + const credentialAttempt = await jsonBrowserRequest(url, "/api/phase4b/sessions", { + objective: "Browser must not choose provider credentials.", + openaiApiKey: "browser-secret", + }); + expect(credentialAttempt.status).toBe(422); + await expect(credentialAttempt.json()).resolves.toMatchObject({ + error: "browser_credentials_forbidden", + }); + expect(driver.openInputs).toHaveLength(1); + }); + + it("caps active sessions and frees capacity through explicit cleanup", async () => { + const server = new Phase4bDemoServer({ + maxActiveSessions: 1, + workingDirectory: "/safe/server-owned-workspace", + driverFactory: () => new StubDriver(), + }); + servers.push(server); + const { url } = await server.start(); + + const first = await jsonBrowserRequest(url, "/api/phase4b/sessions", { startTurn: false }); + expect(first.status).toBe(201); + const { sessionId } = await first.json() as { sessionId: string }; + + const capped = await jsonBrowserRequest(url, "/api/phase4b/sessions", { startTurn: false }); + expect(capped.status).toBe(429); + await expect(capped.json()).resolves.toMatchObject({ error: "session_limit_reached" }); + + const closed = await jsonBrowserRequest(url, `/api/phase4b/sessions/${sessionId}/close`, {}); + expect(closed.status).toBe(200); + await expect(closed.json()).resolves.toEqual({ closed: true, sessionId }); + + const replacement = await jsonBrowserRequest(url, "/api/phase4b/sessions", { startTurn: false }); + expect(replacement.status).toBe(201); + }); + + it("caps SSE subscribers per session", async () => { + const server = new Phase4bDemoServer({ + maxSessionSubscribers: 1, + workingDirectory: "/safe/server-owned-workspace", + driverFactory: () => new StubDriver(), + }); + servers.push(server); + const { url } = await server.start(); + const created = await jsonBrowserRequest(url, "/api/phase4b/sessions", { startTurn: false }); + const { sessionId } = await created.json() as { sessionId: string }; + const first = await openEventStream(url, sessionId); + + try { + const capped = await browserRequest( + url, + `/api/phase4b/sessions/${sessionId}/stream?after=0`, + ); + expect(capped.status).toBe(429); + await expect(capped.json()).resolves.toMatchObject({ error: "subscriber_limit_reached" }); + } finally { + first.destroy(); + } + }); + it("keeps credentials and workspace selection server-side across replay and reconnect", async () => { const driver = new StubDriver(); const server = new Phase4bDemoServer({ @@ -274,22 +510,17 @@ describe("Phase 4b package-local demo server", () => { servers.push(server); const { url } = await server.start(); - const health = await fetch(`${url}/api/phase4b/health`).then((response) => response.json()); + const health = await browserRequest(url, "/api/phase4b/health").then((response) => response.json()); expect(health).toEqual(expect.objectContaining({ status: "ok", providerAuthentication: "server-side", credentialsExposed: false, })); - const created = await fetch(`${url}/api/phase4b/sessions`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ + const created = await jsonBrowserRequest(url, "/api/phase4b/sessions", { objective: "Exercise the server boundary.", workingDirectory: "/browser/chosen-path", - authorization: "Bearer browser-secret", - }), - }).then((response) => response.json()) as Record; + }).then((response) => response.json()) as Record; expect(driver.openInputs[0]?.workingDirectory).toBe("/safe/server-owned-workspace"); expect(created).toMatchObject({ providerAuthentication: "server-side", @@ -303,7 +534,7 @@ describe("Phase 4b package-local demo server", () => { expect(JSON.stringify(created)).not.toContain("/srv/paperclip/home"); const sessionId = String(created.sessionId); - const replay = await fetch(`${url}/api/phase4b/sessions/${sessionId}/events?after=0`) + const replay = await browserRequest(url, `/api/phase4b/sessions/${sessionId}/events?after=0`) .then((response) => response.json()) as { events: PrpEvent[]; cursor: number; replay: boolean }; expect(replay.replay).toBe(true); expect(replay.events.length).toBeGreaterThanOrEqual(5); @@ -311,35 +542,63 @@ describe("Phase 4b package-local demo server", () => { expect(JSON.stringify(replay)).not.toContain("/srv/paperclip/home"); expect(JSON.stringify(replay)).toContain(""); - const resolved = await fetch( - `${url}/api/phase4b/sessions/${sessionId}/requests/request-demo/resolve`, + const otherSession = await jsonBrowserRequest(url, "/api/phase4b/sessions", { + objective: "Keep request scope separate.", + startTurn: false, + }).then((response) => response.json()) as { sessionId: string }; + const wrongSession = await jsonBrowserRequest( + url, + `/api/phase4b/sessions/${otherSession.sessionId}/requests/request-demo/resolve`, + { turnId: "turn-demo", resolution: { action: "accept_for_session" } }, + ); + expect(wrongSession.status).toBe(409); + await expect(wrongSession.json()).resolves.toMatchObject({ error: "runtime_request_not_pending" }); + + const wrongTurn = await jsonBrowserRequest( + url, + `/api/phase4b/sessions/${sessionId}/requests/request-demo/resolve`, + { + turnId: "turn-attacker", + resolution: { action: "accept_for_session" }, + }, + ); + expect(wrongTurn.status).toBe(409); + await expect(wrongTurn.json()).resolves.toMatchObject({ error: "runtime_request_scope_mismatch" }); + + const resolved = await jsonBrowserRequest( + url, + `/api/phase4b/sessions/${sessionId}/requests/request-demo/resolve`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ turnId: "turn-demo", resolution: { action: "accept_for_session" }, - }), }, ).then((response) => response.json()) as Record; expect(resolved.pendingRequests).toEqual([]); - const withGoal = await fetch(`${url}/api/phase4b/sessions/${sessionId}/goal/set`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ objective: "Keep the goal durable.", tokenBudget: 1024 }), - }).then((response) => response.json()) as Record; + const duplicate = await jsonBrowserRequest( + url, + `/api/phase4b/sessions/${sessionId}/requests/request-demo/resolve`, + { turnId: "turn-demo", resolution: { action: "decline" } }, + ); + expect(duplicate.status).toBe(409); + await expect(duplicate.json()).resolves.toMatchObject({ error: "runtime_request_not_pending" }); + + const withGoal = await jsonBrowserRequest( + url, + `/api/phase4b/sessions/${sessionId}/goal/set`, + { objective: "Keep the goal durable.", tokenBudget: 1024 }, + ).then((response) => response.json()) as Record; expect(withGoal.goal).toMatchObject({ objective: "Keep the goal durable.", status: "active", tokenBudget: 1024, }); - const reconnected = await fetch(`${url}/api/phase4b/sessions/${sessionId}/reconnect`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{}", - }).then((response) => response.json()) as Record; + const reconnected = await jsonBrowserRequest( + url, + `/api/phase4b/sessions/${sessionId}/reconnect`, + {}, + ).then((response) => response.json()) as Record; expect(reconnected).toMatchObject({ sessionId, runId: created.runId, @@ -347,8 +606,9 @@ describe("Phase 4b package-local demo server", () => { driverSession: created.driverSession, goal: withGoal.goal, }); - const afterReconnect = await fetch( - `${url}/api/phase4b/sessions/${sessionId}/events?after=${replay.cursor}`, + const afterReconnect = await browserRequest( + url, + `/api/phase4b/sessions/${sessionId}/events?after=${replay.cursor}`, ).then((response) => response.json()) as { events: PrpEvent[] }; expect(afterReconnect.events.some(({ eventType }) => eventType === "session.resumed")).toBe(true); }); diff --git a/packages/paperclip-runner/src/mock-core/phase4b-demo-server.ts b/packages/paperclip-runner/src/mock-core/phase4b-demo-server.ts index d3f03e81727..415d93147eb 100644 --- a/packages/paperclip-runner/src/mock-core/phase4b-demo-server.ts +++ b/packages/paperclip-runner/src/mock-core/phase4b-demo-server.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { createServer, type IncomingMessage, @@ -30,6 +30,24 @@ import { redactCodexDiagnostic } from "../drivers/codex/app-server-transport.js" const MAX_BROWSER_BODY_BYTES = 64 * 1024; const MAX_BROWSER_EVENTS = 4096; +const DEFAULT_MAX_ACTIVE_SESSIONS = 16; +const DEFAULT_MAX_SESSION_SUBSCRIBERS = 4; +const MAX_CONFIGURED_ACTIVE_SESSIONS = 64; +const MAX_CONFIGURED_SESSION_SUBSCRIBERS = 16; + +const LOOPBACK_ONLY = "Phase 4b transport is available only over loopback"; + +class Phase4bHttpError extends Error { + readonly code: string; + readonly status: number; + + constructor(status: number, code: string, message: string) { + super(message); + this.name = "Phase4bHttpError"; + this.code = code; + this.status = status; + } +} export interface Phase4bDemoServerOptions { workingDirectory: string; @@ -41,6 +59,10 @@ export interface Phase4bDemoServerOptions { port?: number; /** Demo-chat manifests the browser lists; the server owns the catalogue. */ manifests?: readonly unknown[]; + /** May reduce, but never raise, the package hard limit. */ + maxActiveSessions?: number; + /** May reduce, but never raise, the package hard limit. */ + maxSessionSubscribers?: number; } interface DemoEntry { @@ -103,6 +125,7 @@ function json(response: ServerResponse, status: number, body: unknown): void { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "content-length": Buffer.byteLength(serialized), + "x-content-type-options": "nosniff", }); response.end(serialized); } @@ -147,15 +170,152 @@ function pathParts(request: IncomingMessage): string[] { } function errorStatus(error: unknown): number { + if (error instanceof Phase4bHttpError) return error.status; const code = record(error).code; if (code === "stale_turn" || code === "already_terminal") return 409; return 400; } +function errorCode(error: unknown): string { + if (error instanceof Phase4bHttpError) return error.code; + return String(record(error).code ?? "invalid_request"); +} + +function isLoopbackAddress(address: unknown): boolean { + if (typeof address !== "string") return false; + const normalized = address.toLowerCase().replace(/^\[|\]$/g, "").replace(/^::ffff:/, ""); + if (normalized === "::1" || normalized === "localhost") return true; + const octets = normalized.split("."); + return ( + octets.length === 4 && + octets[0] === "127" && + octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) + ); +} + +/** Reject wildcard and LAN binds before a provider-capable server starts. */ +export function assertPhase4bLoopbackBindHost(host: string): void { + if (!isLoopbackAddress(host)) { + throw new Phase4bHttpError(403, "non_loopback_bind_forbidden", LOOPBACK_ONLY); + } +} + +function normalizedHost(host: string): string { + return host.toLowerCase().replace(/^\[|\]$/g, ""); +} + +function expectedAuthority(host: string, port: number): string { + const normalized = normalizedHost(host); + return normalized.includes(":") ? `[${normalized}]:${port}` : `${normalized}:${port}`; +} + +function singleHeader(value: string | string[] | undefined): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function hasValidCapability(request: IncomingMessage, capability: string): boolean { + const authorization = singleHeader(request.headers.authorization); + if (authorization === null) return false; + const expected = Buffer.from(`Bearer ${capability}`); + const actual = Buffer.from(authorization); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +function requireTransportAdmission( + request: IncomingMessage, + bindHost: string, + capability: string, +): void { + if ( + !isLoopbackAddress(request.socket.localAddress) || + !isLoopbackAddress(request.socket.remoteAddress) + ) { + throw new Phase4bHttpError(403, "loopback_required", LOOPBACK_ONLY); + } + + const port = request.socket.localPort; + const host = singleHeader(request.headers.host); + if (port === undefined || host === null || host.toLowerCase() !== expectedAuthority(bindHost, port)) { + throw new Phase4bHttpError( + 403, + "invalid_host", + "Phase 4b requests require the exact loopback host and listening port", + ); + } + + const authorization = singleHeader(request.headers.authorization); + const authenticatedDirectRequest = hasValidCapability(request, capability); + if (authorization !== null && !authenticatedDirectRequest) { + throw new Phase4bHttpError(401, "invalid_transport_capability", "Invalid Phase 4b capability"); + } + + const mutation = !["GET", "HEAD"].includes(request.method ?? "GET"); + if (mutation) { + const mediaType = singleHeader(request.headers["content-type"])?.split(";", 1)[0]?.trim().toLowerCase(); + if (mediaType !== "application/json") { + throw new Phase4bHttpError( + 415, + "json_content_type_required", + "Phase 4b mutations require application/json", + ); + } + } + if (authenticatedDirectRequest) return; + + const fetchSite = singleHeader(request.headers["sec-fetch-site"]); + if (fetchSite !== "same-origin") { + throw new Phase4bHttpError( + 403, + "invalid_fetch_metadata", + "Phase 4b browser requests require Sec-Fetch-Site: same-origin", + ); + } + + const origin = singleHeader(request.headers.origin); + if (origin !== null && origin !== `http://${host.toLowerCase()}`) { + throw new Phase4bHttpError(403, "invalid_origin", "Phase 4b cross-origin requests are forbidden"); + } + if ((mutation || request.url?.includes("/stream") === true) && origin === null) { + throw new Phase4bHttpError(403, "origin_required", "Phase 4b browser request Origin is required"); + } +} + +function boundedOption( + value: number | undefined, + fallback: number, + maximum: number, + name: string, +): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new Error(`${name} must be an integer from 1 to ${maximum}`); + } + return resolved; +} + +function browserCredentialField(body: Record): string | null { + const forbidden = new Set([ + "authorization", + "cookie", + "credentials", + "apikey", + "paperclipapikey", + "openaiapikey", + "providertoken", + "refreshtoken", + "accesstoken", + ]); + return Object.keys(body).find((key) => forbidden.has(key.replace(/[-_]/g, "").toLowerCase())) ?? null; +} + export class Phase4bDemoServer { readonly #options: Required> & Omit; readonly #entries = new Map(); + readonly #transportCapability = randomBytes(32).toString("base64url"); + readonly #maxActiveSessions: number; + readonly #maxSessionSubscribers: number; + #creatingSessions = 0; #server: Server | null = null; constructor(options: Phase4bDemoServerOptions) { @@ -164,10 +324,23 @@ export class Phase4bDemoServer { host: options.host ?? "127.0.0.1", port: options.port ?? 0, }; + this.#maxActiveSessions = boundedOption( + options.maxActiveSessions, + DEFAULT_MAX_ACTIVE_SESSIONS, + MAX_CONFIGURED_ACTIVE_SESSIONS, + "maxActiveSessions", + ); + this.#maxSessionSubscribers = boundedOption( + options.maxSessionSubscribers, + DEFAULT_MAX_SESSION_SUBSCRIBERS, + MAX_CONFIGURED_SESSION_SUBSCRIBERS, + "maxSessionSubscribers", + ); } async start(): Promise<{ host: string; port: number; url: string }> { if (this.#server !== null) throw new Error("Phase 4b demo server is already started"); + assertPhase4bLoopbackBindHost(this.#options.host); this.#server = createServer(this.middleware()); await new Promise((resolve, reject) => { this.#server!.once("error", reject); @@ -180,20 +353,35 @@ export class Phase4bDemoServer { return { host: this.#options.host, port: address.port, - url: `http://${this.#options.host}:${address.port}`, + url: `http://${expectedAuthority(this.#options.host, address.port)}`, }; } + /** Server-side automation can authenticate without browser-controlled headers. */ + directApiAuthorization(): string { + return `Bearer ${this.#transportCapability}`; + } + /** * The same handler `start()` binds, so an embedding dev server (the Vite * plugin) can mount the identical routes without a second implementation. */ middleware(): (request: IncomingMessage, response: ServerResponse) => void { return (request, response) => { + try { + assertPhase4bLoopbackBindHost(this.#options.host); + requireTransportAdmission(request, this.#options.host, this.#transportCapability); + } catch (error) { + json(response, errorStatus(error), { + error: errorCode(error), + message: redactCodexDiagnostic(String(error instanceof Error ? error.message : error)), + }); + return; + } void this.#handle(request, response).catch((error) => { if (!response.headersSent) { json(response, errorStatus(error), { - error: record(error).code ?? "invalid_request", + error: errorCode(error), message: redactCodexDiagnostic(String(error)), }); } else { @@ -236,7 +424,22 @@ export class Phase4bDemoServer { return; } if (request.method === "POST" && parts.join("/") === "api/phase4b/sessions") { - const body = await readJson(request) as BrowserCreateBody; + const body = await readJson(request) as BrowserCreateBody & Record; + const credentialField = browserCredentialField(body); + if (credentialField !== null) { + throw new Phase4bHttpError( + 422, + "browser_credentials_forbidden", + `Browser bodies cannot provide credentials (${credentialField})`, + ); + } + if (this.#entries.size + this.#creatingSessions >= this.#maxActiveSessions) { + throw new Phase4bHttpError( + 429, + "session_limit_reached", + `Phase 4b is limited to ${this.#maxActiveSessions} active sessions`, + ); + } const objective = typeof body.objective === "string" && body.objective.trim().length > 0 ? body.objective.trim() : "Complete the Phase 4b demo task safely."; @@ -244,7 +447,13 @@ export class Phase4bDemoServer { const manifestId = typeof body.manifest === "string" && body.manifest.length > 0 ? body.manifest : null; - const entry = await this.#create(objective, message, manifestId, body.startTurn !== false); + this.#creatingSessions += 1; + let entry: DemoEntry; + try { + entry = await this.#create(objective, message, manifestId, body.startTurn !== false); + } finally { + this.#creatingSessions -= 1; + } json(response, 201, await this.#publicState(entry)); return; } @@ -275,6 +484,13 @@ export class Phase4bDemoServer { if (request.method === "GET" && action === "stream") { const url = new URL(request.url ?? "/", "http://phase4b.invalid"); const after = Math.max(0, Number.parseInt(url.searchParams.get("after") ?? "0", 10) || 0); + if (entry.subscribers.size >= this.#maxSessionSubscribers) { + throw new Phase4bHttpError( + 429, + "subscriber_limit_reached", + `Phase 4b sessions allow ${this.#maxSessionSubscribers} subscribers`, + ); + } response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-store", @@ -296,7 +512,11 @@ export class Phase4bDemoServer { return; } const body = await readJson(request); - if (action === "turns") { + if (action === "close") { + await this.#deleteEntry(entry, "browser_session_closed"); + json(response, 200, { closed: true, sessionId: entry.id }); + return; + } else if (action === "turns") { await entry.session.startTurn({ message: { role: "user", text: String(body.text ?? "") }, }); @@ -312,6 +532,21 @@ export class Phase4bDemoServer { ...(typeof body.reason === "string" ? { reason: body.reason } : {}), }); } else if (action === "requests" && parts[5] && parts[6] === "resolve") { + const pending = entry.session.pendingRuntimeRequests?.().find(({ requestId }) => requestId === parts[5]); + if (pending === undefined) { + throw new Phase4bHttpError( + 409, + "runtime_request_not_pending", + "Runtime request is missing, expired, or already resolved", + ); + } + if (typeof body.turnId !== "string" || body.turnId !== pending.turnId) { + throw new Phase4bHttpError( + 409, + "runtime_request_scope_mismatch", + "Runtime request resolution does not match its session and turn", + ); + } await entry.session.resolveRuntimeRequest?.({ requestId: parts[5], turnId: String(body.turnId ?? ""), @@ -328,6 +563,14 @@ export class Phase4bDemoServer { json(response, 200, await this.#publicState(entry)); } + async #deleteEntry(entry: DemoEntry, reason: string): Promise { + if (!this.#entries.delete(entry.id)) return; + for (const subscriber of entry.subscribers) subscriber.end(); + entry.subscribers.clear(); + await entry.session.close({ reason }); + await entry.consumeTask.catch(() => undefined); + } + async #create( objective: string, message: string, @@ -348,27 +591,35 @@ export class Phase4bDemoServer { normalizedSessionId, workingDirectory: this.#options.workingDirectory, }); - const descriptor = await driver.descriptor(); - const entry: DemoEntry = { - id, - runId, - normalizedSessionId, - manifestId, - envelope, - driver, - session, - capabilities: descriptor.capabilities, - events: [], - subscribers: new Set(), - consumeTask: Promise.resolve(), - }; - this.#entries.set(id, entry); - entry.consumeTask = this.#consume(entry, session); - if (startTurn) { - await session.startTurn({ message: { role: "user", text: message } }); - await new Promise((resolve) => setImmediate(resolve)); + let entry: DemoEntry | null = null; + try { + const descriptor = await driver.descriptor(); + entry = { + id, + runId, + normalizedSessionId, + manifestId, + envelope, + driver, + session, + capabilities: descriptor.capabilities, + events: [], + subscribers: new Set(), + consumeTask: Promise.resolve(), + }; + this.#entries.set(id, entry); + entry.consumeTask = this.#consume(entry, session); + if (startTurn) { + await session.startTurn({ message: { role: "user", text: message } }); + await new Promise((resolve) => setImmediate(resolve)); + } + return entry; + } catch (error) { + this.#entries.delete(id); + await session.close({ reason: "demo_session_start_failed" }).catch(() => undefined); + await entry?.consumeTask.catch(() => undefined); + throw error; } - return entry; } async #consume(entry: DemoEntry, session: HarnessSession): Promise { diff --git a/packages/paperclip-runner/vitest.config.ts b/packages/paperclip-runner/vitest.config.ts index c0828d81567..b8052bb7263 100644 --- a/packages/paperclip-runner/vitest.config.ts +++ b/packages/paperclip-runner/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ "src/**/*.test.ts", "devtools/browser/src/**/*.test.ts", "scripts/phase2-browser-server.test.mjs", + "scripts/phase4b-browser-server.test.mjs", ], }, });