fix(runner): secure phase4b browser transport

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-08 05:43:13 +00:00
1 parent 96092b832d
commit 405a4a4506
12 files changed
+730 -116

No files matched your search

+4
View File
@@ -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 |
@@ -127,6 +127,13 @@ export async function reconnectSession(sessionId: string): Promise<LiveSessionSt
});
}
export async function closeSession(sessionId: string): Promise<void> {
await send(`/sessions/${sessionId}/close`, {
method: "POST",
body: JSON.stringify({}),
});
}
export interface EventStreamHandle {
close(): void;
}
@@ -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;
@@ -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
@@ -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:
@@ -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:
@@ -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 };
@@ -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",
});
});
});
@@ -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;
@@ -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<Response> {
return fetch(`${url}${path}`, {
...init,
headers: browserHeaders(url, init.headers),
});
}
function jsonBrowserRequest(
url: string,
path: string,
body: Record<string, unknown>,
): Promise<Response> {
return browserRequest(url, path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
async function rawRequest(
url: string,
headers: Record<string, string>,
): Promise<{ status: number; body: Record<string, unknown> }> {
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<string, unknown>,
}));
});
request.once("error", reject);
request.end();
});
}
async function openEventStream(
url: string,
sessionId: string,
): Promise<ReturnType<typeof httpRequest>> {
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<string, unknown>;
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<string, unknown>;
}).then((response) => response.json()) as Record<string, unknown>;
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("<server-path>");
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const reconnected = await jsonBrowserRequest(
url,
`/api/phase4b/sessions/${sessionId}/reconnect`,
{},
).then((response) => response.json()) as Record<string, unknown>;
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);
});
@@ -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, unknown>): 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<Pick<Phase4bDemoServerOptions, "host" | "port">> &
Omit<Phase4bDemoServerOptions, "host" | "port">;
readonly #entries = new Map<string, DemoEntry>();
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<void>((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<string, unknown>;
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<void> {
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<void>((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<void>((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<void> {
@@ -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",
],
},
});