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