From 0566a9f3cac01e4fa0d130a22ab997247b7f6e68 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 9 Aug 2026 16:15:17 +0000 Subject: [PATCH] feat(runner): harden Phase 7I demo deployment Co-Authored-By: Paperclip --- .../examples/phase7-explorer/src/app.tsx | 11 +- .../src/components/chat-surface.tsx | 33 +- .../examples/phase7-explorer/src/main.tsx | 2 + .../src/remote-chat-session.ts | 107 +++ .../scripts/run-phase7i-demo.sh | 83 +++ .../src/phase7/chat-session.ts | 18 +- .../src/phase7/phase7i-demo-server.test.ts | 101 +++ .../src/phase7/phase7i-demo-server.ts | 662 ++++++++++++++++++ .../src/phase7/phase7i-loopback-proxy.ts | 97 +++ .../paperclip-runner/vite.phase7.config.ts | 1 + 10 files changed, 1102 insertions(+), 13 deletions(-) create mode 100644 packages/paperclip-runner/examples/phase7-explorer/src/remote-chat-session.ts create mode 100755 packages/paperclip-runner/scripts/run-phase7i-demo.sh create mode 100644 packages/paperclip-runner/src/phase7/phase7i-demo-server.test.ts create mode 100644 packages/paperclip-runner/src/phase7/phase7i-demo-server.ts create mode 100644 packages/paperclip-runner/src/phase7/phase7i-loopback-proxy.ts diff --git a/packages/paperclip-runner/examples/phase7-explorer/src/app.tsx b/packages/paperclip-runner/examples/phase7-explorer/src/app.tsx index 3767eb6d773..94ee48f52dd 100644 --- a/packages/paperclip-runner/examples/phase7-explorer/src/app.tsx +++ b/packages/paperclip-runner/examples/phase7-explorer/src/app.tsx @@ -3,7 +3,6 @@ import * as React from "react"; import { Tabs, type TabDefinition } from "@paperclipai/paperclip-runner/react"; import { phase7RunScenario, - Phase7ChatSession, type Phase7ChatSessionArtifact, type Phase7EvalSuiteLookup, type Phase7ParityStatus, @@ -23,7 +22,11 @@ import { buildFacets, ScenarioPicker, type Phase7PickerFacets } from "./componen import { RunHeader } from "./components/run-header.js"; import { ScenarioTranscript } from "./components/scenario-transcript.js"; import { EmptyState, Mono } from "./components/primitives.js"; -import { ChatSurface, type Phase7ChatState } from "./components/chat-surface.js"; +import { + ChatSurface, + type OpenPhase7ChatSession, + type Phase7ChatState, +} from "./components/chat-surface.js"; import { ActivityEmptyState, ActivityStream, @@ -57,7 +60,7 @@ export interface ExplorerAppProps { /** Test seam: run scenarios synchronously through an injected runner. */ runScenario?: typeof phase7RunScenario; /** Test seam: open chat sessions through an injected session factory. */ - openSession?: typeof Phase7ChatSession.open; + openSession?: OpenPhase7ChatSession; } type RunState = "idle" | "pending" | "settled" | "failed"; @@ -555,7 +558,7 @@ function IntroCard({

{index.entries.length} scenarios across {index.groups.length} eval groups

- Every scenario runs against the deterministic mock control plane in this page. The + Every scenario runs against the deterministic, package-local mock control plane. The {chat ? " chat" : " explorer"} renders records the runtime produced: it holds no credential, decides no policy, and owns no control-plane state.

diff --git a/packages/paperclip-runner/examples/phase7-explorer/src/components/chat-surface.tsx b/packages/paperclip-runner/examples/phase7-explorer/src/components/chat-surface.tsx index 3769dbe8024..dd8220e0e7e 100644 --- a/packages/paperclip-runner/examples/phase7-explorer/src/components/chat-surface.tsx +++ b/packages/paperclip-runner/examples/phase7-explorer/src/components/chat-surface.tsx @@ -4,6 +4,7 @@ import { Composer, Dialog, Button } from "@paperclipai/paperclip-runner/react"; import { Phase7ChatSession, type Phase7ChatSessionArtifact, + type Phase7ChatSessionOptions, type Phase7ScenarioIndexEntry, } from "@paperclip-runner-local/phase7"; @@ -14,10 +15,9 @@ import { EmptyState } from "./primitives.js"; /** * Phase 7I chat surface. * - * Owns one live `Phase7ChatSession` and nothing else: no protocol state, no - * policy decision, no credential. Prompts go to the package-local mock control - * plane in this page, and the records that come back are rendered as they - * arrived. + * Owns one live session client and nothing else: no protocol state, no policy + * decision, no credential. Prompts go to the package-local mock control plane, + * and the records that come back are rendered as they arrived. * * Session identity is deliberately explicit. `activeEntry` is the scenario the * open session belongs to, which is not always the scenario the route names: @@ -27,6 +27,19 @@ import { EmptyState } from "./primitives.js"; export type Phase7ChatState = "seeding" | "pending" | "streaming" | "settled" | "failed"; +export interface Phase7ChatSessionClient { + artifact(): Phase7ChatSessionArtifact; + nextPrompt(): string | null; + send(prompt: string): Promise; + replay(): Promise; + close(): Promise; +} + +export type OpenPhase7ChatSession = ( + entry: Phase7ScenarioIndexEntry, + options?: Phase7ChatSessionOptions, +) => Promise; + export interface ChatSurfaceProps { entry: Phase7ScenarioIndexEntry; /** `replay=fake` plays the whole script on load for screenshot routes. */ @@ -46,7 +59,7 @@ export interface ChatSurfaceProps { onStateChange: (state: Phase7ChatState) => void; /** Called when the board declines a scenario switch, to restore the route. */ onCancelScenarioSwitch: (entryId: string) => void; - openSession?: typeof Phase7ChatSession.open; + openSession?: OpenPhase7ChatSession; headerRef?: React.Ref; } @@ -61,7 +74,7 @@ export function ChatSurface({ onArtifactChange, onStateChange, onCancelScenarioSwitch, - openSession = Phase7ChatSession.open, + openSession = (nextEntry, options) => Phase7ChatSession.open(nextEntry, options), headerRef, }: ChatSurfaceProps) { const [artifact, setArtifact] = React.useState(null); @@ -72,7 +85,7 @@ export function ChatSurface({ const [revealed, setRevealed] = React.useState(0); const [mode, setMode] = React.useState("scripted"); const [confirm, setConfirm] = React.useState<"none" | "reset" | "switch">("none"); - const sessionRef = React.useRef(null); + const sessionRef = React.useRef(null); const composerRef = React.useRef(null); const switching = entry.id !== activeEntry.id; @@ -275,11 +288,15 @@ export function ChatSurface({ />

{mode === "scripted" - ? `Scripted mode — your prompt drives the next recorded turn against the in-page mock control plane. ${ + ? `Scripted mode — your prompt drives the next recorded turn against the isolated mock control plane. ${ artifact?.remainingScriptedTurns ?? 0 } scripted turn${(artifact?.remainingScriptedTurns ?? 0) === 1 ? "" : "s"} left.` : "Codex mode drives the same mock control plane through the server-side relay; no provider credential reaches this page."}

+

+ Synthetic demo only. Session state is memory-only and is cleared on reset, expiry, or restart; + do not enter confidential or regulated data. +

, ); diff --git a/packages/paperclip-runner/examples/phase7-explorer/src/remote-chat-session.ts b/packages/paperclip-runner/examples/phase7-explorer/src/remote-chat-session.ts new file mode 100644 index 00000000000..153f665c2d1 --- /dev/null +++ b/packages/paperclip-runner/examples/phase7-explorer/src/remote-chat-session.ts @@ -0,0 +1,107 @@ +import type { + Phase7ChatSessionArtifact, + Phase7ChatSessionOptions, + Phase7ScenarioIndexEntry, +} from "@paperclip-runner-local/phase7"; + +import type { Phase7ChatSessionClient } from "./components/chat-surface.js"; + +type SessionResponse = { + sessionId: string; + artifact: Phase7ChatSessionArtifact; + nextPrompt: string | null; +}; + +function apiBaseFromDocument(): URL | null { + const configured = document + .querySelector('meta[name="phase7i-api-base"]') + ?.content.trim(); + if (!configured) return null; + const url = new URL(configured.replace(/\/*$/, "/"), document.baseURI); + return url.origin === window.location.origin ? url : null; +} + +async function parseResponse(response: Response): Promise { + if (!response.ok) throw new Error(`The Phase 7I demo returned HTTP ${response.status}.`); + const value: unknown = await response.json(); + if ( + typeof value !== "object" || value === null || + typeof (value as { sessionId?: unknown }).sessionId !== "string" || + typeof (value as { artifact?: unknown }).artifact !== "object" + ) { + throw new Error("The Phase 7I demo returned an invalid session response."); + } + return value as SessionResponse; +} + +export class Phase7RemoteChatSession implements Phase7ChatSessionClient { + private constructor( + private readonly apiBase: URL, + private sessionId: string, + private currentArtifact: Phase7ChatSessionArtifact, + private suggestedPrompt: string | null, + ) {} + + static available(): boolean { + return apiBaseFromDocument() !== null; + } + + static async open( + entry: Phase7ScenarioIndexEntry, + _options: Phase7ChatSessionOptions = {}, + ): Promise { + const apiBase = apiBaseFromDocument(); + if (!apiBase) throw new Error("The Phase 7I server endpoint is not configured."); + const response = await fetch(new URL("sessions", apiBase), { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scenarioId: entry.id }), + }); + const opened = await parseResponse(response); + return new Phase7RemoteChatSession( + apiBase, + opened.sessionId, + opened.artifact, + opened.nextPrompt, + ); + } + + artifact(): Phase7ChatSessionArtifact { + return this.currentArtifact; + } + + nextPrompt(): string | null { + return this.suggestedPrompt; + } + + async send(prompt: string): Promise { + return await this.mutate("turn", { prompt }); + } + + async replay(): Promise { + return await this.mutate("replay", {}); + } + + async close(): Promise { + await fetch(new URL(`sessions/${this.sessionId}`, this.apiBase), { + method: "DELETE", + credentials: "same-origin", + keepalive: true, + }).catch(() => undefined); + } + + private async mutate(action: "turn" | "replay", body: Record): Promise { + const response = await fetch(new URL(`sessions/${this.sessionId}/${action}`, this.apiBase), { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const updated = await parseResponse(response); + this.sessionId = updated.sessionId; + this.currentArtifact = updated.artifact; + this.suggestedPrompt = updated.nextPrompt; + return this.currentArtifact; + } +} diff --git a/packages/paperclip-runner/scripts/run-phase7i-demo.sh b/packages/paperclip-runner/scripts/run-phase7i-demo.sh new file mode 100755 index 00000000000..fb9a025d335 --- /dev/null +++ b/packages/paperclip-runner/scripts/run-phase7i-demo.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +umask 077 +ulimit -c 0 +ulimit -n 128 +ulimit -f 2048 + +: "${PHASE7I_PUBLIC_ORIGIN:?PHASE7I_PUBLIC_ORIGIN is required}" +: "${PHASE7I_COMMIT_SHA:?PHASE7I_COMMIT_SHA is required}" +: "${PHASE7I_PROXY_PORT:?PHASE7I_PROXY_PORT is required}" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +package_root="$(cd "$script_dir/.." && pwd)" +workspace_root="$(cd "$package_root/../.." && pwd)" +socket_dir="${PHASE7I_SOCKET_DIR:-/tmp/paperclip-phase7i-pap-16917}" +socket_path="$socket_dir/phase7i.sock" + +install -d -m 700 "$socket_dir" +rm -f "$socket_path" + +server_pid="" +proxy_pid="" +cleanup() { + trap - EXIT INT TERM + if [[ -n "$proxy_pid" ]]; then kill -TERM "$proxy_pid" 2>/dev/null || true; fi + if [[ -n "$server_pid" ]]; then kill -TERM "$server_pid" 2>/dev/null || true; fi + if [[ -n "$proxy_pid" ]]; then wait "$proxy_pid" 2>/dev/null || true; fi + if [[ -n "$server_pid" ]]; then wait "$server_pid" 2>/dev/null || true; fi + rm -f "$socket_path" +} +trap cleanup EXIT INT TERM + +unshare -Un \ + --map-user=65534 \ + --map-group=65534 \ + --kill-child=SIGTERM \ + --fork \ + env -i \ + PATH=/usr/local/bin:/usr/bin:/bin \ + NODE_ENV=production \ + PHASE7I_PUBLIC_ORIGIN="$PHASE7I_PUBLIC_ORIGIN" \ + PHASE7I_BASE_PATH="${PHASE7I_BASE_PATH:-}" \ + PHASE7I_COMMIT_SHA="$PHASE7I_COMMIT_SHA" \ + PHASE7I_SOCKET_PATH="$socket_path" \ + node \ + --max-old-space-size=256 \ + --permission \ + --allow-fs-read="$package_root/dist" \ + --allow-fs-read="$package_root/dist-phase7" \ + --allow-fs-read="$package_root/node_modules" \ + --allow-fs-read="$package_root/package.json" \ + --allow-fs-read="$package_root/spec/phase-07/eval-traceability.yaml" \ + --allow-fs-read="$workspace_root/node_modules" \ + --allow-fs-write="$socket_dir" \ + "$package_root/dist/phase7/phase7i-demo-server.js" & +server_pid=$! + +for _ in $(seq 1 200); do + if [[ -S "$socket_path" ]]; then break; fi + if ! kill -0 "$server_pid" 2>/dev/null; then wait "$server_pid"; fi + sleep 0.05 +done +if [[ ! -S "$socket_path" ]]; then + echo '{"event":"startup_failed","code":"socket_not_ready"}' >&2 + exit 1 +fi + +env -i \ + PATH=/usr/local/bin:/usr/bin:/bin \ + NODE_ENV=production \ + PHASE7I_PROXY_PORT="$PHASE7I_PROXY_PORT" \ + PHASE7I_SOCKET_PATH="$socket_path" \ +node \ + --max-old-space-size=64 \ + --permission \ + --allow-fs-read="$package_root/dist/phase7/phase7i-loopback-proxy.js" \ + --allow-fs-read="$socket_dir" \ + --allow-fs-write="$socket_dir" \ + "$package_root/dist/phase7/phase7i-loopback-proxy.js" & +proxy_pid=$! + +wait -n "$server_pid" "$proxy_pid" diff --git a/packages/paperclip-runner/src/phase7/chat-session.ts b/packages/paperclip-runner/src/phase7/chat-session.ts index d3d317d3cd9..4c2f7ae5a67 100644 --- a/packages/paperclip-runner/src/phase7/chat-session.ts +++ b/packages/paperclip-runner/src/phase7/chat-session.ts @@ -103,13 +103,29 @@ export class Phase7ChatSession { static async open( entry: Phase7ScenarioIndexEntry, options: Phase7ChatSessionOptions = {}, + ): Promise { + const fixture = phase7ScenarioFixture(entry); + const adapter = new Phase7MockControlPlaneAdapter(fixture.seed); + return await Phase7ChatSession.openWithMockAdapter(entry, adapter, options); + } + + /** + * Server composition seam for the hardened Phase 7I demo. + * + * The deployment entrypoint constructs the concrete mock adapter itself and + * passes that already-selected implementation here. HTTP input, environment + * variables, and dependency injection cannot select a different adapter. + */ + static async openWithMockAdapter( + entry: Phase7ScenarioIndexEntry, + adapter: Phase7MockControlPlaneAdapter, + options: Phase7ChatSessionOptions = {}, ): Promise { const mode = options.mode ?? "fake"; const fixture = phase7ScenarioFixture(entry); const script = phase7ChatScript(entry, fixture); const runId = `run-phase7-chat-${entry.id}`; - const adapter = new Phase7MockControlPlaneAdapter(fixture.seed); await adapter.start(); const beforeSeed = snapshotOf(adapter); diff --git a/packages/paperclip-runner/src/phase7/phase7i-demo-server.test.ts b/packages/paperclip-runner/src/phase7/phase7i-demo-server.test.ts new file mode 100644 index 00000000000..8f9e3032cec --- /dev/null +++ b/packages/paperclip-runner/src/phase7/phase7i-demo-server.test.ts @@ -0,0 +1,101 @@ +import { readFile } from "node:fs/promises"; +import { dirname, extname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { + assertPhase7iEnvironmentSafe, + forbiddenPhase7iEnvironmentNames, + Phase7iDemoRuntime, +} from "./phase7i-demo-server.js"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const sourceRoot = resolve(packageRoot, "src"); + +describe("Phase 7I deployment boundary", () => { + it("constructs the concrete mock and initializes a ready, bounded runtime", async () => { + const runtime = new Phase7iDemoRuntime({ + publicOrigin: "https://phase7i.example.ts.net", + basePath: "/phase7i", + commitSha: "deadbeef", + packageRoot, + log: () => undefined, + }); + await runtime.initialize(); + expect(runtime.health()).toMatchObject({ + ready: true, + commitSha: "deadbeef", + controlPlane: "phase7-mock", + providerAuthentication: "server-side", + credentialsExposed: false, + networkEgress: "denied", + retention: "memory-only", + activeSessions: 0, + }); + await runtime.shutdown(); + }); + + it("rejects ambient credential families by name without inspecting values", () => { + const unsafe = { + PATH: "/usr/bin", + PAPERCLIP_API_KEY: "not-read", + AWS_SECRET_ACCESS_KEY: "not-read", + OPENAI_API_KEY: "not-read", + VERCEL_TOKEN: "not-read", + }; + expect(forbiddenPhase7iEnvironmentNames(unsafe)).toEqual([ + "AWS_SECRET_ACCESS_KEY", + "OPENAI_API_KEY", + "PAPERCLIP_API_KEY", + "VERCEL_TOKEN", + ]); + expect(() => assertPhase7iEnvironmentSafe(unsafe)).toThrow(/forbidden environment names/); + expect(() => assertPhase7iEnvironmentSafe({ + NODE_ENV: "production", + PATH: "/usr/bin", + PHASE7I_COMMIT_SHA: "deadbeef", + PHASE7I_PUBLIC_ORIGIN: "https://phase7i.example.ts.net", + })).not.toThrow(); + }); + + it("keeps the deployed source closure package-local, mock-only, and free of dynamic imports", async () => { + const closure = await sourceClosure(resolve(sourceRoot, "phase7/phase7i-demo-server.ts")); + const rootSource = await readFile(resolve(sourceRoot, "phase7/phase7i-demo-server.ts"), "utf8"); + expect(rootSource).toContain('import { Phase7MockControlPlaneAdapter }'); + expect(rootSource).toContain("new Phase7MockControlPlaneAdapter(fixture.seed)"); + expect(closure.size).toBeGreaterThan(10); + + for (const [path, source] of closure) { + expect(source, path).not.toMatch(/\bimport\s*\(/); + expect(source, path).not.toMatch(/PaperclipControlPlanePort|PAPERCLIP_API_(?:URL|KEY)|@paperclipai\/db/); + for (const specifier of importSpecifiers(source)) { + expect(specifier, `${path} imports outside the standalone boundary`).not.toMatch( + /^(?:server|ui|cli)(?:\/|$)|^@paperclipai\/(?!paperclip-runner(?:\/|$))/, + ); + } + } + }); +}); + +function importSpecifiers(source: string): string[] { + const matches = source.matchAll(/\b(?:import|export)\s+(?:type\s+)?(?:[^"'`;]{0,500}?\s+from\s+)?["']([^"']+)["']/g); + return [...matches].map((match) => match[1]!); +} + +async function sourceClosure(entry: string): Promise> { + const found = new Map(); + const visit = async (path: string): Promise => { + if (found.has(path)) return; + const source = await readFile(path, "utf8"); + found.set(path, source); + for (const specifier of importSpecifiers(source)) { + if (!specifier.startsWith(".")) continue; + const imported = resolve(dirname(path), specifier); + const target = extname(imported) === ".js" ? `${imported.slice(0, -3)}.ts` : imported; + await visit(target); + } + }; + await visit(entry); + return found; +} diff --git a/packages/paperclip-runner/src/phase7/phase7i-demo-server.ts b/packages/paperclip-runner/src/phase7/phase7i-demo-server.ts new file mode 100644 index 00000000000..09e7c6ec4ca --- /dev/null +++ b/packages/paperclip-runner/src/phase7/phase7i-demo-server.ts @@ -0,0 +1,662 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { fileURLToPath } from "node:url"; +import { dirname, extname, isAbsolute, relative, resolve } from "node:path"; + +import { Phase7MockControlPlaneAdapter } from "../mock-core/phase7-mock-control-plane-adapter.js"; +import { Phase7ChatSession } from "./chat-session.js"; +import { buildPhase7ScenarioIndex } from "./scenario-index.js"; +import { phase7ScenarioFixture } from "./scenario-fixtures.js"; +import type { + Phase7ChatSessionArtifact, + Phase7ScenarioIndex, + Phase7ScenarioIndexEntry, +} from "./scenario-explorer-types.js"; + +const REQUEST_BODY_BYTES = 64 * 1024; +const PROMPT_BYTES = 16 * 1024; +const MAX_ACTIVE_SESSIONS = 8; +const SESSION_IDLE_MS = 5 * 60_000; +const SESSION_LIFETIME_MS = 30 * 60_000; +const MAX_SESSION_EVENTS = 1_024; +const MAX_SERIALIZED_STATE_BYTES = 1024 * 1024; +const TURN_DEADLINE_MS = 30_000; +const CAPABILITY_COOKIE = "__Host-paperclip_phase7i"; + +const FORBIDDEN_ENV_NAME = /^(?:PAPERCLIP_|GITHUB_|AWS_|GOOGLE_|AZURE_)|(?:API_KEY|TOKEN|SECRET|PASSWORD|DATABASE_URL|CODEX_HOME)$/i; + +const CONTENT_TYPES: Record = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".txt": "text/plain; charset=utf-8", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +type SessionRecord = { + id: string; + capabilityHash: Buffer; + identityHash: string; + createdAt: number; + lastActiveAt: number; + activeTurn: boolean; + chat: Phase7ChatSession; +}; + +type RuntimeOptions = { + publicOrigin: string; + basePath: string; + commitSha: string; + packageRoot?: string; + now?: () => number; + log?: (record: Record) => void; +}; + +class RequestFailure extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + } +} + +function normalizeBasePath(value: string): string { + const trimmed = value.trim(); + if (trimmed === "" || trimmed === "/") return ""; + return `/${trimmed.replace(/^\/+|\/+$/g, "")}`; +} + +function sha256(value: string): Buffer { + return createHash("sha256").update(value).digest(); +} + +function shortHash(value: string): string { + return sha256(value).toString("hex").slice(0, 16); +} + +function header(req: IncomingMessage, name: string): string { + const value = req.headers[name]; + return Array.isArray(value) ? value[0] ?? "" : value ?? ""; +} + +function requestIdentity(req: IncomingMessage): string { + const login = header(req, "tailscale-user-login").trim(); + if (login) return `tailnet:${login.toLowerCase()}`; + const forwardedFor = header(req, "x-forwarded-for").split(",")[0]?.trim(); + return `tailnet-peer:${forwardedFor || "serve-proxy"}`; +} + +function parseCookies(req: IncomingMessage): Map { + const result = new Map(); + for (const item of header(req, "cookie").split(";")) { + const separator = item.indexOf("="); + if (separator <= 0) continue; + result.set(item.slice(0, separator).trim(), item.slice(separator + 1).trim()); + } + return result; +} + +function capabilityCookie(value: string, maxAgeSeconds = 1_800): string { + return `${CAPABILITY_COOKIE}=${value}; Path=/; Max-Age=${maxAgeSeconds}; HttpOnly; Secure; SameSite=Strict`; +} + +function applySecurityHeaders(res: ServerResponse): void { + res.setHeader("Cache-Control", "no-store"); + res.setHeader( + "Content-Security-Policy", + "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'", + ); + res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + res.setHeader("Cross-Origin-Resource-Policy", "same-origin"); + res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()"); + res.setHeader("Referrer-Policy", "no-referrer"); + res.setHeader("Strict-Transport-Security", "max-age=31536000"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); +} + +function sendJson(res: ServerResponse, status: number, value: unknown): void { + applySecurityHeaders(res); + const body = JSON.stringify(value); + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Content-Length", Buffer.byteLength(body)); + res.end(body); +} + +function sendError(res: ServerResponse, error: RequestFailure): void { + sendJson(res, error.status, { error: { code: error.code, message: error.message } }); +} + +async function readJsonBody(req: IncomingMessage): Promise> { + if (!header(req, "content-type").toLowerCase().startsWith("application/json")) { + throw new RequestFailure(415, "json_required", "Mutation requests require application/json."); + } + const declaredLength = Number(header(req, "content-length")); + if (Number.isFinite(declaredLength) && declaredLength > REQUEST_BODY_BYTES) { + throw new RequestFailure(413, "request_too_large", "Request body exceeds the 64 KiB limit."); + } + const chunks: Buffer[] = []; + let total = 0; + let exceeded = false; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.length; + if (total > REQUEST_BODY_BYTES) { + exceeded = true; + continue; + } + chunks.push(buffer); + } + if (exceeded) { + throw new RequestFailure(413, "request_too_large", "Request body exceeds the 64 KiB limit."); + } + try { + const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("not an object"); + } + return parsed as Record; + } catch { + throw new RequestFailure(400, "invalid_json", "Request body must be a JSON object."); + } +} + +function exactKeys(body: Record, expected: readonly string[]): void { + const expectedSet = new Set(expected); + const unknown = Object.keys(body).find((key) => !expectedSet.has(key)); + if (unknown) { + throw new RequestFailure(422, "unexpected_field", "Request contains an unsupported field."); + } +} + +function withDeadline(promise: Promise): Promise { + let timer: NodeJS.Timeout | undefined; + return Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new RequestFailure(504, "turn_timeout", "The turn exceeded its 30 second deadline.")), + TURN_DEADLINE_MS, + ); + }), + ]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + +function assertArtifactBounds(artifact: Phase7ChatSessionArtifact): void { + if (artifact.timeline.length > MAX_SESSION_EVENTS) { + throw new RequestFailure(507, "event_limit", "The session exceeded its retained event limit."); + } + if (Buffer.byteLength(JSON.stringify(artifact)) > MAX_SERIALIZED_STATE_BYTES) { + throw new RequestFailure(507, "state_limit", "The session exceeded its serialized state limit."); + } +} + +export function forbiddenPhase7iEnvironmentNames(env: NodeJS.ProcessEnv): string[] { + return Object.keys(env).filter((name) => FORBIDDEN_ENV_NAME.test(name)).sort(); +} + +export function assertPhase7iEnvironmentSafe(env: NodeJS.ProcessEnv): void { + const forbidden = forbiddenPhase7iEnvironmentNames(env); + if (forbidden.length > 0) { + throw new Error(`Phase 7I refused forbidden environment names: ${forbidden.join(", ")}`); + } +} + +export class Phase7iDemoRuntime { + readonly publicOrigin: URL; + readonly basePath: string; + readonly commitSha: string; + + private readonly packageRoot: string; + private readonly staticRoot: string; + private readonly now: () => number; + private readonly logRecord: (record: Record) => void; + private readonly sessions = new Map(); + private readonly rateBuckets = new Map(); + private scenarioIndex: Phase7ScenarioIndex | null = null; + private ready = false; + private closing = false; + + constructor(options: RuntimeOptions) { + this.publicOrigin = new URL(options.publicOrigin); + if (this.publicOrigin.protocol !== "https:") { + throw new Error("PHASE7I_PUBLIC_ORIGIN must use HTTPS"); + } + this.basePath = normalizeBasePath(options.basePath); + this.commitSha = options.commitSha.trim() || "unknown"; + this.packageRoot = options.packageRoot ?? resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + this.staticRoot = resolve(this.packageRoot, "dist-phase7"); + this.now = options.now ?? Date.now; + this.logRecord = options.log ?? ((record) => process.stdout.write(`${JSON.stringify(record)}\n`)); + } + + async initialize(): Promise { + const manifest = await readFile( + resolve(this.packageRoot, "spec/phase-07/eval-traceability.yaml"), + "utf8", + ); + this.scenarioIndex = buildPhase7ScenarioIndex(manifest); + const probe = this.scenarioIndex.entries[0]; + if (!probe) throw new Error("Phase 7 scenario corpus is empty"); + const chat = await this.openConcreteMockSession(probe); + await chat.close(); + this.ready = true; + } + + createServer(): Server { + const server = createServer((req, res) => void this.handle(req, res)); + server.maxConnections = 32; + server.requestTimeout = 35_000; + server.headersTimeout = 10_000; + server.keepAliveTimeout = 5_000; + return server; + } + + health(): Record { + return { + status: this.ready && !this.closing ? "ok" : "unavailable", + ready: this.ready && !this.closing && this.sessions.size < MAX_ACTIVE_SESSIONS, + commitSha: this.commitSha, + controlPlane: "phase7-mock", + providerAuthentication: "server-side", + providerMode: "deterministic-scripted-no-provider", + credentialsExposed: false, + networkEgress: "denied", + retention: "memory-only", + activeSessions: this.sessions.size, + limits: { + requestBodyBytes: REQUEST_BODY_BYTES, + promptBytes: PROMPT_BYTES, + activeSessions: MAX_ACTIVE_SESSIONS, + activeTurnsPerSession: 1, + turnDeadlineSeconds: TURN_DEADLINE_MS / 1_000, + idleExpirySeconds: SESSION_IDLE_MS / 1_000, + absoluteLifetimeSeconds: SESSION_LIFETIME_MS / 1_000, + retainedEventsPerSession: MAX_SESSION_EVENTS, + serializedStateBytesPerSession: MAX_SERIALIZED_STATE_BYTES, + }, + }; + } + + async shutdown(): Promise { + if (this.closing) return; + this.closing = true; + const records = [...this.sessions.values()]; + this.sessions.clear(); + await Promise.allSettled(records.map((record) => record.chat.close())); + this.ready = false; + } + + async expireSessions(): Promise { + const now = this.now(); + const expired = [...this.sessions.values()].filter( + (record) => now - record.lastActiveAt >= SESSION_IDLE_MS || now - record.createdAt >= SESSION_LIFETIME_MS, + ); + for (const record of expired) this.sessions.delete(record.id); + await Promise.allSettled(expired.map((record) => record.chat.close())); + return expired.length; + } + + private async openConcreteMockSession(entry: Phase7ScenarioIndexEntry): Promise { + const fixture = phase7ScenarioFixture(entry); + // The concrete adapter choice is fixed here. No request or environment + // value can supply a module, URL, path, or alternate control plane. + const adapter = new Phase7MockControlPlaneAdapter(fixture.seed); + return await Phase7ChatSession.openWithMockAdapter(entry, adapter, { mode: "fake" }); + } + + private stripBasePath(rawPath: string): string { + if (this.basePath && rawPath === this.basePath) return "/"; + if (this.basePath && rawPath.startsWith(`${this.basePath}/`)) { + return rawPath.slice(this.basePath.length) || "/"; + } + return rawPath; + } + + private assertProxyAdmission(req: IncomingMessage): void { + if (header(req, "x-phase7i-proxy") !== "v1") { + throw new RequestFailure(403, "trusted_proxy_required", "Request did not arrive through the trusted proxy."); + } + if (header(req, "x-forwarded-proto").split(",")[0]?.trim().toLowerCase() !== "https") { + throw new RequestFailure(403, "https_required", "HTTPS is required."); + } + const forwardedHost = header(req, "x-forwarded-host").split(",")[0]?.trim().toLowerCase(); + if (forwardedHost !== this.publicOrigin.host.toLowerCase()) { + throw new RequestFailure(403, "host_denied", "Forwarded host is not allowed."); + } + } + + private assertMutationAdmission(req: IncomingMessage): void { + this.assertProxyAdmission(req); + if (header(req, "origin") !== this.publicOrigin.origin) { + throw new RequestFailure(403, "origin_denied", "Request origin is not allowed."); + } + const fetchSite = header(req, "sec-fetch-site").toLowerCase(); + if (fetchSite && fetchSite !== "same-origin" && fetchSite !== "none") { + throw new RequestFailure(403, "fetch_metadata_denied", "Cross-site mutation requests are not allowed."); + } + } + + private enforceRate(key: string, limit: number, windowMs = 60_000): void { + const now = this.now(); + const bucket = (this.rateBuckets.get(key) ?? []).filter((timestamp) => now - timestamp < windowMs); + if (bucket.length >= limit) { + throw new RequestFailure(429, "rate_limited", "Request rate limit exceeded."); + } + bucket.push(now); + this.rateBuckets.set(key, bucket); + } + + private requireSession(req: IncomingMessage, id: string): SessionRecord { + const record = this.sessions.get(id); + if (!record) throw new RequestFailure(404, "session_not_found", "Session was not found."); + const capability = parseCookies(req).get(CAPABILITY_COOKIE) ?? ""; + const capabilityHash = sha256(capability); + const identityHash = shortHash(requestIdentity(req)); + if ( + capability.length < 32 || + capabilityHash.length !== record.capabilityHash.length || + !timingSafeEqual(capabilityHash, record.capabilityHash) || + identityHash !== record.identityHash + ) { + throw new RequestFailure(404, "session_not_found", "Session was not found."); + } + const now = this.now(); + if (now - record.lastActiveAt >= SESSION_IDLE_MS || now - record.createdAt >= SESSION_LIFETIME_MS) { + this.sessions.delete(record.id); + void record.chat.close(); + throw new RequestFailure(404, "session_not_found", "Session was not found."); + } + record.lastActiveAt = now; + return record; + } + + private async createSession(req: IncomingMessage, res: ServerResponse, body: Record): Promise { + exactKeys(body, ["scenarioId"]); + const scenarioId = typeof body.scenarioId === "string" ? body.scenarioId : ""; + const entry = this.scenarioIndex?.entries.find((candidate) => candidate.id === scenarioId); + if (!entry) throw new RequestFailure(422, "scenario_unknown", "Scenario is not available."); + if (this.sessions.size >= MAX_ACTIVE_SESSIONS) { + throw new RequestFailure(429, "session_capacity", "The active session limit has been reached."); + } + const identityHash = shortHash(requestIdentity(req)); + this.enforceRate("global:create", 40); + this.enforceRate(`${identityHash}:create`, 10); + const capability = randomBytes(32).toString("base64url"); + const chat = await this.openConcreteMockSession(entry); + const artifact = chat.artifact(); + assertArtifactBounds(artifact); + const now = this.now(); + const record: SessionRecord = { + id: randomBytes(16).toString("hex"), + capabilityHash: sha256(capability), + identityHash, + createdAt: now, + lastActiveAt: now, + activeTurn: false, + chat, + }; + this.sessions.set(record.id, record); + res.setHeader("Set-Cookie", capabilityCookie(capability)); + sendJson(res, 201, { sessionId: record.id, artifact, nextPrompt: chat.nextPrompt() }); + } + + private async runTurn( + req: IncomingMessage, + res: ServerResponse, + record: SessionRecord, + body: Record, + ): Promise { + exactKeys(body, ["prompt"]); + if (typeof body.prompt !== "string" || body.prompt.trim() === "") { + throw new RequestFailure(422, "prompt_required", "Prompt must be a non-empty string."); + } + const prompt = body.prompt.trim(); + if (Buffer.byteLength(prompt, "utf8") > PROMPT_BYTES) { + throw new RequestFailure(422, "prompt_too_large", "Prompt exceeds the 16 KiB limit."); + } + if (record.activeTurn) throw new RequestFailure(409, "turn_active", "A turn is already active."); + this.enforceRate("global:turn", 120); + this.enforceRate(`${record.identityHash}:turn`, 30); + record.activeTurn = true; + try { + const artifact = await withDeadline(record.chat.send(prompt)); + assertArtifactBounds(artifact); + sendJson(res, 200, { sessionId: record.id, artifact, nextPrompt: record.chat.nextPrompt() }); + } catch (error) { + if (error instanceof RequestFailure && error.code === "turn_timeout") { + this.sessions.delete(record.id); + await record.chat.close(); + } + throw error; + } finally { + record.activeTurn = false; + } + } + + private async replaySession(res: ServerResponse, record: SessionRecord): Promise { + if (record.activeTurn) throw new RequestFailure(409, "turn_active", "A turn is already active."); + this.enforceRate("global:turn", 120); + this.enforceRate(`${record.identityHash}:turn`, 30); + record.activeTurn = true; + try { + const artifact = await withDeadline(record.chat.replay()); + assertArtifactBounds(artifact); + sendJson(res, 200, { sessionId: record.id, artifact, nextPrompt: record.chat.nextPrompt() }); + } finally { + record.activeTurn = false; + } + } + + private async resetSession(req: IncomingMessage, res: ServerResponse, record: SessionRecord): Promise { + this.enforceRate(`${record.identityHash}:reset`, 10); + this.sessions.delete(record.id); + const entry = record.chat.entry; + await record.chat.close(); + const capability = randomBytes(32).toString("base64url"); + const chat = await this.openConcreteMockSession(entry); + const artifact = chat.artifact(); + assertArtifactBounds(artifact); + const now = this.now(); + const replacement: SessionRecord = { + id: randomBytes(16).toString("hex"), + capabilityHash: sha256(capability), + identityHash: record.identityHash, + createdAt: now, + lastActiveAt: now, + activeTurn: false, + chat, + }; + this.sessions.set(replacement.id, replacement); + res.setHeader("Set-Cookie", capabilityCookie(capability)); + sendJson(res, 200, { sessionId: replacement.id, artifact, nextPrompt: chat.nextPrompt() }); + } + + private async deleteSession(res: ServerResponse, record: SessionRecord): Promise { + this.sessions.delete(record.id); + await record.chat.close(); + res.setHeader("Set-Cookie", capabilityCookie("deleted", 0)); + sendJson(res, 200, { closed: true }); + } + + private async serveStatic(req: IncomingMessage, res: ServerResponse, pathname: string): Promise { + this.assertProxyAdmission(req); + if (req.method !== "GET" && req.method !== "HEAD") { + throw new RequestFailure(405, "method_not_allowed", "Method is not allowed."); + } + const decoded = decodeURIComponent(pathname); + const relativePath = decoded === "/" ? "phase7-explorer/index.html" : decoded.replace(/^\/+/, ""); + const filePath = resolve(this.staticRoot, relativePath); + const relativeToRoot = relative(this.staticRoot, filePath); + if (relativeToRoot.startsWith("..") || isAbsolute(relativeToRoot)) { + throw new RequestFailure(404, "not_found", "Resource was not found."); + } + let body: Buffer; + try { + body = await readFile(filePath); + } catch { + throw new RequestFailure(404, "not_found", "Resource was not found."); + } + if (relativePath === "phase7-explorer/index.html") { + const html = body.toString("utf8").replace( + "", + '', + ); + body = Buffer.from(html); + } + applySecurityHeaders(res); + res.statusCode = 200; + res.setHeader("Content-Type", CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream"); + res.setHeader("Content-Length", body.length); + res.end(req.method === "HEAD" ? undefined : body); + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const startedAt = this.now(); + const requestId = randomBytes(8).toString("hex"); + let route = "unknown"; + let outcome = "error"; + try { + if (this.closing) throw new RequestFailure(503, "shutting_down", "Service is shutting down."); + const url = new URL(req.url ?? "/", "http://phase7i.invalid"); + const pathname = this.stripBasePath(url.pathname); + if (pathname === "/api/phase7/health" && req.method === "GET") { + route = "health"; + this.assertProxyAdmission(req); + sendJson(res, this.ready ? 200 : 503, this.health()); + outcome = "ok"; + return; + } + if (pathname.startsWith("/api/phase7/")) { + this.assertMutationAdmission(req); + const identityHash = shortHash(requestIdentity(req)); + this.enforceRate("global:request", 300); + this.enforceRate(`${identityHash}:request`, 90); + if (pathname === "/api/phase7/sessions" && req.method === "POST") { + route = "session_create"; + await this.createSession(req, res, await readJsonBody(req)); + outcome = "created"; + return; + } + const match = pathname.match(/^\/api\/phase7\/sessions\/([a-f0-9]{32})(?:\/(turn|replay|reset))?$/); + if (!match) throw new RequestFailure(404, "not_found", "Route was not found."); + const record = this.requireSession(req, match[1]!); + const action = match[2] ?? "session"; + route = action; + if (action === "turn" && req.method === "POST") { + await this.runTurn(req, res, record, await readJsonBody(req)); + outcome = "settled"; + return; + } + if (action === "replay" && req.method === "POST") { + exactKeys(await readJsonBody(req), []); + await this.replaySession(res, record); + outcome = "settled"; + return; + } + if (action === "reset" && req.method === "POST") { + exactKeys(await readJsonBody(req), []); + await this.resetSession(req, res, record); + outcome = "reset"; + return; + } + if (action === "session" && req.method === "DELETE") { + await this.deleteSession(res, record); + outcome = "closed"; + return; + } + throw new RequestFailure(405, "method_not_allowed", "Method is not allowed."); + } + route = "static"; + await this.serveStatic(req, res, pathname); + outcome = "ok"; + } catch (error) { + const failure = error instanceof RequestFailure + ? error + : new RequestFailure(500, "internal_error", "The request could not be completed."); + if (!res.headersSent) sendError(res, failure); + outcome = failure.code; + } finally { + this.logRecord({ + timestamp: new Date().toISOString(), + requestId, + route, + method: req.method ?? "UNKNOWN", + status: res.statusCode, + outcome, + latencyMs: Math.max(0, this.now() - startedAt), + identity: shortHash(requestIdentity(req)), + }); + } + } +} + +async function main(): Promise { + assertPhase7iEnvironmentSafe(process.env); + const socketPath = process.env.PHASE7I_SOCKET_PATH?.trim(); + const publicOrigin = process.env.PHASE7I_PUBLIC_ORIGIN?.trim(); + if (!socketPath || !isAbsolute(socketPath)) throw new Error("PHASE7I_SOCKET_PATH must be absolute"); + if (!publicOrigin) throw new Error("PHASE7I_PUBLIC_ORIGIN is required"); + const runtime = new Phase7iDemoRuntime({ + publicOrigin, + basePath: process.env.PHASE7I_BASE_PATH ?? "", + commitSha: process.env.PHASE7I_COMMIT_SHA ?? "unknown", + }); + await runtime.initialize(); + const server = runtime.createServer(); + const expiryTimer = setInterval(() => void runtime.expireSessions(), 30_000); + expiryTimer.unref(); + + const stop = async (signal: string): Promise => { + clearInterval(expiryTimer); + process.stdout.write(`${JSON.stringify({ timestamp: new Date().toISOString(), event: "shutdown", signal })}\n`); + server.close(); + await runtime.shutdown(); + process.exit(0); + }; + process.once("SIGTERM", () => void stop("SIGTERM")); + process.once("SIGINT", () => void stop("SIGINT")); + + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(socketPath, () => { + server.off("error", rejectListen); + resolveListen(); + }); + }); + process.stdout.write(`${JSON.stringify({ + timestamp: new Date().toISOString(), + event: "ready", + controlPlane: "phase7-mock", + commitSha: runtime.commitSha, + networkEgress: "denied", + })}\n`); +} + +const entryPath = process.argv[1] ? resolve(process.argv[1]) : ""; +if (entryPath === fileURLToPath(import.meta.url)) { + void main().catch((error) => { + process.stderr.write(`${JSON.stringify({ + timestamp: new Date().toISOString(), + event: "startup_failed", + code: + typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" + ? error.code + : error instanceof Error + ? error.name + : "Error", + })}\n`); + process.exitCode = 1; + }); +} diff --git a/packages/paperclip-runner/src/phase7/phase7i-loopback-proxy.ts b/packages/paperclip-runner/src/phase7/phase7i-loopback-proxy.ts new file mode 100644 index 00000000000..6facf02a81f --- /dev/null +++ b/packages/paperclip-runner/src/phase7/phase7i-loopback-proxy.ts @@ -0,0 +1,97 @@ +import { request as proxyRequest, createServer, type IncomingHttpHeaders } from "node:http"; + +const MAX_BODY_BYTES = 64 * 1024; +const FORWARDED_HEADERS = new Set([ + "accept", + "accept-encoding", + "content-length", + "content-type", + "cookie", + "host", + "origin", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + "tailscale-user-login", + "tailscale-user-name", + "user-agent", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", +]); + +function selectedHeaders(headers: IncomingHttpHeaders): Record { + const selected: Record = {}; + for (const [name, value] of Object.entries(headers)) { + if (!FORWARDED_HEADERS.has(name) || value === undefined) continue; + selected[name] = value; + } + selected["x-forwarded-proto"] = Array.isArray(headers["x-forwarded-proto"]) + ? headers["x-forwarded-proto"][0] ?? "https" + : headers["x-forwarded-proto"] ?? "https"; + selected["x-forwarded-host"] = Array.isArray(headers["x-forwarded-host"]) + ? headers["x-forwarded-host"][0] ?? String(headers.host ?? "") + : headers["x-forwarded-host"] ?? String(headers.host ?? ""); + selected["x-phase7i-proxy"] = "v1"; + return selected; +} + +function main(): void { + const socketPath = process.env.PHASE7I_SOCKET_PATH?.trim(); + const port = Number(process.env.PHASE7I_PROXY_PORT); + if (!socketPath || !Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error("PHASE7I_SOCKET_PATH and a valid PHASE7I_PROXY_PORT are required"); + } + + const server = createServer((req, res) => { + const declaredLength = Number(req.headers["content-length"]); + if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) { + res.writeHead(413, { "Cache-Control": "no-store", "Content-Type": "application/json" }); + res.end('{"error":{"code":"request_too_large","message":"Request body exceeds the 64 KiB limit."}}'); + return; + } + + const upstream = proxyRequest({ + socketPath, + method: req.method, + path: req.url, + headers: selectedHeaders(req.headers), + timeout: 35_000, + }, (upstreamResponse) => { + res.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); + upstreamResponse.pipe(res); + }); + upstream.once("timeout", () => upstream.destroy(new Error("upstream timeout"))); + upstream.once("error", () => { + if (res.headersSent) { + res.destroy(); + return; + } + res.writeHead(502, { "Cache-Control": "no-store", "Content-Type": "application/json" }); + res.end('{"error":{"code":"upstream_unavailable","message":"Demo service is unavailable."}}'); + }); + req.pipe(upstream); + }); + server.maxConnections = 32; + server.requestTimeout = 35_000; + server.headersTimeout = 10_000; + server.keepAliveTimeout = 5_000; + server.listen(port, "127.0.0.1"); + + const stop = (): void => { + server.close(() => process.exit(0)); + }; + process.once("SIGTERM", stop); + process.once("SIGINT", stop); +} + +try { + main(); +} catch (error) { + process.stderr.write(`${JSON.stringify({ + timestamp: new Date().toISOString(), + event: "proxy_startup_failed", + code: error instanceof Error ? error.name : "Error", + })}\n`); + process.exitCode = 1; +} diff --git a/packages/paperclip-runner/vite.phase7.config.ts b/packages/paperclip-runner/vite.phase7.config.ts index efd473b06cd..c8c80320430 100644 --- a/packages/paperclip-runner/vite.phase7.config.ts +++ b/packages/paperclip-runner/vite.phase7.config.ts @@ -18,6 +18,7 @@ const packageRoot = fileURLToPath(new URL(".", import.meta.url)); */ export default defineConfig({ root: resolve(packageRoot, "examples"), + base: "./", plugins: [react(), phase7ExplorerFixturesPlugin()], resolve: { alias: [