mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
fix(runner): secure phase4b browser transport
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
96092b832d
commit
405a4a4506
12 files changed
+730
-116
No files matched your search
@@ -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",
|
||||
],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user