feat(runner): add Phase 7M clean-room live Codex chat

Adds a second primary path beside the preset scenario explorer. `#/chat`
opens a blank thread on a freshly minted mock tenant — one company, one
agent, one blank issue — and drives real Codex through real runnerd. The
scenario explorer and its deterministic deep links are unchanged.

The clean room is live-only by construction. The route pins `mode=live`
and drops `shot`/`at`, the package server has no scripted path for it and
refuses any non-live projection, and a failure to start runnerd or Codex
is reported with a retry rather than replaced by a fixture.

`createPhase7CleanRoomSessionInput` seeds the minimum a runner session
needs and fails closed if anything else is present, so "starts blank" is
enforced at the seam rather than assumed. The exposure profile is fixed
and inspectable while the calls stay conversation-driven; three grants
(`governance:approvals:decide`, `workspace:control`,
`test:generic_api_request`) stay withheld so a typed denial is reachable
in a chat nobody scripted.

`Reset` and `New chat` retire the previous session's authority, drop its
workspace, and mint new mock identities; the old session id answers 404
afterwards. The projection now publishes the real-API block as a
`network-guard-<sessionId>` control-plane record, so "no request reached
a real Paperclip API" is inspectable in the drawer on both surfaces.

Also fixes two things the clean room surfaced: the unanchored `sk-`
credential heuristic matched mock ids of the form `task-cleanroom-<token>`,
and a hash change left one committed frame reporting `settled` for the
surface just navigated away from.

The 24-image screenshot matrix is re-recorded: the surface rail carrying
the clean-room entry is a real, always-visible element, and it reproduces
byte-for-byte against Chromium 151.0.7922.34.

Verified: test:phase7 159/159; test:browser:issue-thread 58/58 with axe
clean; check:phase7:ui 24/24 byte-identical; docs:validate 93 files, 34
OKF concepts; smoke:phase7:cleanroom all 15 assertions true against real
Codex 0.132.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5 committed 2026-08-10 15:45:35 +00:00
1 parent 789e74fceb
commit 3e875c6f9b
65 files changed
+2790 -136

No files matched your search

@@ -402,3 +402,267 @@ test.describe("Phase 7G issue thread", () => {
});
}
});
/* ------------------------------------------------------- Phase 7M clean room */
/**
* The clean room is live-only by construction, so these tests stub the package
* session route rather than starting a real Codex process: what is under test
* here is the surface — entry, blank state, evidence-on-demand, identity
* rotation, failure honesty, and the narrow layout — not the tool loop, which
* `smoke:phase7:cleanroom` and the server suite cover against the real thing.
*/
const CLEAN_ROOM_API = "**/api/phase7/ui/cleanroom/session*";
function cleanRoomView(identifier: string, withTurn: boolean) {
const guard = {
id: `network-guard-session-${identifier}`,
turnId: "turn-0",
category: "session",
outcome: "no_real_paperclip_request",
reason: "Real Paperclip API requests: 0. Child PAPERCLIP_* environment keys: none.",
stateRevision: 3,
threadAnchorId: null,
};
return {
schema: "paperclip.phase7.issue-thread-view.v1",
sessionId: `session-${identifier}`,
mode: "live",
identity: {
agentLabel: "Real Codex",
runnerLabel: "Real runnerd",
runnerAttached: true,
controlPlaneLabel: "Mock Paperclip",
controlPlaneTooltip: "All issue records are mock. No real Paperclip API is reachable.",
replaySource: null,
},
issue: {
identifier,
title: "Clean-room chat",
status: "in_progress",
priority: "medium",
assignee: "Mock Agent",
runState: `run-${identifier} · idle`,
scenarioId: "phase7-clean-room",
fixtureProfile: "clean-room",
},
turns: withTurn
? [
{
id: "turn-1",
ordinal: 1,
mode: "live",
toolCallCount: 1,
at: "2026-08-10T12:00:00.000Z",
stoppedByUser: false,
items: [
{
kind: "user_message",
id: "transcript-1",
at: "2026-08-10T12:00:00.000Z",
author: "You (board user)",
body: "Read this issue and record a status.",
},
{
kind: "tool_activity",
id: "item-call-call-1",
at: "2026-08-10T12:00:01.000Z",
status: "ok",
operationId: "report_progress",
summary: "state revision 3",
input: { body: "first status" },
result: { ok: true, stateRevision: 3 },
evidenceRef: { section: "calls", recordId: "call-1" },
},
{
kind: "agent_message",
id: "transcript-2",
at: "2026-08-10T12:00:02.000Z",
author: "Real Codex",
body: "Recorded a first status on the mock issue.",
streaming: false,
},
],
},
]
: [],
composer: { state: "ready", helper: null, reason: null, pendingInteractionId: null },
evidence: {
tools: [],
calls: [],
authorization: [],
control_plane: [guard],
runner: [],
state: [],
traceability: [],
parity: [],
},
connection: { state: "connected", attempt: 0 },
replay: null,
renderedAt: "2026-08-10T12:00:02.000Z",
};
}
function cleanRoomPayload(identifier: string, withTurn = false) {
return {
sessionId: `session-${identifier}`,
surface: "cleanroom",
identity: {
token: identifier.toLowerCase().replace("mck-", "tok"),
sequence: Number(identifier.slice(4)),
companyId: `company-cleanroom-${identifier}`,
actorId: `actor-cleanroom-${identifier}`,
taskId: `task-cleanroom-${identifier}`,
identifier,
},
limits: { maxTurns: 24, maxMessageBytes: 8192 },
view: cleanRoomView(identifier, withTurn),
};
}
async function stubCleanRoom(page: Page, identifiers: string[]) {
let opened = 0;
await page.route(CLEAN_ROOM_API, async (route) => {
const identifier = identifiers[Math.min(opened, identifiers.length - 1)] ?? "MCK-1000";
opened += 1;
await route.fulfill({
status: route.request().method() === "POST" ? 201 : 200,
contentType: "application/json",
body: JSON.stringify(cleanRoomPayload(identifier)),
});
});
}
async function openCleanRoom(page: Page, identifiers: string[] = ["MCK-1000"]) {
await stubCleanRoom(page, identifiers);
await page.addInitScript(() => window.localStorage.clear());
await page.goto("/#/chat");
await expect(page.locator('[data-thread-state="settled"]')).toBeVisible();
}
test.describe("Phase 7M clean-room chat", () => {
test("the landing surface offers a clean-room entry that needs no scenario", async ({ page }) => {
await open(page, "thread-baseline");
const entry = page.getByTestId("surface-chat-link");
await expect(entry).toBeVisible();
await expect(entry).toHaveAttribute("href", "#/chat");
await expect(entry).toContainText("New chat");
});
test("a new chat opens a blank live thread with no scenario controls", async ({ page }) => {
await openCleanRoom(page);
await expect(page.locator('[data-surface="chat"]')).toBeVisible();
await expect(page.getByTestId("clean-room-empty")).toBeVisible();
await expect(page.locator("[data-turn-id]")).toHaveCount(0);
await expect(page.getByTestId("scenario-picker")).toHaveCount(0);
await expect(page.getByTestId("replay-button")).toHaveCount(0);
await expect(page.getByTestId("replay-strip")).toHaveCount(0);
const chips = page.getByTestId("identity-chips");
await expect(chips.getByTestId("agent-chip")).toHaveText("Real Codex");
await expect(chips.getByTestId("runner-chip")).toContainText("Real runnerd");
await expect(chips.getByTestId("control-plane-chip")).toHaveText("Mock Paperclip");
await expect(page.locator('[data-composer-state="ready"]')).toHaveCount(1);
});
test("evidence stays collapsed until it is asked for", async ({ page }) => {
await openCleanRoom(page);
await expect(page.locator(".pit-panel")).toHaveCount(0);
await expect(page.getByTestId("evidence-toggle")).toHaveAttribute("aria-expanded", "false");
await page.getByTestId("evidence-toggle").click();
await expect(page.locator(".pit-panel")).toBeVisible();
await page.getByRole("button", { name: /Control plane/ }).click();
await expect(page.getByText("Real Paperclip API requests: 0")).toBeVisible();
});
test("a sent message renders the live turn it produced", async ({ page }) => {
await openCleanRoom(page);
await page.route("**/api/phase7/ui/message", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
sessionId: "session-MCK-1000",
surface: "cleanroom",
identity: cleanRoomPayload("MCK-1000").identity,
limits: { maxTurns: 24, maxMessageBytes: 8192 },
view: cleanRoomView("MCK-1000", true),
}),
});
});
await page.locator("#composer-input").fill("Read this issue and record a status.");
await page.getByTestId("composer-send").click();
await expect(page.getByTestId("clean-room-empty")).toHaveCount(0);
await expect(page.locator('[data-thread-item="user_message"]')).toBeVisible();
await expect(page.locator('[data-thread-item="agent_message"]')).toBeVisible();
await expect(page.locator('[data-tool-strip="report_progress"]')).toBeVisible();
});
test("New chat visibly rotates the mock identity", async ({ page }) => {
await openCleanRoom(page, ["MCK-1000", "MCK-2000"]);
await expect(page.locator(".pit-identifier")).toHaveText("MCK-1000");
await page.getByTestId("new-chat-button").click();
await expect(page.locator(".pit-identifier")).toHaveText("MCK-2000");
await expect(page.getByTestId("clean-room-empty")).toBeVisible();
});
test("a failed live start reports the failure instead of a fixture", async ({ page }) => {
await page.addInitScript(() => window.localStorage.clear());
await page.route(CLEAN_ROOM_API, async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
error: "phase7_issue_thread_unavailable",
message: "paperclip-runnerd exited before the Codex app-server was ready",
}),
});
});
await page.goto("/#/chat");
const error = page.getByTestId("surface-error");
await expect(error).toBeVisible();
await expect(error).toContainText("could not start a real Codex session");
await expect(page.getByTestId("surface-error-retry")).toBeVisible();
await expect(page.locator("[data-turn-id]")).toHaveCount(0);
});
test("the narrow layout keeps thread and composer usable without horizontal scroll", async ({
page,
}) => {
await page.setViewportSize(MOBILE);
await openCleanRoom(page);
await expect(page.getByTestId("clean-room-empty")).toBeVisible();
await expect(page.locator("#composer-input")).toBeVisible();
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
await page.getByTestId("segment-evidence").click();
await expect(page.locator(".pit-panel")).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
),
).toBeLessThanOrEqual(0);
});
test("axe reports no serious or critical violation on the clean room", async ({ page }) => {
await page.setViewportSize(DESKTOP);
await openCleanRoom(page);
expect(await seriousAxeViolations(page)).toEqual([]);
await page.setViewportSize(MOBILE);
await expect(page.getByTestId("clean-room-empty")).toBeVisible();
expect(await seriousAxeViolations(page)).toEqual([]);
});
});
@@ -14,11 +14,24 @@ import { EvidencePanel } from "./EvidencePanel";
import { IssueHeader } from "./IssueHeader";
import { applyFakeInteractionResponse } from "./fake-store";
import type { Phase7InteractionResponse } from "./InteractionCard";
import { phase7LiveClient, recallSession, rememberSession } from "./live-client";
import {
phase7LiveClient,
recallSession,
rememberSession,
type Phase7CleanRoomIdentity,
} from "./live-client";
import { parsePhase7Route, phase7RouteHref, type Phase7Route } from "./route";
import { SurfaceNav } from "./SurfaceNav";
import { TurnGroup } from "./ThreadItems";
const PANEL_OPEN_KEY = "paperclip-runner.phase7.panel.open";
/**
* The clean room keeps its own panel preference. Evidence is collapsed by
* default there on purpose (revision 5: "the default view reads as plain
* chat"), and inheriting an explorer session's opened drawer would quietly
* break that on the very first visit.
*/
const CHAT_PANEL_OPEN_KEY = "paperclip-runner.phase7.chat.panel.open";
const PANEL_WIDTH_KEY = "paperclip-runner.phase7.panel.width";
const PANEL_MIN = 320;
const PANEL_MAX = 640;
@@ -59,6 +72,10 @@ function scrollEvidenceIntoView(target: Element, block: "start" | "center"): voi
panel.scrollTop = Math.max(0, panel.scrollTop - head.offsetHeight);
}
function describe(cause: unknown): string {
return String(cause instanceof Error ? cause.message : cause);
}
function useRoute(): Phase7Route {
const [route, setRoute] = useState(() => parsePhase7Route(window.location));
useEffect(() => {
@@ -88,11 +105,24 @@ function useLayout(): "side" | "overlay" | "segment" {
export function App() {
const route = useRoute();
const chat = route.surface === "chat";
const layout = useLayout();
const [snapshot, setSnapshot] = useState<Phase7IssueThreadSnapshot | null>(null);
const [identity, setIdentity] = useState<Phase7CleanRoomIdentity | null>(null);
/**
* Which surface produced `snapshot`. A hash change commits the new route in
* the same frame that still renders the old snapshot, so without this a
* capture or a test can catch one frame of "settled" that belongs to the
* surface it just navigated away from.
*/
const [snapshotSurface, setSnapshotSurface] = useState(route.surface);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [loadNonce, setLoadNonce] = useState(0);
const [settled, setSettled] = useState(false);
const [panelOpen, setPanelOpen] = useState(() => readStoredFlag(PANEL_OPEN_KEY, false));
const [panelOpen, setPanelOpen] = useState(() =>
readStoredFlag(route.surface === "chat" ? CHAT_PANEL_OPEN_KEY : PANEL_OPEN_KEY, false),
);
const [panelWidth, setPanelWidth] = useState(() => readStoredNumber(PANEL_WIDTH_KEY, 384));
const [segment, setSegment] = useState<"thread" | "evidence">(route.segment);
const [openSections, setOpenSections] = useState<Phase7EvidenceSectionId[]>(["tools"]);
@@ -112,24 +142,43 @@ export function App() {
useEffect(() => {
let cancelled = false;
setSettled(false);
if (route.mode === "live") {
setError(null);
if (chat) {
// The clean room has no fixture fallback: if real Codex and real runnerd
// cannot start, the surface says so rather than rendering a canned thread.
void (async () => {
try {
const response = await phase7LiveClient.loadCleanRoom(recallSession("cleanroom"));
if (cancelled) return;
rememberSession(response.sessionId, "cleanroom");
setIdentity(response.identity ?? null);
setSnapshot(response.view);
setSnapshotSurface("chat");
} catch (cause) {
if (!cancelled) setError(String(cause instanceof Error ? cause.message : cause));
}
})();
} else if (route.mode === "live") {
void (async () => {
try {
const response = await phase7LiveClient.load(recallSession());
if (cancelled) return;
rememberSession(response.sessionId);
setSnapshot(response.view);
setSnapshotSurface("issue");
} catch (cause) {
if (!cancelled) setError(String(cause instanceof Error ? cause.message : cause));
}
})();
} else {
setIdentity(null);
setSnapshot(phase7IssueThreadFixture(route.shot ?? "thread-baseline", route.fixtureProfile));
setSnapshotSurface("issue");
}
return () => {
cancelled = true;
};
}, [route.mode, route.shot, route.fixtureProfile]);
}, [chat, route.mode, route.shot, route.fixtureProfile, loadNonce]);
/* ------------------------------------------------------- deep-link params */
@@ -193,14 +242,17 @@ export function App() {
/* -------------------------------------------------------------- handlers */
const persistPanel = useCallback((open: boolean, width: number) => {
try {
window.localStorage.setItem(PANEL_OPEN_KEY, String(open));
window.localStorage.setItem(PANEL_WIDTH_KEY, String(width));
} catch {
// Panel preference is a convenience, never a correctness requirement.
}
}, []);
const persistPanel = useCallback(
(open: boolean, width: number) => {
try {
window.localStorage.setItem(chat ? CHAT_PANEL_OPEN_KEY : PANEL_OPEN_KEY, String(open));
window.localStorage.setItem(PANEL_WIDTH_KEY, String(width));
} catch {
// Panel preference is a convenience, never a correctness requirement.
}
},
[chat],
);
const openEvidence = useCallback(
(section: Phase7EvidenceSectionId, recordId: string) => {
@@ -251,7 +303,7 @@ export function App() {
void phase7LiveClient
.respond(current.sessionId, response.interactionId, response.outcome, response.result)
.then((next) => setSnapshot(next.view))
.catch((cause) => setError(String(cause)));
.catch((cause) => setActionError(describe(cause)));
return {
...current,
turns: current.turns.map((turn) => ({
@@ -273,13 +325,22 @@ export function App() {
const send = useCallback(
(message: string) => {
setActionError(null);
setSnapshot((current) => {
if (current === null) return current;
if (route.mode === "live") {
void phase7LiveClient
.send(current.sessionId, message)
.then((next) => setSnapshot(next.view))
.catch((cause) => setError(String(cause)));
.catch((cause) => {
setActionError(describe(cause));
// A refused turn must not strand the composer in `sending`.
setSnapshot((stale) =>
stale === null
? stale
: { ...stale, composer: { ...stale.composer, state: "ready" } },
);
});
return { ...current, composer: { ...current.composer, state: "sending" } };
}
return current;
@@ -295,7 +356,7 @@ export function App() {
void phase7LiveClient
.stop(current.sessionId)
.then((next) => setSnapshot(next.view))
.catch((cause) => setError(String(cause)));
.catch((cause) => setActionError(describe(cause)));
return current;
}
const turns = current.turns.map((turn, index) =>
@@ -310,8 +371,39 @@ export function App() {
setAnnouncement("Turn stopped. Partial output is preserved.");
}, [route.mode]);
/**
* Adopts a rotated clean room. Reset and `New chat` both land here: the
* server always answers with a new session id and new mock identities, so the
* client's job is only to forget the old ones.
*/
const adoptCleanRoom = useCallback((next: Awaited<ReturnType<typeof phase7LiveClient.newCleanRoom>>) => {
rememberSession(next.sessionId, "cleanroom");
setIdentity(next.identity ?? null);
setSnapshot(next.view);
setActionError(null);
setAnnouncement(
`New clean-room chat started on ${next.view.issue.identifier}. The previous session was closed.`,
);
}, []);
const newChat = useCallback(() => {
setConfirmReset(false);
setAnnouncement("Starting a new clean-room chat…");
void phase7LiveClient
.newCleanRoom(snapshot?.sessionId ?? null)
.then(adoptCleanRoom)
.catch((cause) => setActionError(describe(cause)));
}, [adoptCleanRoom, snapshot]);
const reset = useCallback(() => {
setConfirmReset(false);
if (chat && snapshot !== null) {
void phase7LiveClient
.reset(snapshot.sessionId)
.then(adoptCleanRoom)
.catch((cause) => setActionError(describe(cause)));
return;
}
if (route.mode === "live" && snapshot !== null) {
void phase7LiveClient
.reset(snapshot.sessionId)
@@ -319,19 +411,19 @@ export function App() {
rememberSession(next.sessionId);
setSnapshot(next.view);
})
.catch((cause) => setError(String(cause)));
.catch((cause) => setActionError(describe(cause)));
return;
}
setSnapshot(phase7IssueThreadFixture("thread-baseline", route.fixtureProfile));
setAnnouncement("Scenario reset. The mock state is back to its clean seed.");
}, [route.fixtureProfile, route.mode, snapshot]);
}, [adoptCleanRoom, chat, route.fixtureProfile, route.mode, snapshot]);
const retry = useCallback(() => {
if (route.mode === "live" && snapshot !== null) {
void phase7LiveClient
.reconnect(snapshot.sessionId)
.then((next) => setSnapshot(next.view))
.catch((cause) => setError(String(cause)));
.catch((cause) => setActionError(describe(cause)));
return;
}
setSnapshot((current) =>
@@ -402,16 +494,47 @@ export function App() {
if (error !== null) {
return (
<main className="pit-app">
<p className="pit-composer-reason" role="alert">
{error}
</p>
</main>
<div className="pit-app" data-thread-state="failed" data-surface={route.surface}>
<SurfaceNav surface={route.surface} />
<main className="pit-app-error">
<p className="pit-composer-reason" role="alert" data-testid="surface-error">
{chat
? `The clean-room chat could not start a real Codex session: ${error}`
: error}
</p>
{chat ? (
<p className="pit-muted">
The clean room only runs against real Codex through real runnerd, so it does not fall
back to a fixture or a recording.
</p>
) : null}
<button
type="button"
className="pit-button"
data-variant="primary"
data-testid="surface-error-retry"
onClick={() => setLoadNonce((current) => current + 1)}
>
Try again
</button>
</main>
</div>
);
}
if (snapshot === null) {
return <main className="pit-app" data-thread-state="loading" />;
return (
<div className="pit-app" data-thread-state="loading" data-surface={route.surface}>
<SurfaceNav surface={route.surface} />
<main className="pit-app-error">
<p className="pit-muted" role="status" data-testid="surface-loading">
{chat
? "Starting real runnerd and a real Codex session for a fresh mock tenant…"
: "Loading…"}
</p>
</main>
</div>
);
}
const showThread = layout !== "segment" || segment === "thread";
@@ -420,13 +543,19 @@ export function App() {
return (
<div
className="pit-app"
data-thread-state={settled ? "settled" : "loading"}
data-thread-state={settled && snapshotSurface === route.surface ? "settled" : "loading"}
data-session-mode={snapshot.mode}
data-surface={route.surface}
data-connection-state={snapshot.connection.state}
>
<SurfaceNav surface={route.surface} />
<IssueHeader
snapshot={snapshot}
scenarios={SCENARIOS}
surface={route.surface}
cleanRoomToken={identity?.token ?? null}
onNewChat={newChat}
evidenceOpen={panelOpen}
denialCount={denialCount}
segment={segment}
@@ -451,6 +580,20 @@ export function App() {
onSelectSegment={setSegment}
/>
{actionError !== null ? (
<p className="pit-banner" data-tone="danger" role="alert" data-testid="action-error">
<span aria-hidden="true">⚠</span>
{actionError}
<button
type="button"
className="pit-link-button"
onClick={() => setActionError(null)}
>
Dismiss
</button>
</p>
) : null}
{snapshot.connection.state === "reconnecting" ? (
<p className="pit-banner" role="status" data-testid="reconnect-banner">
<span aria-hidden="true">⏳</span>
@@ -528,17 +671,36 @@ export function App() {
}}
>
<div className="pit-thread">
{snapshot.turns.map((turn) => (
<TurnGroup
key={turn.id}
turn={turn}
callbacks={{
onOpenEvidence: openEvidence,
onRespond: respond,
focusInteractionId,
}}
/>
))}
{snapshot.turns.length === 0 ? (
<section className="pit-empty-thread" data-testid="clean-room-empty">
<h2>Start a clean-room chat</h2>
<p>
This is a blank thread on a brand-new mock tenant:{" "}
<strong>{snapshot.issue.identifier}</strong> in{" "}
<strong>Mock Paperclip (clean room)</strong>. Nothing has been said, called, or
recorded yet.
</p>
<p className="pit-muted">
Your first message starts a real Codex turn through real runnerd. Codex may
call the semantic tools this session exposes, and every record it creates lands
in the mock control plane only — never a real Paperclip API. Detailed tool,
policy, event, and state evidence stays in the Evidence drawer until you open
it.
</p>
</section>
) : (
snapshot.turns.map((turn) => (
<TurnGroup
key={turn.id}
turn={turn}
callbacks={{
onOpenEvidence: openEvidence,
onRespond: respond,
focusInteractionId,
}}
/>
))
)}
</div>
</div>
{showJump ? (
@@ -562,7 +724,8 @@ export function App() {
onSend={send}
onStop={stop}
onRetry={retry}
onReset={() => setConfirmReset(true)}
onReset={() => (chat ? newChat() : setConfirmReset(true))}
resetLabel={chat ? "New chat" : "Reset scenario"}
onFocusPending={(interactionId) => {
setFocusInteractionId(interactionId);
document
@@ -635,10 +798,12 @@ export function App() {
data-testid="reset-dialog"
>
<h2 className="pit-dialog-title" id="reset-dialog-title">
Reset scenario?
{chat ? "Reset this chat?" : "Reset scenario?"}
</h2>
<p>
This clears the mock state and starts a clean session. The transcript will be lost.
{chat
? "This stops any active turn, closes this session's authority, and opens a new mock tenant with new identities. The transcript and its mock records will be lost."
: "This clears the mock state and starts a clean session. The transcript will be lost."}
</p>
<div className="pit-button-row">
<button
@@ -654,8 +819,9 @@ export function App() {
className="pit-button"
data-variant="destructive"
onClick={reset}
data-testid="reset-confirm"
>
Reset scenario
{chat ? "Reset chat" : "Reset scenario"}
</button>
</div>
</div>
@@ -16,6 +16,8 @@ export interface ComposerProps {
onRetry: () => void;
onReset: () => void;
onFocusPending: (interactionId: string) => void;
/** The clean room resets into a new tenant, so it names the action itself. */
resetLabel?: string;
}
function draftKey(sessionId: string): string {
@@ -31,7 +33,16 @@ function readDraft(sessionId: string): string {
}
export function Composer(props: ComposerProps) {
const { model, sessionId, onSend, onStop, onRetry, onReset, onFocusPending } = props;
const {
model,
sessionId,
onSend,
onStop,
onRetry,
onReset,
onFocusPending,
resetLabel = "Reset scenario",
} = props;
const [value, setValue] = useState(() => readDraft(sessionId));
const inputRef = useRef<HTMLTextAreaElement | null>(null);
@@ -130,7 +141,7 @@ export function Composer(props: ComposerProps) {
</button>
) : model.state === "disabled" ? (
<button type="button" className="pit-button" onClick={onReset}>
Reset scenario
{resetLabel}
</button>
) : null}
@@ -12,6 +12,11 @@ import { Chip, PriorityIcon, StatusBadge } from "./primitives";
export interface IssueHeaderProps {
snapshot: Phase7IssueThreadSnapshot;
scenarios: string[];
/** `chat` is the clean room: no preset scenario, no recording to replay. */
surface?: "issue" | "chat";
/** Clean-room tenant token, rendered so identity rotation is visible. */
cleanRoomToken?: string | null;
onNewChat?: () => void;
evidenceOpen: boolean;
denialCount: number;
segment: "thread" | "evidence";
@@ -27,6 +32,9 @@ export function IssueHeader(props: IssueHeaderProps) {
const {
snapshot,
scenarios,
surface = "issue",
cleanRoomToken = null,
onNewChat,
evidenceOpen,
denialCount,
segment,
@@ -37,6 +45,7 @@ export function IssueHeader(props: IssueHeaderProps) {
onStop,
onSelectSegment,
} = props;
const chat = surface === "chat";
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const turnActive = snapshot.composer.state === "streaming" || snapshot.composer.state === "sending";
@@ -83,36 +92,54 @@ export function IssueHeader(props: IssueHeaderProps) {
<Chip tone="mock" title={snapshot.identity.controlPlaneTooltip} testId="control-plane-chip">
{snapshot.identity.controlPlaneLabel}
</Chip>
{chat && cleanRoomToken !== null ? (
<Chip tone="accent" testId="clean-room-identity">
Clean room {cleanRoomToken}
</Chip>
) : null}
</div>
<div className="pit-header-controls">
<span className="pit-run-state">{snapshot.issue.runState}</span>
<label className="pit-visually-hidden" htmlFor="scenario-picker">
Scenario
</label>
<select
className="pit-select pit-desktop-only"
id="scenario-picker"
value={snapshot.issue.fixtureProfile}
onChange={(event) => onSelectScenario(event.target.value)}
data-testid="scenario-picker"
>
{scenarios.map((scenario) => (
<option key={scenario} value={scenario}>
{scenario}
</option>
))}
</select>
{chat ? (
<button
type="button"
className="pit-button"
onClick={onNewChat}
data-testid="new-chat-button"
>
New chat
</button>
) : (
<>
<label className="pit-visually-hidden" htmlFor="scenario-picker">
Scenario
</label>
<select
className="pit-select pit-desktop-only"
id="scenario-picker"
value={snapshot.issue.fixtureProfile}
onChange={(event) => onSelectScenario(event.target.value)}
data-testid="scenario-picker"
>
{scenarios.map((scenario) => (
<option key={scenario} value={scenario}>
{scenario}
</option>
))}
</select>
<button
type="button"
className="pit-button pit-desktop-only"
onClick={onReplay}
data-testid="replay-button"
>
Replay
</button>
<button
type="button"
className="pit-button pit-desktop-only"
onClick={onReplay}
data-testid="replay-button"
>
Replay
</button>
</>
)}
<button
type="button"
className="pit-button pit-desktop-only"
@@ -147,35 +174,39 @@ export function IssueHeader(props: IssueHeaderProps) {
</button>
{menuOpen ? (
<div className="pit-menu-list" role="menu" data-testid="overflow-menu">
<label className="pit-menu-item" htmlFor="scenario-picker-mobile">
Scenario
<select
className="pit-select"
id="scenario-picker-mobile"
value={snapshot.issue.fixtureProfile}
onChange={(event) => {
setMenuOpen(false);
onSelectScenario(event.target.value);
}}
>
{scenarios.map((scenario) => (
<option key={scenario} value={scenario}>
{scenario}
</option>
))}
</select>
</label>
<button
type="button"
role="menuitem"
className="pit-menu-item"
onClick={() => {
setMenuOpen(false);
onReplay();
}}
>
Replay
</button>
{chat ? null : (
<>
<label className="pit-menu-item" htmlFor="scenario-picker-mobile">
Scenario
<select
className="pit-select"
id="scenario-picker-mobile"
value={snapshot.issue.fixtureProfile}
onChange={(event) => {
setMenuOpen(false);
onSelectScenario(event.target.value);
}}
>
{scenarios.map((scenario) => (
<option key={scenario} value={scenario}>
{scenario}
</option>
))}
</select>
</label>
<button
type="button"
role="menuitem"
className="pit-menu-item"
onClick={() => {
setMenuOpen(false);
onReplay();
}}
>
Replay
</button>
</>
)}
<button
type="button"
role="menuitem"
@@ -185,7 +216,7 @@ export function IssueHeader(props: IssueHeaderProps) {
onReset();
}}
>
Reset scenario
{chat ? "Reset chat" : "Reset scenario"}
</button>
</div>
) : null}
@@ -0,0 +1,36 @@
import type { Phase7Surface } from "./route";
/**
* Top-level surface switch (Phase 7 plan revision 5).
*
* Phase 7 now has two primary paths, not one path with a mode toggle: the
* preset scenario explorer and a clean-room chat that starts from nothing. They
* are different jobs, so they get links rather than tabs — the clean-room entry
* has to be reachable from the landing page without first choosing a scenario.
*
* These are real anchors on purpose. The entry must survive a hard refresh, be
* copyable, and open in a new tab, which a button handler would not give.
*/
export function SurfaceNav({ surface }: { surface: Phase7Surface }) {
return (
<nav className="pit-surface-nav" aria-label="Phase 7 surfaces" data-testid="surface-nav">
<a
className="pit-surface-link"
href="#/issue/hb-baseline"
aria-current={surface === "issue" ? "page" : undefined}
data-testid="surface-issue-link"
>
Scenario explorer
</a>
<a
className="pit-surface-link"
href="#/chat"
aria-current={surface === "chat" ? "page" : undefined}
data-testid="surface-chat-link"
>
New chat
<span className="pit-surface-note">· clean room</span>
</a>
</nav>
);
}
@@ -136,6 +136,96 @@ textarea {
border: 0;
}
/* ---------------------------------------------------------- surface nav */
/*
* Two primary paths, one rail. The links wrap rather than scroll horizontally,
* because the narrow layout must never introduce a horizontal scrollbar (§10).
*/
.pit-surface-nav {
flex: none;
display: flex;
flex-wrap: wrap;
gap: var(--pit-space-2);
padding: var(--pit-space-2) var(--pit-space-4);
background: var(--pit-surface-sunken);
border-bottom: 1px solid var(--pit-border);
}
.pit-surface-link {
display: inline-flex;
align-items: center;
/* The label and its qualifier are separate flex items, so the space between
them has to be a gap: a leading space inside the qualifier collapses. */
gap: var(--pit-space-1);
min-height: var(--pit-touch-target);
padding: 0 var(--pit-space-3);
border: 1px solid var(--pit-border);
border-radius: var(--pit-radius-md);
color: var(--pit-muted-foreground);
font-size: var(--pit-text-xs);
text-decoration: none;
}
.pit-surface-link:hover {
color: var(--pit-foreground);
border-color: var(--pit-border-strong);
}
.pit-surface-link[aria-current="page"] {
background: var(--pit-accent-surface);
border-color: var(--pit-accent);
color: var(--pit-foreground);
}
.pit-surface-note {
color: var(--pit-faint-foreground);
}
.pit-surface-link[aria-current="page"] .pit-surface-note {
color: var(--pit-muted-foreground);
}
/* A load failure and the pre-session wait share the centred single-column. */
.pit-app-error {
display: flex;
flex: 1;
flex-direction: column;
align-items: flex-start;
gap: var(--pit-space-3);
max-width: var(--pit-thread-measure);
padding: var(--pit-space-6) var(--pit-space-4);
}
.pit-muted {
margin: 0;
color: var(--pit-muted-foreground);
}
/*
* The clean-room blank state. It carries the whole explanation of what the
* surface is, because the thread it replaces is deliberately empty.
*/
.pit-empty-thread {
display: flex;
flex-direction: column;
gap: var(--pit-space-3);
padding: var(--pit-space-5);
background: var(--pit-surface);
border: 1px dashed var(--pit-border-strong);
border-radius: var(--pit-radius-lg);
}
.pit-empty-thread h2 {
margin: 0;
font-size: var(--pit-text-base);
line-height: var(--pit-leading-tight);
}
.pit-empty-thread p {
margin: 0;
}
/* ---------------------------------------------------------------- header */
.pit-header {
@@ -779,6 +869,11 @@ textarea {
border-bottom-color: var(--pit-success);
}
.pit-banner[data-tone="danger"] {
background: var(--pit-danger-surface);
border-bottom-color: var(--pit-danger);
}
@keyframes pit-slide {
from {
transform: translateY(-0.5rem);
@@ -11,9 +11,51 @@ import type { Phase7IssueThreadSnapshot } from "../../../src/issue-thread/types"
const BASE = "/api/phase7/ui";
export interface Phase7CleanRoomIdentity {
token: string;
sequence: number;
companyId: string;
actorId: string;
taskId: string;
identifier: string;
}
export interface Phase7LiveResponse {
sessionId: string;
view: Phase7IssueThreadSnapshot;
surface?: "issue" | "cleanroom";
/** Present on the clean-room surface so the UI can show which tenant is live. */
identity?: Phase7CleanRoomIdentity;
limits?: { maxTurns: number; maxMessageBytes: number };
turns?: number;
}
/**
* The server names its own failures. Surfacing the code lets the clean room say
* "this chat hit its turn limit" instead of "500", and — more importantly —
* lets a failure to start real Codex read as a failure rather than quietly
* degrading into a fixture.
*/
export class Phase7LiveError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
this.name = "Phase7LiveError";
}
}
async function readError(response: Response, path: string): Promise<Phase7LiveError> {
try {
const body = (await response.json()) as { error?: string; message?: string };
return new Phase7LiveError(
body.error ?? `http_${response.status}`,
body.message ?? `${path} failed with ${response.status}`,
);
} catch {
return new Phase7LiveError(`http_${response.status}`, `${path} failed with ${response.status}`);
}
}
async function post(path: string, body: unknown): Promise<Phase7LiveResponse> {
@@ -22,9 +64,7 @@ async function post(path: string, body: unknown): Promise<Phase7LiveResponse> {
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${path} failed with ${response.status}`);
}
if (!response.ok) throw await readError(response, path);
return (await response.json()) as Phase7LiveResponse;
}
@@ -32,12 +72,23 @@ export const phase7LiveClient = {
async load(sessionId: string | null): Promise<Phase7LiveResponse> {
const query = sessionId === null ? "" : `?sessionId=${encodeURIComponent(sessionId)}`;
const response = await fetch(`${BASE}/session${query}`);
if (!response.ok) throw new Error(`session load failed with ${response.status}`);
if (!response.ok) throw await readError(response, "/session");
return (await response.json()) as Phase7LiveResponse;
},
create(scenario: string): Promise<Phase7LiveResponse> {
return post("/session", { scenario });
},
/** Reconnect to a clean room, or open one when there is nothing to resume. */
async loadCleanRoom(sessionId: string | null): Promise<Phase7LiveResponse> {
const query = sessionId === null ? "" : `?sessionId=${encodeURIComponent(sessionId)}`;
const response = await fetch(`${BASE}/cleanroom/session${query}`);
if (!response.ok) throw await readError(response, "/cleanroom/session");
return (await response.json()) as Phase7LiveResponse;
},
/** `New chat`: retire the current room and mint a new mock tenant. */
newCleanRoom(sessionId: string | null): Promise<Phase7LiveResponse> {
return post("/cleanroom/session", sessionId === null ? {} : { sessionId });
},
send(sessionId: string, message: string): Promise<Phase7LiveResponse> {
return post("/message", { sessionId, message });
},
@@ -61,18 +112,28 @@ export const phase7LiveClient = {
};
const SESSION_STORAGE_KEY = "paperclip-runner.phase7.session";
/**
* The clean room keeps its own key. Sharing one would let a scenario session id
* be handed to the clean-room route (and the reverse) after a refresh, which is
* exactly the cross-surface bleed the isolation criterion forbids.
*/
const CLEAN_ROOM_STORAGE_KEY = "paperclip-runner.phase7.cleanroom.session";
export function rememberSession(sessionId: string): void {
function storageKey(surface: "issue" | "cleanroom"): string {
return surface === "cleanroom" ? CLEAN_ROOM_STORAGE_KEY : SESSION_STORAGE_KEY;
}
export function rememberSession(sessionId: string, surface: "issue" | "cleanroom" = "issue"): void {
try {
window.localStorage.setItem(SESSION_STORAGE_KEY, sessionId);
window.localStorage.setItem(storageKey(surface), sessionId);
} catch {
// Refresh restore falls back to a fresh session when storage is blocked.
}
}
export function recallSession(): string | null {
export function recallSession(surface: "issue" | "cleanroom" = "issue"): string | null {
try {
return window.localStorage.getItem(SESSION_STORAGE_KEY);
return window.localStorage.getItem(storageKey(surface));
} catch {
return null;
}
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { parsePhase7Route, phase7RouteHref } from "./route";
function at(hash: string, search = "") {
return parsePhase7Route({ hash, search });
}
describe("Phase 7 issue-thread routes", () => {
it("keeps the existing deterministic scenario deep links intact", () => {
const route = at("#/issue/dp-documents?shot=document-revision&panel=state&rec=doc-1&at=3&seg=evidence");
expect(route.surface).toBe("issue");
expect(route.fixtureProfile).toBe("dp-documents");
expect(route.shot).toBe("document-revision");
expect(route.panel).toBe("state");
expect(route.record).toBe("doc-1");
expect(route.at).toBe(3);
expect(route.segment).toBe("evidence");
expect(route.mode).toBe("fake");
expect(phase7RouteHref(route, {})).toBe(
"#/issue/dp-documents?shot=document-revision&panel=state&rec=doc-1&at=3&seg=evidence",
);
});
it("still infers replay mode from the replay shot and honours an explicit mode", () => {
expect(at("#/issue/hb-baseline?shot=replay-mode").mode).toBe("replay");
expect(at("#/issue/hb-baseline?mode=live").mode).toBe("live");
});
});
describe("Phase 7 clean-room route", () => {
it("is reachable without naming a scenario", () => {
const route = at("#/chat");
expect(route.surface).toBe("chat");
expect(route.shot).toBe(null);
expect(route.at).toBe(null);
expect(phase7RouteHref(route, {})).toBe("#/chat");
});
it("is always live: no URL can downgrade it to a fixture or a recording", () => {
for (const hash of [
"#/chat",
"#/chat?mode=fake",
"#/chat?mode=replay",
"#/chat?shot=replay-mode",
"#/chat?shot=thread-baseline&at=4",
]) {
const route = at(hash);
expect(route.surface, hash).toBe("chat");
expect(route.mode, hash).toBe("live");
expect(route.shot, hash).toBe(null);
expect(route.at, hash).toBe(null);
}
expect(at("", "?mode=fake#/chat").surface).toBe("issue");
});
it("carries only the evidence deep-link parameters back into its own href", () => {
const route = at("#/chat?panel=calls&rec=call-1&seg=evidence");
expect(route.panel).toBe("calls");
expect(route.record).toBe("call-1");
expect(route.segment).toBe("evidence");
expect(phase7RouteHref(route, {})).toBe("#/chat?panel=calls&rec=call-1&seg=evidence");
expect(phase7RouteHref(route, { panel: null, record: null, segment: "thread" })).toBe("#/chat");
});
it("switches surfaces without dragging scenario state across", () => {
const chat = at("#/chat");
expect(phase7RouteHref(chat, { surface: "issue", fixtureProfile: "ar-artifacts" })).toBe(
"#/issue/ar-artifacts?mode=live",
);
});
});
@@ -1,17 +1,27 @@
/**
* Deterministic route scheme from the Phase 7B UX contract §10.1.
* Deterministic route scheme from the Phase 7B UX contract §10.1, plus the
* Phase 7M clean-room surface.
*
* `#/issue/<fixtureProfile>?shot=<slug>&panel=<section>&rec=<id>&at=<ordinal>&seg=thread|evidence`
* `#/chat?panel=<section>&rec=<id>&seg=thread|evidence`
*
* Parameters are also accepted on the document query string so a capture tool
* can address a state without depending on hash-fragment escaping.
*
* The clean-room surface has no fixture, shot, or replay ordinal by design: it
* starts blank and is always a live Codex session. `mode` is pinned to `live`
* there rather than parsed, so no URL can talk the clean room into answering
* with a fixture or a recording.
*/
import type { Phase7EvidenceSectionId } from "../../../src/issue-thread/types";
import { PHASE7_EVIDENCE_SECTIONS } from "../../../src/issue-thread/types";
import { PHASE7_DEFAULT_FIXTURE_PROFILE } from "../../../src/issue-thread/fixtures";
export type Phase7Surface = "issue" | "chat";
export interface Phase7Route {
surface: Phase7Surface;
fixtureProfile: string;
shot: string | null;
panel: Phase7EvidenceSectionId | null;
@@ -41,24 +51,27 @@ export function parsePhase7Route(url: {
}): Phase7Route {
const params = mergeParams(url.search, url.hash);
const path = url.hash.replace(/^#/, "").split("?")[0] ?? "";
const chat = /^\/chat\/?$/.test(path);
const match = /^\/issue\/([^/?]+)/.exec(path);
const shot = params.get("shot");
const shot = chat ? null : params.get("shot");
const requestedMode = params.get("mode");
const panel = params.get("panel");
const at = params.get("at");
const segment = params.get("seg");
const mode =
requestedMode === "live" || requestedMode === "replay" || requestedMode === "fake"
const mode = chat
? ("live" as const)
: requestedMode === "live" || requestedMode === "replay" || requestedMode === "fake"
? requestedMode
: shot === "replay-mode"
? "replay"
: "fake";
return {
surface: chat ? "chat" : "issue",
fixtureProfile: match?.[1] ?? PHASE7_DEFAULT_FIXTURE_PROFILE,
shot,
panel: panel !== null && SECTION_IDS.has(panel) ? (panel as Phase7EvidenceSectionId) : null,
record: params.get("rec"),
at: at !== null && /^\d+$/.test(at) ? Number.parseInt(at, 10) : null,
at: chat || at === null || !/^\d+$/.test(at) ? null : Number.parseInt(at, 10),
segment: segment === "evidence" ? "evidence" : "thread",
mode,
};
@@ -67,6 +80,13 @@ export function parsePhase7Route(url: {
export function phase7RouteHref(route: Phase7Route, overrides: Partial<Phase7Route>): string {
const next = { ...route, ...overrides };
const params = new URLSearchParams();
if (next.surface === "chat") {
if (next.panel !== null) params.set("panel", next.panel);
if (next.record !== null) params.set("rec", next.record);
if (next.segment !== "thread") params.set("seg", next.segment);
const chatQuery = params.toString();
return `#/chat${chatQuery.length > 0 ? `?${chatQuery}` : ""}`;
}
if (next.shot !== null) params.set("shot", next.shot);
if (next.mode !== "fake") params.set("mode", next.mode);
if (next.panel !== null) params.set("panel", next.panel);
@@ -264,7 +264,10 @@ suite and replay offline; the Codex (live) mode requires a locally authenticated
Codex. See [execution modes and identity](phase-07-execution-modes.md) for the
mode and eligibility rules, and [the live runnerd/Codex loop](phase-07-live-runnerd-codex.md)
for the session API. The package server blocks every request to a real
Paperclip API, and the evidence suite proves no such request occurred.
Paperclip API, and the evidence suite proves no such request occurred. The same
topology serves the [clean-room chat](phase-07-clean-room-chat.md): the only
difference is a mock tenant seeded with a company, an agent, and one blank issue
instead of a recorded eval case.
## Future integration rule
+2
View File
@@ -17,6 +17,7 @@ Each tutorial starts with an explanation of what the phase is and what the phase
- [Phase 7: the Paperclip-style issue thread over a mock control plane](tutorials/phase-07-issue-thread.md) — the canonical clean-start tutorial for the final build: prove the 106-case conformance suite, open the issue thread in deterministic fake mode, record the byte-stable screenshot matrix, and optionally drive a real Codex turn — all from a clean checkout with no Paperclip service.
- [Phase 7: explore the capability contract and scenario explorer](tutorials/phase-07-scenario-explorer.md) — companion tour of the read-only browser explorer over the mock control plane and the focused verification set.
- [Phase 7I: chat with the mock control plane](tutorials/phase-07i-scenario-chat.md) — the scenario-explorer chat surface: send prompts to a Phase 7 scenario and watch the mock Paperclip activity for every turn: exposure, typed calls, denials, control-plane-owned actions, state diffs, wakes, and per-turn parity.
- [Phase 7: chat with real Codex in a clean room](tutorials/phase-07-clean-room-chat.md) — the second primary path: open a blank chat on a freshly minted mock tenant, send a free-form message to real Codex through real runnerd, and inspect the tool, policy, and state evidence on demand.
- [Cumulative end-to-end tutorial](tutorials/end-to-end.md) — the shortest complete workflow available at the current phase.
## Reference
@@ -41,6 +42,7 @@ Each tutorial starts with an explanation of what the phase is and what the phase
- [Phase 7 live runnerd/Codex loop](phase-07-live-runnerd-codex.md)
- [Phase 7 execution modes and identity (fake vs real Codex)](phase-07-execution-modes.md)
- [Phase 7 Paperclip-style issue-thread UI](phase-07-issue-thread-ui.md)
- [Phase 7 clean-room live chat](phase-07-clean-room-chat.md)
- [Phase 7I interactive scenario chat](phase-07i-scenario-chat.md)
- [Phase 7 final evidence manifest (Revision 3/4)](../knowledge/evidence/2026-08-10-phase-07i-final-evidence.md)
- [Phase 7 future binding boundary (Phase 8 / ACPX)](phase-07-future-binding-boundary.md)
@@ -0,0 +1,123 @@
# Phase 7 clean-room live chat
Phase 7M adds a second primary path beside the preset scenario explorer. A board
user opens a blank chat, sends a free-form message, and watches real Codex work
a mock Paperclip issue through real runnerd. There is no scenario to pick, no
recorded transcript, and no scripted tool tour.
Both paths ship in the same app and share one view contract. What separates them
is what the session is seeded with, and whether a fixture is allowed to stand in
for a provider.
| | Preset scenario explorer | Clean-room chat |
| --- | --- | --- |
| Route | `#/issue/<fixtureProfile>` | `#/chat` |
| Seed | A recorded eval case: transcript, scripted calls, parity verdicts | A company, an agent, and one blank issue |
| Modes | `fake` (default), `replay`, `mode=live` | Live only — no fixture, no recording |
| Tool calls | Fixed by the scenario | Chosen by the conversation |
| Evidence drawer | Collapsed by default | Collapsed by default |
## What "clean room" means
Every open mints a new mock tenant — a new company, actor, task, and `MCK-`
identifier — and seeds nothing else. `assertPhase7CleanRoomSeedIsBlank` fails the
open if any comment, document, interaction, approval, artifact, blocker, wake,
or fault is present, so "the thread starts blank" is enforced at the seam that
creates the session rather than assumed.
The only mutation an open performs is the run checking the mock issue out, which
is why a fresh room reads `in_progress` rather than `todo`.
## Live only, and loudly so
The clean room never falls back:
- `parsePhase7Route` pins `mode` to `live` on `#/chat` and drops `shot` and
`at`. No URL can talk it into rendering a fixture or a recording.
- The package server has no scripted path for this route. It projects the live
session and refuses any view that is not `mode=live` with a `Real Codex`
agent label.
- When runnerd or the Codex app-server cannot start, the surface says exactly
that and offers `Try again`. It does not render a canned thread.
## Exposure profile
The profile is deterministic and inspectable even though the calls are not.
`PHASE7_CLEAN_ROOM_CLAIMS` grants the read, comment, document, interaction,
deliverable, delegation, dependency, wake, and approval-request operations.
Three grants stay withheld on purpose:
| Withheld grant | Effect |
| --- | --- |
| `governance:approvals:decide` | The agent may request an approval; the board decides it. |
| `workspace:control` | No service lifecycle changes from a chat. |
| `test:generic_api_request` | The escape hatch stays off. |
Withholding them keeps a denial reachable in a conversation nobody scripted: if
the model reaches for one, it gets a typed denial with a named code and the mock
state does not move. The Evidence drawer's Tools section lists all three under
`Control plane (not exposed to the agent)`.
The run itself holds the wider adapter claim set that the mock command boundary
requires (`phase7FixtureRunCapabilities`). That union widens the port, never the
catalog the model sees: effective claims are the intersection of run claims,
scenario claims, and explicitly delegated claims.
## Session lifecycle
```text
GET /api/phase7/ui/cleanroom/session[?sessionId=…] open or reconnect
POST /api/phase7/ui/cleanroom/session {sessionId?} New chat (retires the caller's room)
POST /api/phase7/ui/message {sessionId, message}
POST /api/phase7/ui/interrupt {sessionId}
POST /api/phase7/ui/reconnect {sessionId}
POST /api/phase7/ui/reset {sessionId}
POST /api/phase7/ui/interaction {sessionId, interactionId, outcome, result}
```
`Reset` and `New chat` both stop active work, clear the previous session's
authority, drop its workspace directory, and open a new tenant. A retired
session id answers `404` on every route afterwards — the rotation is verifiable,
not just visible. Refresh reconnects to the same durable session; a stale id
from `localStorage` opens a fresh room rather than dead-ending.
The clean room stores its session id under its own `localStorage` key, so a
scenario session can never be handed to the chat route or the reverse.
## Bounds
| Bound | Value |
| --- | --- |
| Turns per chat | 24, then a named `turn_limit` refusal that points at `New chat` |
| Message size | 8 KiB |
| Concurrent clean rooms | 4; the oldest yields to a new board user |
| Turn timeout | 120 s (`Phase7LiveSessionService` default) |
## Real-API block
`Phase7LiveSession` routes every Paperclip operation through the in-process mock
`ControlPlanePort`; no code path reaches a Paperclip URL. The projection turns
that into a record rather than a claim: the Control plane section of the
Evidence drawer carries a `network-guard-<sessionId>` row reading
`Real Paperclip API requests: 0. Child PAPERCLIP_* environment keys: none.`
The child environment is allowlisted by `createSanitizedCodexEnvironment`, so no
`PAPERCLIP_*` value reaches runnerd or Codex, and the browser receives no
provider, runner, or control-plane credential.
## Verification
| Surface | Command |
| --- | --- |
| Seed, exposure profile, and identity rotation | `pnpm --filter @paperclipai/paperclip-runner test:phase7` |
| Clean-room HTTP routes end to end (stub provider) | included in `test:phase7` |
| Browser entry, blank state, evidence-on-demand, narrow layout, axe | `pnpm --filter @paperclipai/paperclip-runner test:browser:issue-thread` |
| Real Codex through real runnerd | `pnpm --filter @paperclipai/paperclip-runner smoke:phase7:cleanroom` |
| Live screenshots | `pnpm --filter @paperclipai/paperclip-runner record:phase7:cleanroom` |
See the [clean-room chat tutorial](tutorials/phase-07-clean-room-chat.md) for the
clean-start walkthrough, [execution modes and identity](phase-07-execution-modes.md)
for the fake/live eligibility rules, and the
[issue-thread UI reference](phase-07-issue-thread-ui.md) for the thread,
composer, and Evidence panel this surface reuses.
@@ -65,6 +65,16 @@ the runner session, not on the agent:
Codex app-server and resumes the persisted provider thread; the session does
not restart because the browser reconnected.
## Two entry points, one live mode
Live mode is reachable from both primary surfaces:
- the preset scenario explorer, with `mode=live` on a chosen scenario;
- the [clean-room chat](phase-07-clean-room-chat.md) at `#/chat`, which has no
other mode. It starts blank on a freshly minted mock tenant, pins `mode=live`
in the route, and reports a failure to start real Codex rather than falling
back to a fixture or a recording.
## Eligibility summary
| Evidence | Eligible for |
@@ -5,6 +5,13 @@ reads the thread, answers typed interactions inline, and inspects the evidence
behind every mock mutation. The implementation follows the binding
[Phase 7B issue-thread UX contract](design/phase-7-issue-thread-ux-contract.md).
The same shell hosts a second primary path: the
[clean-room live chat](phase-07-clean-room-chat.md) at `#/chat`, which starts a
blank thread on a freshly minted mock tenant instead of a preset scenario. It
reuses every surface described below — header, thread, composer, and Evidence
panel — and differs only in what the session is seeded with and in refusing any
non-live mode.
## Authority boundary
```text
@@ -14,11 +14,23 @@ acceptance record are in
| Semantic tools + authorization/redaction | `exec vitest run src/tools/phase7-semantic-tools.test.ts` | 9 tests pass |
| 106-case conformance | `test:phase7-evals` | 1 file (all 106 cases) passes |
| Fake-agent matrix + bounded Codex + parity report | `report:phase7-evals` | `Phase 7 eval conformance passed: 106 cases across 16 groups.` |
| Scenario runtime + explorer + routes | `test:phase7` | 49 tests pass |
| Browser IA, accessibility, determinism, boundary | `test:browser:phase7` | 25 Playwright tests pass |
| Scenario runtime + explorer + clean room + routes | `test:phase7` | 159 tests pass |
| Browser IA, accessibility, determinism, boundary | `test:browser:phase7` | Playwright suites pass (58 in the issue-thread/clean-room suite) |
| Screenshot acceptance set | `record:phase7` | 24 deterministic images |
| Doc links + OKF bundle | `docs:validate` | 73 files, 28 OKF concepts, 5 indexes |
## Live commands (not offline)
The clean-room chat has no fake or replay path, so its end-to-end proof needs a
Rust toolchain and a locally authenticated Codex. These are the only Phase 7
commands that start a provider.
| Surface | Command | Expected result |
| --- | --- | --- |
| Real Codex through real runnerd on a fresh mock tenant | `smoke:phase7:cleanroom` | every assertion `true`; two `MCK-` identifiers, one per chat |
| Preset issue thread against real Codex | `smoke:phase7:ui` | every assertion `true` |
| Clean-room screenshots | `record:phase7:cleanroom` | 7 images; intentionally not byte-stable |
## Notes
- **No Rust toolchain is needed** for any Phase 7 command. The full
@@ -39,3 +51,4 @@ acceptance record are in
- [Clean-start tutorial](tutorials/phase-07-scenario-explorer.md)
- [Eval conformance](phase-07-eval-conformance.md)
- [Scenario explorer](phase-07-scenario-explorer.md)
- [Clean-room live chat](phase-07-clean-room-chat.md)
@@ -0,0 +1,147 @@
# Phase 7 Tutorial: Chat With Real Codex In A Clean Room
**Time to first success: about 3 minutes**, once the package is installed. You
open a blank chat, type whatever you like, and watch a real Codex session work a
brand-new mock Paperclip issue through a real `paperclip-runnerd` process. No
scenario to pick, no recorded transcript, and no real Paperclip service anywhere
in the picture.
This is the second primary Phase 7 path. The first — the preset scenario
explorer — is covered by the
[issue-thread tutorial](phase-07-issue-thread.md) and stays available and
unchanged. Read [the clean-room reference](../phase-07-clean-room-chat.md) for
the seed, exposure profile, bounds, and route contract behind what you see here.
## What you need
- Node.js 20 or newer and pnpm 9 or newer (verified with Node 22.22.2 and pnpm
9.15.4).
- A Rust toolchain, because this path builds the real `paperclip-runnerd`
binary. There is no offline fallback: the clean room is live-only by design.
- A locally authenticated Codex CLI. The package server owns that
authentication; the browser never receives it.
Install the workspace from the repository root:
```sh
NODE_ENV=development pnpm install --filter @paperclipai/paperclip-runner --frozen-lockfile --offline --ignore-scripts
```
Setting `NODE_ENV=development` matters even if your shell already has
`NODE_ENV=production`: pnpm silently skips devDependencies otherwise, and the
build tooling lives there.
## 1. Prove the path without a browser (about 1 minute)
```sh
pnpm --filter @paperclipai/paperclip-runner smoke:phase7:cleanroom -- --json
```
This builds the TypeScript output and `paperclip-runnerd`, starts the package
server on loopback, opens a clean room, and drives two real Codex turns through
the same HTTP routes the browser uses. Expected shape:
```json
{
"schema": "paperclip.phase7.clean-room-smoke.v1",
"firstIssue": "MCK-2114",
"newChatIssue": "MCK-9539",
"turns": 2,
"toolCalls": [
{ "operationId": "get_task_context", "outcome": "ok" },
{ "operationId": "report_progress", "outcome": "ok" }
],
"assertions": { "...": true }
}
```
Every assertion must read `true`. The two issue identifiers differ because
`New chat` mints a new mock tenant rather than clearing the old one.
## 2. Open the chat in a browser (about 1 minute)
```sh
pnpm --filter @paperclipai/paperclip-runner console:issue-thread
```
Open `http://127.0.0.1:4184/`. The landing surface is the scenario explorer.
Above the issue header, a two-item rail offers **Scenario explorer** and
**New chat · clean room**. Click `New chat`, or go straight to
`http://127.0.0.1:4184/#/chat`.
The first load takes a few seconds: the server is starting a real runnerd and a
real Codex app-server for you. When it settles you should see:
- three identity chips — `Real Codex`, `Real runnerd`, `Mock Paperclip` — plus a
`Clean room <token>` chip naming the tenant;
- a mock issue identifier such as `MCK-3799` with the title `Clean-room chat`;
- a blank thread with a short explanation of what the surface is;
- a composer, and an `Evidence` button that is closed.
If Codex or runnerd cannot start, the page says so and offers `Try again`. It
will not quietly show you a fixture.
## 3. Send a real message
Type anything. A useful first prompt:
```
Read this issue, then record a one-sentence status on it.
```
You should see a user message, a `Real Codex` reply, collapsed tool strips for
the semantic calls Codex chose (`get_task_context`, `report_progress`, …), and a
durable comment card tagged **Recorded to mock thread** — that card is a mock
control-plane record, not model prose.
Keep going. The conversation is free-form and multi-turn on one durable session.
Ask Codex to write a document, register a deliverable, propose child tasks, or
ask you a structured question; interaction cards render inline and resume the
same Codex thread once you answer.
## 4. Look at the evidence, when you want it
Click `Evidence`. The drawer carries the same eight sections the scenario path
uses: tools exposed, calls and typed results, authorization decisions,
control-plane actions, runner events, state diffs, traceability, and parity.
Two things are worth finding on your first pass:
- **Tools exposed** lists `decide_approval`, `control_workspace_service`, and
`generic_api_request` under `Control plane (not exposed to the agent)`. Those
grants are withheld on purpose, so a denial stays reachable in a conversation
nobody scripted.
- **Control plane** carries a `network-guard-…` row reading
`Real Paperclip API requests: 0. Child PAPERCLIP_* environment keys: none.`
Close the drawer and the surface is a plain chat again.
## 5. Stop, reset, and start over
- **Stop** cancels the active turn and keeps the session and its transcript.
- **Reset** stops active work, retires this session's authority, and opens a new
mock tenant. The confirmation dialog says so before it happens.
- **New chat** does the same without the dialog.
- **Refresh** (F5) reconnects to the same chat. It does not become a recording.
After a reset or a new chat, look at the header: the `MCK-` identifier, the run
id, and the `Clean room` token have all changed. The previous session is gone —
its id answers `404` on every route.
## 6. Confirm the scenario explorer still works
Click `Scenario explorer` in the rail. Every preset deep link still resolves,
including the deterministic capture routes:
```
http://127.0.0.1:4184/#/issue/hb-baseline?shot=denial-optional-tool&capture=1
```
## Where to go next
- [Clean-room chat reference](../phase-07-clean-room-chat.md) — seed, exposure
profile, session routes, and bounds.
- [Execution modes and identity](../phase-07-execution-modes.md) — why a
`mode=fake` artifact can never satisfy a live criterion.
- [Verification commands](../phase-07-verification-commands.md) — the full
command set, including the browser and screenshot gates.
@@ -0,0 +1,130 @@
---
type: Verification Evidence
title: Phase 7M clean-room live chat verification
description: Command-and-result record for the clean-room chat added beside the preset scenario explorer — blank-seed enforcement, exposure profile, live Codex smoke, browser coverage, screenshot determinism, and the scenario-explorer regression check.
tags: [native-runner, phase-7, clean-room, live-codex, verification, evidence]
status: stable
generated: { by: anthropic/claude-opus-5, at: 2026-08-10T15:45:00Z }
entry_kind: evidence
phase: "7"
---
# Scope
Track 7M (PAP-16972) adds a clean-room chat as a second primary path in the
Phase 7 standalone app, beside the preset scenario explorer. This page records
what was run and what was observed on branch `PAP-16679-paperclip-runner`.
The clean room seeds only a mock company, agent, and blank work issue, then
drives the same real `paperclip-runnerd` + real Codex loop the scenario path
uses. It has no fake or replay path: every Paperclip operation still terminates
in the in-process mock `ControlPlanePort`, and no request reaches a real
Paperclip API.
See [the clean-room reference](../../docs/phase-07-clean-room-chat.md) for the
design and [its tutorial](../../docs/tutorials/phase-07-clean-room-chat.md) for
the clean-start walkthrough.
# Environment
- Node.js 22.22.2, pnpm 9.15.4, vitest 4.1.10, Playwright 1.61.1.
- Chromium 151.0.7922.34, pinned through `PAPERCLIP_RUNNER_CHROMIUM_PATH`. The
committed PNG matrix is byte-stable only against that build.
- Rust toolchain and Codex CLI 0.132.0 (locally authenticated) for the live rows
only.
- `NODE_ENV=development` for install and build: an inherited
`NODE_ENV=production` makes pnpm skip the devDependencies the toolchain needs.
# Offline verification
Commands are prefixed `pnpm --filter @paperclipai/paperclip-runner`.
| Surface | Command | Observed result |
| --- | --- | --- |
| Type surfaces (package + browser) | `typecheck:typescript`, `typecheck:browser` | exit 0, no diagnostics |
| Clean-room seed, exposure profile, identity rotation | `exec vitest run src/phase7/clean-room.test.ts` | 7 tests pass |
| Clean-room HTTP routes end to end (stub provider) | `exec vitest run src/phase7/clean-room-server.test.ts` | 9 tests pass |
| Route contract, including explorer deep-link regression | `exec vitest run devtools/issue-thread/src` | 6 tests pass |
| Full Phase 7 suite | `test:phase7` | 14 files, 159 tests pass |
| Browser suite (issue thread + clean room) | `test:browser:issue-thread` | 58 Playwright tests pass, axe clean |
| Deterministic screenshot matrix | `record:phase7:ui` then `check:phase7:ui` | 24 images; all reproduce byte-for-byte |
| Doc links + OKF bundle | `docs:validate` | 93 files; 34 OKF concepts, 8 indexes |
The 24-image matrix was re-recorded because the surface rail that carries the
clean-room entry is a real, always-visible element of the app. Hiding it from
the capture would have kept the old bytes at the cost of screenshots that no
longer show the shipped surface.
# Live verification
| Surface | Command | Observed result |
| --- | --- | --- |
| Real Codex through real runnerd on a fresh mock tenant | `smoke:phase7:cleanroom -- --json` | all 15 assertions `true` |
| Clean-room screenshots | `record:phase7:cleanroom` | 7 images captured (`MCK-3799` → `MCK-7394`) |
Observed smoke output (abridged; identifiers differ per run because each open
mints a new mock tenant):
```json
{
"schema": "paperclip.phase7.clean-room-smoke.v1",
"firstIssue": "MCK-2114",
"newChatIssue": "MCK-9539",
"turns": 2,
"toolCalls": [
{ "operationId": "get_task_context", "outcome": "ok" },
{ "operationId": "report_progress", "outcome": "ok" }
],
"authorizationRecords": 30,
"assertions": {
"cleanRoomOpened": true, "blankThread": true, "noCannedEvidence": true,
"liveIdentity": true, "freshMockTenant": true, "realCodexTurn": true,
"semanticToolCalled": true, "mockStateMutated": true,
"authorizationRecorded": true, "multiTurnSameSession": true,
"composerReady": true, "realApiBlocked": true, "noCredentialInView": true,
"newChatRotatesIdentity": true, "priorAuthorityRetired": true
}
}
```
# Acceptance criteria
| Criterion | Where it is proved |
| --- | --- |
| 1. Blank thread, no scenario selection or canned evidence | `clean-room-server.test.ts` "opens a blank live thread…"; browser "a new chat opens a blank live thread…"; `clean-room-blank--desktop.png` |
| 2. First message starts a real Codex turn through real runnerd | `smoke:phase7:cleanroom` (`realCodexTurn`, `liveIdentity`); `clean-room-live-turn--desktop.png` |
| 3. Allowed call mutates only the mock port; denied call returns a typed denial with no state change | `clean-room-server.test.ts` "runs an allowed semantic call…" and "returns a typed denial…" |
| 4. Multi-turn, one inline interaction, refresh/reconnect, stop, retry on one durable session | `clean-room-server.test.ts` "keeps one durable session…" |
| 5. Reset/new-chat isolation, with new session and mock identities | `clean-room-server.test.ts` "reset and new chat both rotate identities…"; browser "New chat visibly rotates…"; smoke `newChatRotatesIdentity` / `priorAuthorityRetired` |
| 6. Network guards prove no real Paperclip API call | `network-guard-<sessionId>` control-plane record; `clean-room-server.test.ts` "publishes the real-API block…"; smoke `realApiBlocked` |
| 7. Desktop and narrow mobile stay usable with no horizontal scrolling | browser "the narrow layout keeps thread and composer usable…"; `record:phase7:cleanroom` asserts zero overflow at 390px before each mobile capture |
| 8. Existing scenario-explorer tests remain green | `test:phase7` 159/159; `test:browser:issue-thread` 58/58; `check:phase7:ui` 24/24 byte-identical; route test covers the existing deep links |
| 9. Targeted tests and a safe local live smoke pass | tables above |
# Notes and gotchas
- **`sk-` credential heuristic.** The clean room's `task-cleanroom-<token>` ids
contain the substring `sk-`, which the unanchored `sk-[a-z0-9]{8,}` probe used
by the smoke scripts read as an OpenAI-style key. The probe is now anchored
with `(?<![A-Za-z0-9])`; the same fix was applied to the Phase 7G smoke and
the issue-thread test so the heuristic cannot cry wolf on a mock id.
- **Stale settled frame.** A hash change commits the new route in the same frame
that still renders the previous surface's snapshot, so `data-thread-state`
briefly read `settled` for the surface just navigated away from. The app now
tracks which surface produced the current snapshot; a capture or test waiting
on `[data-surface="chat"][data-thread-state="settled"]` can no longer catch
the old frame.
- **Chromium pin.** A wholesale 24/24 screenshot mismatch means the wrong
Chromium, not a UI regression. Pin `PAPERCLIP_RUNNER_CHROMIUM_PATH` to
151.0.7922.34.
- **Panel preference.** The clean room stores its Evidence-drawer preference
under its own key, so an explorer session with the drawer pinned open does not
make the first clean-room visit open with evidence showing.
# Related
- [Clean-room chat reference](../../docs/phase-07-clean-room-chat.md)
- [Clean-room chat tutorial](../../docs/tutorials/phase-07-clean-room-chat.md)
- [Phase 7 final evidence manifest (Revision 3/4)](2026-08-10-phase-07i-final-evidence.md)
- [Phase 7G issue-thread UI verification](2026-08-10-phase-07g-issue-thread-ui-verification.md)
- [Clean-room screenshots](phase-07/clean-room/index.md)
@@ -19,3 +19,4 @@
* [Phase 7 evidence](phase-07/) - Screenshot acceptance captures and the eval conformance parity report.
* [Phase 7G issue-thread UI verification](2026-08-10-phase-07g-issue-thread-ui-verification.md) - View-model tests, browser coverage, the axe gate on 12 slugs × 2 viewports, the byte-stable 24-PNG matrix, and the live runnerd/Codex smoke.
* [Phase 7 final evidence manifest (Revision 3/4)](2026-08-10-phase-07i-final-evidence.md) - Authoritative Phase 7I manifest for the final candidate build `865d1a72`: offline and live command results, conformance and approved security results, screenshot eligibility, and the Revision 2 ineligibility record.
* [Phase 7M clean-room live chat verification](2026-08-10-phase-07m-clean-room-verification.md) - Blank-seed enforcement, deterministic exposure profile, allowed/denied semantic calls, durable multi-turn session, identity rotation on reset and new chat, the real-API block record, browser and axe coverage, and the live Codex smoke.
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@@ -0,0 +1,18 @@
# Phase 7 clean-room chat evidence
Recorded by `pnpm --filter @paperclipai/paperclip-runner record:phase7:cleanroom`.
Real `paperclip-runnerd` and a real Codex app-server drive these frames against a
freshly minted mock tenant. A real model writes the prose and the mock identities
are minted per session, so these PNGs are **not** byte-stable and are excluded
from the §10.2 determinism gate.
First mock issue: `MCK-3799`. After `New chat`: `MCK-7394`.
- `clean-room-entry--desktop.png`
- `clean-room-blank--desktop.png`
- `clean-room-blank--mobile.png`
- `clean-room-live-turn--desktop.png`
- `clean-room-evidence--desktop.png`
- `clean-room-live-turn--mobile.png`
- `clean-room-new-chat--desktop.png`
@@ -47,3 +47,4 @@ recordings of the same route agree.
* [Issue-thread UI matrix](ui/index.md) - Deterministic `fake`-mode captures for the 12 contract slugs at 1440×900 and 390×844; byte-stable across clean runs.
* [Issue-thread live evidence](ui-live/index.md) - Real `paperclip-runnerd` and Codex frames; intentionally not byte-stable.
* [Clean-room chat evidence](clean-room/index.md) - Real `paperclip-runnerd` and Codex frames on a freshly minted mock tenant: the entry point, the blank thread, a live turn, the on-demand evidence drawer, and the identity rotation `New chat` performs. Intentionally not byte-stable.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 45 KiB

@@ -58,3 +58,13 @@
audit, native cancellation, migration coverage, a runnable tutorial, and
deterministic mock/real-port evidence while retaining legacy as the kill-
switch path.
## 2026-08-10
* **Phase 7M clean-room live chat**: Added a second primary path beside the
preset scenario explorer: a blank `#/chat` surface that mints a fresh mock
tenant (company, agent, one blank issue), drives real Codex through real
runnerd with a deterministic exposure profile, keeps tool, policy, event, and
state evidence in an on-demand drawer, rotates session authority and mock
identities on reset and new chat, and publishes the real-API block as an
inspectable control-plane record.
+3 -1
View File
@@ -46,7 +46,7 @@
"test:rust": "cargo test --manifest-path runner/Cargo.toml --locked --workspace",
"test:browser": "pnpm run build:typescript && pnpm run build:phase2-binaries && pnpm run build:browser && playwright test --config devtools/browser/playwright.config.ts",
"test:phase5": "vitest run src/browser/client.test.ts src/browser/transcript-model.test.ts src/react/public-contract.test.tsx",
"test:phase7": "vitest run src/phase7 src/semantic-tools/semantic-tools.test.ts src/mock-core/phase7-mock-control-plane-adapter.test.ts src/issue-thread/issue-thread.test.ts examples/phase7-explorer",
"test:phase7": "vitest run src/phase7 src/semantic-tools/semantic-tools.test.ts src/mock-core/phase7-mock-control-plane-adapter.test.ts src/issue-thread/issue-thread.test.ts devtools/issue-thread/src examples/phase7-explorer",
"test:browser:phase5": "pnpm run build:typescript && playwright test --config examples/playwright.config.ts",
"test:browser:phase7": "playwright test --config examples/playwright.phase7.config.ts && pnpm run test:browser:issue-thread",
"test:browser:issue-thread": "pnpm run build:typescript && pnpm run build:issue-thread && playwright test --config devtools/issue-thread/playwright.config.ts",
@@ -86,7 +86,9 @@
"record:phase7:ui": "pnpm run build:typescript && pnpm run build:issue-thread && node scripts/record-phase7-ui-evidence.mjs",
"check:phase7:ui": "pnpm run build:typescript && pnpm run build:issue-thread && node scripts/record-phase7-ui-evidence.mjs --check",
"smoke:phase7:ui": "pnpm run build:typescript && cargo build --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/phase7-issue-thread-smoke.mjs",
"smoke:phase7:cleanroom": "pnpm run build:typescript && cargo build --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/phase7-clean-room-smoke.mjs",
"record:phase7:ui:live": "pnpm run build:typescript && pnpm run build:issue-thread && cargo build --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/record-phase7-ui-live-evidence.mjs",
"record:phase7:cleanroom": "pnpm run build:typescript && pnpm run build:issue-thread && cargo build --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/record-phase7-clean-room-evidence.mjs",
"diagnose:phase3": "pnpm run trace:phase3",
"record:phase3": "pnpm run build:typescript && pnpm run build:phase2-binaries && node scripts/record-phase3-evidence.mjs",
"record:phase3:lost-ack": "pnpm run trace:phase3 -- --fault lost-ack --output knowledge/evidence/phase-03-lost-ack.json",
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* Live smoke for the Phase 7M clean-room chat.
*
* Starts the real package server on loopback, opens a clean room, and drives a
* real runnerd + Codex app-server session through the same HTTP routes the
* browser uses. It checks that the room starts blank, that a free-form message
* produces a real Codex turn with semantic tool calls against the mock control
* plane, that `New chat` rotates the mock identities and retires the previous
* session, and that nothing in the projected view carries a credential.
*
* Requires an authenticated local Codex installation and a built runnerd.
*
* Usage: node scripts/phase7-clean-room-smoke.mjs [--json]
*/
import { createServer } from "node:http";
import { once } from "node:events";
import { createPhase7IssueThreadMiddleware } from "./phase7-issue-thread-server.mjs";
const CREDENTIAL_PATTERNS = [
/bearer\s+[a-z0-9._-]+/i,
// Anchored so a mock id such as `task-cleanroom-3f2a9c11` cannot masquerade
// as an `sk-` provider key. A real key is preceded by a delimiter.
/(?<![A-Za-z0-9])sk-[a-z0-9]{8,}/i,
/"api[_-]?key"\s*:/i,
/PAPERCLIP_API_KEY/,
/OPENAI_API_KEY/,
];
function credentialLeaks(value) {
const serialized = JSON.stringify(value);
return CREDENTIAL_PATTERNS.filter((pattern) => pattern.test(serialized)).map(String);
}
function assert(condition, message) {
if (!condition) throw new Error(`assertion failed: ${message}`);
}
function items(view) {
return view.turns.flatMap((turn) => turn.items);
}
async function main() {
const asJson = process.argv.includes("--json");
const middleware = createPhase7IssueThreadMiddleware({ bindHost: "127.0.0.1" });
const server = createServer((request, response) => {
middleware(request, response, () => {
response.statusCode = 404;
response.end("not found");
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const { port } = server.address();
const origin = `http://127.0.0.1:${port}`;
const get = async (path) => (await fetch(`${origin}${path}`)).json();
const post = async (path, body) =>
(
await fetch(`${origin}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
})
).json();
const assertions = {};
let summary = {};
try {
const opened = await get("/api/phase7/ui/cleanroom/session");
assertions.cleanRoomOpened = typeof opened.sessionId === "string" && opened.sessionId.length > 0;
assertions.blankThread = Array.isArray(opened.view.turns) && opened.view.turns.length === 0;
assertions.noCannedEvidence =
opened.view.evidence.calls.length === 0 &&
opened.view.evidence.parity.length === 0 &&
opened.view.replay === null;
assertions.liveIdentity =
opened.view.mode === "live" &&
opened.view.identity.agentLabel === "Real Codex" &&
opened.view.identity.runnerLabel === "Real runnerd" &&
opened.view.identity.controlPlaneLabel === "Mock Paperclip";
assertions.freshMockTenant =
typeof opened.identity?.token === "string" &&
opened.view.issue.identifier === opened.identity.identifier &&
opened.view.issue.identifier.startsWith("MCK-");
const first = await post("/api/phase7/ui/message", {
sessionId: opened.sessionId,
message:
"Read this issue with get_task_context, then call report_progress with a one-sentence status. Do not ask me anything.",
});
const firstItems = items(first.view);
assertions.realCodexTurn = firstItems.some((item) => item.kind === "agent_message");
assertions.semanticToolCalled = firstItems.some((item) => item.kind === "tool_activity");
assertions.mockStateMutated = firstItems.some((item) => item.kind === "durable_comment");
assertions.authorizationRecorded = first.view.evidence.authorization.length > 0;
const second = await post("/api/phase7/ui/message", {
sessionId: opened.sessionId,
message: "In one line, what status did you just record?",
});
assertions.multiTurnSameSession =
second.sessionId === opened.sessionId && second.view.turns.length > first.view.turns.length;
assertions.composerReady = second.view.composer.state === "ready";
const guard = second.view.evidence.control_plane.find((record) =>
record.id.startsWith("network-guard-"),
);
assertions.realApiBlocked = guard?.outcome === "no_real_paperclip_request";
assertions.noCredentialInView = credentialLeaks(second.view).length === 0;
const fresh = await post("/api/phase7/ui/cleanroom/session", { sessionId: opened.sessionId });
assertions.newChatRotatesIdentity =
fresh.sessionId !== opened.sessionId &&
fresh.identity.companyId !== opened.identity.companyId &&
fresh.identity.taskId !== opened.identity.taskId &&
fresh.view.turns.length === 0;
const retired = await fetch(`${origin}/api/phase7/ui/message`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionId: opened.sessionId, message: "still there?" }),
});
assertions.priorAuthorityRetired = retired.status === 404;
summary = {
schema: "paperclip.phase7.clean-room-smoke.v1",
firstSessionId: opened.sessionId,
firstIssue: opened.view.issue.identifier,
newChatSessionId: fresh.sessionId,
newChatIssue: fresh.view.issue.identifier,
turns: second.view.turns.length,
toolCalls: second.view.evidence.calls.map((call) => ({
operationId: call.operationId,
outcome: call.outcome,
})),
authorizationRecords: second.view.evidence.authorization.length,
assistantReply: firstItems.find((item) => item.kind === "agent_message")?.body ?? null,
};
const failures = Object.entries(assertions).filter(([, ok]) => ok !== true);
if (asJson) {
process.stdout.write(`${JSON.stringify({ ...summary, assertions }, null, 2)}\n`);
}
assert(failures.length === 0, `${failures.map(([name]) => name).join(", ")}`);
if (!asJson) process.stdout.write("Phase 7 clean-room live smoke passed.\n");
} finally {
await middleware.close();
server.close();
}
}
await main();
@@ -1,9 +1,10 @@
import { mkdtemp } from "node:fs/promises";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
/**
* Package server for the Phase 7G issue-thread UI.
* Package server for the Phase 7G issue-thread UI and the Phase 7M clean-room
* chat.
*
* The browser posts intents here; this process owns the real runnerd and Codex
* app-server pair, the mock ControlPlanePort, and every policy decision. Each
@@ -13,18 +14,35 @@ import { resolve } from "node:path";
* An interaction response is stored in the mock control plane before the
* runner is resumed — that ordering is enforced inside `Phase7LiveSession`
* (§5 response authority path).
*
* Two surfaces share one session registry and one set of turn routes:
*
* - `issue` seeds the preset scenario the explorer selects;
* - `cleanroom` seeds only a company, an agent, and a blank issue.
*
* Both run the same real runnerd + real Codex loop. Neither has a scripted or
* replay path in this process, so there is nothing here that could quietly
* substitute for a live turn.
*/
const ROUTE_PREFIX = "/api/phase7/ui";
const CLEAN_ROOM_ROUTE = "cleanroom/session";
/** Bounded concurrency (revision 5 safety requirements). */
const MAX_CLEAN_ROOM_SESSIONS = 4;
/** Bounded output: a clean room is a demo, not a long-lived agent. */
const MAX_TURNS_PER_SESSION = 24;
const MAX_MESSAGE_BYTES = 8 * 1024;
async function loadRunner() {
return import(new URL("../dist/index.js", import.meta.url).href);
}
async function createWorkingDirectory() {
const scratchRoot =
process.env.PAPERCLIP_RUN_SCRATCH_DIR ?? process.env.PAPERCLIP_SCRATCH_DIR ?? tmpdir();
return mkdtemp(resolve(scratchRoot, "phase7-issue-thread-"));
function scratchRoot() {
return process.env.PAPERCLIP_RUN_SCRATCH_DIR ?? process.env.PAPERCLIP_SCRATCH_DIR ?? tmpdir();
}
async function createWorkingDirectory(prefix = "phase7-issue-thread-") {
return mkdtemp(resolve(scratchRoot(), prefix));
}
/** Mock-only fixture seed. Identifiers use the reserved `MCK-` prefix (§1.3). */
@@ -64,11 +82,31 @@ function issueThreadSeed(runner, scenario) {
});
}
class RouteError extends Error {
constructor(status, code, message) {
super(message);
this.status = status;
this.code = code;
}
}
export function createPhase7IssueThreadMiddleware(options = {}) {
const load = options.loadRunner ?? loadRunner;
let bootstrap = null;
let bindHost = options.bindHost ?? "127.0.0.1";
/** @type {Map<string, { session: unknown, scenario: string, connection: { state: string, attempt: number } }>} */
/**
* @type {Map<string, {
* session: unknown,
* surface: "issue" | "cleanroom",
* scenario: string,
* identity: unknown,
* workingDirectory: string,
* ownsWorkingDirectory: boolean,
* turns: number,
* createdAt: number,
* connection: { state: string, attempt: number },
* }>}
*/
const sessions = new Map();
async function ready(requestedBindHost = bindHost) {
@@ -89,7 +127,7 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
return bootstrap;
}
function view(runner, entry, sessionId) {
function view(runner, entry) {
return runner.projectPhase7IssueThread({
snapshot: entry.session.snapshot(),
connection: entry.connection,
@@ -98,9 +136,29 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
});
}
/**
* The clean room must never answer with a scripted or replayed turn. The
* projection is the only thing the browser sees, so the guard sits on the way
* out rather than on the way in.
*/
function liveView(runner, entry) {
const projected = view(runner, entry);
if (projected.mode !== "live" || projected.identity.agentLabel !== "Real Codex") {
throw new RouteError(500, "live_mode_required", "The clean-room path refused a non-live view.");
}
return projected;
}
async function readBody(request) {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
let total = 0;
for await (const chunk of request) {
total += chunk.length;
if (total > MAX_MESSAGE_BYTES * 4) {
throw new RouteError(413, "request_too_large", "Request body exceeds the server limit.");
}
chunks.push(chunk);
}
if (chunks.length === 0) return {};
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
@@ -124,11 +182,80 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
actorId: "actor-1",
companyId: "company-1",
});
const entry = { session, scenario, connection: { state: "connected", attempt: 0 } };
const entry = {
session,
surface: "issue",
scenario,
identity: null,
workingDirectory,
ownsWorkingDirectory: false,
turns: 0,
createdAt: Date.now(),
connection: { state: "connected", attempt: 0 },
};
sessions.set(session.id, entry);
return entry;
}
/** Retire a session: stop its work, clear its authority, drop its workspace. */
async function retire(service, sessionId, reason) {
const entry = sessions.get(sessionId);
if (entry === undefined) return;
sessions.delete(sessionId);
await service.stop(sessionId, reason).catch(() => undefined);
if (entry.ownsWorkingDirectory) {
await rm(entry.workingDirectory, { recursive: true, force: true }).catch(() => undefined);
}
}
async function createCleanRoomSession(runner, service) {
const open = [...sessions.values()].filter((entry) => entry.surface === "cleanroom");
// Bounded concurrency: the oldest clean room yields rather than refusing a
// new board user, because an abandoned chat is the likelier tenant here.
for (const stale of open.slice(0, Math.max(0, open.length - (MAX_CLEAN_ROOM_SESSIONS - 1)))) {
await retire(service, stale.session.id, "clean-room capacity");
}
const workingDirectory = await createWorkingDirectory("phase7-clean-room-");
const { identity, input } = runner.createPhase7CleanRoomSessionInput({ workingDirectory });
let session;
try {
session = await service.create(input);
} catch (error) {
await rm(workingDirectory, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
const entry = {
session,
surface: "cleanroom",
scenario: "clean-room",
identity,
workingDirectory,
ownsWorkingDirectory: true,
turns: 0,
createdAt: Date.now(),
connection: { state: "connected", attempt: 0 },
};
sessions.set(session.id, entry);
return entry;
}
function cleanRoomPayload(runner, entry) {
return {
sessionId: entry.session.id,
surface: "cleanroom",
identity: entry.identity,
limits: { maxTurns: MAX_TURNS_PER_SESSION, maxMessageBytes: MAX_MESSAGE_BYTES },
turns: entry.turns,
view: liveView(runner, entry),
};
}
function payload(runner, entry) {
return entry.surface === "cleanroom"
? cleanRoomPayload(runner, entry)
: { sessionId: entry.session.id, surface: "issue", view: view(runner, entry) };
}
const middleware = async function phase7IssueThreadMiddleware(request, response, next) {
const url = new URL(request.url ?? "/", "http://phase7.local");
if (!url.pathname.startsWith(`${ROUTE_PREFIX}/`)) {
@@ -144,16 +271,40 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
const scenario =
typeof body.scenario === "string" ? body.scenario : url.searchParams.get("scenario") ?? "hb-baseline";
if (route === CLEAN_ROOM_ROUTE && request.method === "GET") {
const existing = requestedId === null ? undefined : sessions.get(requestedId);
// A stale id from localStorage opens a fresh room rather than a dead
// end; a live id reconnects to the same durable chat.
const entry =
existing !== undefined && existing.surface === "cleanroom"
? existing
: await createCleanRoomSession(runner, service);
send(response, 200, cleanRoomPayload(runner, entry));
return;
}
if (route === CLEAN_ROOM_ROUTE && request.method === "POST") {
// `New chat`: retire the caller's room before minting the next one, so
// the prior session authority is cleared rather than left running.
if (requestedId !== null) await retire(service, requestedId, "new clean-room chat");
const entry = await createCleanRoomSession(runner, service);
send(response, 201, cleanRoomPayload(runner, entry));
return;
}
if (route === "session" && request.method === "GET") {
const existing = requestedId === null ? undefined : sessions.get(requestedId);
const entry = existing ?? (await createSession(runner, service, scenario, workingDirectory));
send(response, 200, { sessionId: entry.session.id, view: view(runner, entry) });
const entry =
existing !== undefined && existing.surface === "issue"
? existing
: await createSession(runner, service, scenario, workingDirectory);
send(response, 200, payload(runner, entry));
return;
}
if (route === "session" && request.method === "POST") {
const entry = await createSession(runner, service, scenario, workingDirectory);
send(response, 201, { sessionId: entry.session.id, view: view(runner, entry) });
send(response, 201, payload(runner, entry));
return;
}
@@ -164,7 +315,19 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
}
if (route === "message") {
await entry.session.sendMessage(String(body.message ?? ""));
const message = String(body.message ?? "");
if (Buffer.byteLength(message, "utf8") > MAX_MESSAGE_BYTES) {
throw new RouteError(413, "message_too_large", "Message exceeds the server limit.");
}
if (entry.turns >= MAX_TURNS_PER_SESSION) {
throw new RouteError(
429,
"turn_limit",
`This chat reached its ${MAX_TURNS_PER_SESSION}-turn limit. Start a new chat to continue.`,
);
}
entry.turns += 1;
await entry.session.sendMessage(message);
} else if (route === "interrupt") {
await entry.session.interrupt("operator stopped the turn");
} else if (route === "reconnect") {
@@ -172,15 +335,25 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
await entry.session.reconnect();
entry.connection = { state: "connected", attempt: 0 };
} else if (route === "reset") {
if (entry.surface === "cleanroom") {
// A clean-room reset is a new tenant, not a rewound one: the seed is
// blank either way, so restoring it would hand back the same mock
// identities the board just saw.
await retire(service, entry.session.id, "clean-room reset");
const replacement = await createCleanRoomSession(runner, service);
send(response, 200, cleanRoomPayload(runner, replacement));
return;
}
const next = await service.reset(entry.session.id);
sessions.delete(entry.session.id);
const resetEntry = {
...entry,
session: next,
scenario: entry.scenario,
turns: 0,
connection: { state: "connected", attempt: 0 },
};
sessions.set(next.id, resetEntry);
send(response, 200, { sessionId: next.id, view: view(runner, resetEntry) });
send(response, 200, payload(runner, resetEntry));
return;
} else if (route === "interaction") {
// The session stores the typed response in the mock control plane
@@ -195,8 +368,12 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
return;
}
send(response, 200, { sessionId: entry.session.id, view: view(runner, entry) });
send(response, 200, payload(runner, entry));
} catch (error) {
if (error instanceof RouteError) {
send(response, error.status, { error: error.code, message: error.message });
return;
}
send(response, 500, {
error: "phase7_issue_thread_unavailable",
message: String(error instanceof Error ? error.message : error),
@@ -208,8 +385,7 @@ export function createPhase7IssueThreadMiddleware(options = {}) {
if (bootstrap === null) return;
const { service } = await bootstrap;
for (const sessionId of [...sessions.keys()]) {
await service.shutdown(sessionId, "server shutdown").catch(() => undefined);
sessions.delete(sessionId);
await retire(service, sessionId, "server shutdown");
}
};
middleware.prepare = ready;
@@ -18,7 +18,9 @@ import { createPhase7IssueThreadMiddleware } from "./phase7-issue-thread-server.
const CREDENTIAL_PATTERNS = [
/bearer\s+[a-z0-9._-]+/i,
/sk-[a-z0-9]{8,}/i,
// Anchored so a mock id such as `task-cleanroom-3f2a9c11` cannot masquerade
// as an `sk-` provider key. A real key is preceded by a delimiter.
/(?<![A-Za-z0-9])sk-[a-z0-9]{8,}/i,
/"api[_-]?key"\s*:/i,
/PAPERCLIP_API_KEY/,
/OPENAI_API_KEY/,
@@ -0,0 +1,167 @@
#!/usr/bin/env node
/**
* Captures clean-room chat evidence: a blank thread, a real Codex turn against
* a fresh mock tenant, the on-demand evidence drawer, and the identity rotation
* that `New chat` performs.
*
* Like the Phase 7G live evidence, these PNGs sit outside the deterministic
* §10.2 matrix on purpose — a real model writes the prose and the mock
* identities are minted per session, so they are not byte-stable. They exist to
* show the surface a board user actually meets.
*
* Requires an authenticated local Codex installation and a built runnerd.
*/
import { spawn } from "node:child_process";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "@playwright/test";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const OUTPUT_DIR = resolve(packageRoot, "knowledge/evidence/phase-07/clean-room");
const PORT = Number.parseInt(process.env.PAPERCLIP_PHASE7_CLEAN_ROOM_PORT ?? "4187", 10);
const ORIGIN = `http://127.0.0.1:${PORT}`;
const DESKTOP = { width: 1440, height: 900 };
const MOBILE = { width: 390, height: 844 };
const PROMPT =
"Read this issue with get_task_context, then call report_progress with a one-sentence status. Do not ask me anything.";
async function waitForServer(url, timeoutMs = 90_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
if ((await fetch(url)).ok) return;
} catch {
// Still binding.
}
if (Date.now() > deadline) throw new Error(`preview server never answered on ${url}`);
await new Promise((done) => setTimeout(done, 250));
}
}
async function assertNoHorizontalScroll(page, label) {
const overflow = await page.evaluate(() => {
const element = document.scrollingElement;
return element.scrollWidth - element.clientWidth;
});
if (overflow > 0) throw new Error(`${label} scrolls horizontally by ${overflow}px`);
}
async function shot(page, name) {
await page.screenshot({
path: resolve(OUTPUT_DIR, `${name}.png`),
animations: "disabled",
caret: "hide",
});
return `${name}.png`;
}
async function main() {
const child = spawn(
"pnpm",
["exec", "vite", "preview", "--config", "vite.issue-thread.config.ts", "--host", "127.0.0.1", "--port", String(PORT)],
{ cwd: packageRoot, stdio: "ignore", env: { ...process.env, NODE_ENV: "" } },
);
await waitForServer(ORIGIN);
const browser = await chromium.launch({
...(process.env.PAPERCLIP_RUNNER_CHROMIUM_PATH === undefined
? {}
: { executablePath: process.env.PAPERCLIP_RUNNER_CHROMIUM_PATH }),
});
const captured = [];
try {
await rm(OUTPUT_DIR, { recursive: true, force: true });
await mkdir(OUTPUT_DIR, { recursive: true });
const context = await browser.newContext({
viewport: DESKTOP,
colorScheme: "dark",
locale: "en-US",
timezoneId: "UTC",
});
const page = await context.newPage();
// The scenario explorer is the landing surface; the clean room has to be
// one click away from it without picking a scenario first.
await page.goto(`${ORIGIN}/#/issue/hb-baseline?shot=thread-baseline`);
await page.waitForSelector('[data-thread-state="settled"]', { timeout: 60_000 });
captured.push(await shot(page, "clean-room-entry--desktop"));
await page.getByTestId("surface-chat-link").click();
// The explorer render is already settled, so the wait has to name the new
// surface too — otherwise it matches the page we just navigated away from.
await page.waitForSelector('[data-surface="chat"][data-thread-state="settled"]', {
timeout: 180_000,
});
const firstIdentifier = await page.locator(".pit-identifier").innerText();
const agentChip = await page.getByTestId("agent-chip").innerText();
const runnerChip = await page.getByTestId("runner-chip").innerText();
if (!agentChip.includes("Real Codex") || !runnerChip.includes("Real runnerd")) {
throw new Error(`clean-room identity chips are wrong: ${agentChip} / ${runnerChip}`);
}
await page.waitForSelector('[data-testid="clean-room-empty"]');
captured.push(await shot(page, "clean-room-blank--desktop"));
await page.setViewportSize(MOBILE);
await assertNoHorizontalScroll(page, "clean-room blank (mobile)");
captured.push(await shot(page, "clean-room-blank--mobile"));
await page.setViewportSize(DESKTOP);
await page.fill("#composer-input", PROMPT);
await page.getByTestId("composer-send").click();
await page.waitForSelector('[data-thread-item="durable_comment"]', { timeout: 300_000 });
await page.waitForSelector('[data-composer-state="ready"]', { timeout: 60_000 });
captured.push(await shot(page, "clean-room-live-turn--desktop"));
await page.getByTestId("evidence-toggle").click();
await page.getByRole("button", { name: /Control plane/ }).click();
captured.push(await shot(page, "clean-room-evidence--desktop"));
await page.getByTestId("evidence-toggle").click();
await page.setViewportSize(MOBILE);
await assertNoHorizontalScroll(page, "clean-room live turn (mobile)");
captured.push(await shot(page, "clean-room-live-turn--mobile"));
await page.setViewportSize(DESKTOP);
await page.getByTestId("new-chat-button").click();
await page.waitForSelector('[data-testid="clean-room-empty"]', { timeout: 180_000 });
const secondIdentifier = await page.locator(".pit-identifier").innerText();
if (secondIdentifier === firstIdentifier) {
throw new Error(`New chat did not rotate the mock identity (${secondIdentifier})`);
}
captured.push(await shot(page, "clean-room-new-chat--desktop"));
await writeFile(
resolve(OUTPUT_DIR, "index.md"),
[
"# Phase 7 clean-room chat evidence",
"",
"Recorded by `pnpm --filter @paperclipai/paperclip-runner record:phase7:cleanroom`.",
"",
"Real `paperclip-runnerd` and a real Codex app-server drive these frames against a",
"freshly minted mock tenant. A real model writes the prose and the mock identities",
"are minted per session, so these PNGs are **not** byte-stable and are excluded",
"from the §10.2 determinism gate.",
"",
`First mock issue: \`${firstIdentifier}\`. After \`New chat\`: \`${secondIdentifier}\`.`,
"",
...captured.map((name) => `- \`${name}\``),
"",
].join("\n"),
"utf8",
);
process.stdout.write(
`Recorded ${captured.length} clean-room screenshots to ${OUTPUT_DIR} (${firstIdentifier} → ${secondIdentifier})\n`,
);
await context.close();
} finally {
await browser.close();
child.kill("SIGTERM");
}
}
await main();
+1
View File
@@ -31,6 +31,7 @@ export * from "./tracer/phase1-replay.js";
export * from "./tracer/phase2-live.js";
export * from "./tools/index.js";
export * as acceptedPhase7SemanticTools from "./semantic-tools/index.js";
export * from "./phase7/clean-room.js";
export * from "./phase7/live-session.js";
export * from "./phase7/runnerd-codex-transport.js";
export { projectPhase7IssueThread } from "./issue-thread/live-projection.js";
@@ -49,7 +49,9 @@ function credentialLike(value: unknown): string[] {
const patterns = [
/"authorization"\s*:\s*"/i,
/bearer\s+[a-z0-9._-]+/i,
/sk-[a-z0-9]{8,}/i,
// Anchored: a mock id such as `task-cleanroom-3f2a9c11` embeds `sk-` and
// would otherwise read as an `sk-` provider key.
/(?<![A-Za-z0-9])sk-[a-z0-9]{8,}/i,
/"api[_-]?key"\s*:/i,
/PAPERCLIP_API_KEY/,
];
@@ -21,6 +21,7 @@ import { PHASE7_SEMANTIC_TOOL_CATALOG } from "../semantic-tools/catalog.js";
import type { Phase7SemanticOperationId } from "../semantic-tools/types.js";
import type {
Phase7ComposerModel,
Phase7EvidenceControlPlaneRecord,
Phase7EvidenceModel,
Phase7EvidenceParityRecord,
Phase7EvidenceToolRow,
@@ -505,6 +506,37 @@ function composerModel(
return { state: "ready", helper: null, reason: null, pendingInteractionId: null };
}
/**
* The real-API block, rendered as a record rather than a claim in prose.
*
* Both the clean-room chat and the scenario path route every Paperclip
* operation through the in-process mock port, so "no request reached a real
* Paperclip API" is a fact the session already carries. Projecting it into the
* Control plane section is what makes it inspectable in the drawer instead of
* something a reader has to take on trust.
*/
function networkGuardRecord(
snapshot: Phase7LiveSessionSnapshot,
state: Phase7FixtureState | null,
): Phase7EvidenceControlPlaneRecord {
const { realPaperclipRequests, childPaperclipEnvironmentKeys } = snapshot.networkEvidence;
return {
id: `network-guard-${snapshot.sessionId}`,
turnId: "turn-0",
category: "session",
outcome: realPaperclipRequests === 0 ? "no_real_paperclip_request" : "real_paperclip_request",
reason:
`Real Paperclip API requests: ${realPaperclipRequests}. ` +
`Child PAPERCLIP_* environment keys: ${
childPaperclipEnvironmentKeys.length === 0
? "none"
: childPaperclipEnvironmentKeys.join(", ")
}.`,
stateRevision: state?.revision ?? 0,
threadAnchorId: null,
};
}
function evidenceModel(
snapshot: Phase7LiveSessionSnapshot,
state: Phase7FixtureState | null,
@@ -558,7 +590,7 @@ function evidenceModel(
stateChangeRef: null,
threadAnchorId: record.callId === null ? "" : `item-call-${record.callId}`,
})),
control_plane: (state?.decisions ?? []).map((decision) => ({
control_plane: [networkGuardRecord(snapshot, state), ...(state?.decisions ?? []).map((decision) => ({
id: decision.id,
turnId: "turn-0",
category:
@@ -575,7 +607,7 @@ function evidenceModel(
reason: decision.reason,
stateRevision: decision.stateRevision,
threadAnchorId: null,
})),
}))],
runner: snapshot.evidence.map((entry, index) => ({
id: entry.id,
turnId: entry.turnId ?? "turn-0",
@@ -0,0 +1,525 @@
import { once } from "node:events";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type {
CodexAppServerTransport,
CodexRpcNotification,
CodexRpcServerRequest,
CodexServerRequestHandler,
} from "../drivers/codex/app-server-transport.js";
import type { Phase7IssueThreadSnapshot, Phase7ThreadItem } from "../issue-thread/types.js";
import type { Phase7LiveTransportFactory } from "./live-session.js";
/**
* Clean-room chat, driven through the same HTTP routes the browser uses.
*
* The provider is a stand-in — a real Codex turn is proved by the live smoke
* script, not by a unit test — but everything below the provider is real: the
* middleware, the live session, the semantic dispatcher, the authorization
* engine, and the mock `ControlPlanePort`. That is the layer these criteria
* actually live in: what a tool call mutates, what a denial does *not* mutate,
* and what `New chat` retires.
*/
interface FakeProviderState {
threadId: string;
providerSessionId: string;
nextTurn: number;
transports: FakeCleanRoomTransport[];
}
class AsyncNotifications implements AsyncIterable<CodexRpcNotification> {
#values: CodexRpcNotification[] = [];
#waiters: Array<(value: IteratorResult<CodexRpcNotification>) => void> = [];
#closed = false;
push(value: CodexRpcNotification): void {
const waiter = this.#waiters.shift();
if (waiter) waiter({ value, done: false });
else this.#values.push(value);
}
close(): void {
this.#closed = true;
for (const waiter of this.#waiters.splice(0)) waiter({ value: undefined, done: true });
}
[Symbol.asyncIterator](): AsyncIterator<CodexRpcNotification> {
return {
next: async () => {
const value = this.#values.shift();
if (value) return { value, done: false };
if (this.#closed) return { value: undefined, done: true };
return new Promise((resolve) => this.#waiters.push(resolve));
},
};
}
}
class FakeCleanRoomTransport implements CodexAppServerTransport {
readonly queue = new AsyncNotifications();
readonly exposedTools: string[] = [];
#handler: CodexServerRequestHandler = async () => ({});
#closed = false;
constructor(
readonly state: FakeProviderState,
readonly onClose: () => void,
) {
state.transports.push(this);
}
async request(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
if (method === "initialize") return { user: { sessionId: this.state.providerSessionId } };
if (method === "thread/start") {
for (const tool of (params.dynamicTools ?? []) as Array<{ name: string }>) {
this.exposedTools.push(tool.name);
}
return { thread: { id: this.state.threadId, sessionId: this.state.providerSessionId } };
}
if (method === "thread/read" || method === "thread/resume") {
return { thread: { id: this.state.threadId, sessionId: this.state.providerSessionId } };
}
if (method === "turn/start") {
const turnId = `turn-${++this.state.nextTurn}`;
const input = Array.isArray(params.input) ? (params.input[0] as Record<string, unknown>) : {};
void this.#runTurn(turnId, String(input.text ?? ""));
return { turn: { id: turnId, status: "inProgress" } };
}
if (method === "turn/interrupt") {
this.queue.push({
method: "turn/completed",
params: {
threadId: this.state.threadId,
turn: { id: String(params.turnId), status: "interrupted" },
},
});
return {};
}
throw new Error(`unsupported fake Codex method ${method}`);
}
notify(): void {}
notifications(): AsyncIterable<CodexRpcNotification> {
return this.queue;
}
setServerRequestHandler(handler: CodexServerRequestHandler): void {
this.#handler = handler;
}
async close(): Promise<void> {
this.#closed = true;
this.queue.close();
this.onClose();
}
processInfo() {
return { pid: 7100, processGroupId: 7100, exited: this.#closed, exitCode: null, signal: null };
}
async #runTurn(turnId: string, message: string): Promise<void> {
await Promise.resolve();
this.queue.push({
method: "turn/started",
params: { threadId: this.state.threadId, turn: { id: turnId, status: "inProgress" } },
});
await new Promise((resolve) => setTimeout(resolve, 0));
let assistantText = `Nothing to do for "${message}".`;
if (message.includes("hang")) return;
// The resumed turn carries the typed interaction result as its input, so it
// is matched before the keyword branches it would otherwise trip.
if (message.includes("semantic-interaction-result")) {
assistantText = `Continuing with the typed interaction result: ${message}`;
} else if (message.includes("progress")) {
assistantText = await this.#call(turnId, "report_progress", {
idempotencyKey: `progress-${turnId}`,
body: "Read the blank clean-room issue and recorded a first status.",
});
} else if (message.includes("decide")) {
assistantText = await this.#call(turnId, "decide_approval", {
idempotencyKey: `decide-${turnId}`,
approvalId: "approval-1",
decision: "approved",
note: "trying an operation this chat was never granted",
});
} else if (message.includes("question")) {
assistantText = await this.#call(turnId, "request_human_input", {
idempotencyKey: `question-${turnId}`,
interactionKind: "questions",
title: "Which way should I take this?",
prompt: "Pick a direction for the clean-room issue.",
payload: { questions: [{ id: "path", prompt: "Which path?", options: ["a", "b"] }] },
continuationPolicy: "wake_assignee",
});
}
this.queue.push({
method: "item/completed",
params: {
threadId: this.state.threadId,
turnId,
item: { id: `message-${turnId}`, type: "agentMessage", text: assistantText },
},
});
this.queue.push({
method: "turn/completed",
params: { threadId: this.state.threadId, turn: { id: turnId, status: "completed" } },
});
}
async #call(turnId: string, tool: string, args: Record<string, unknown>): Promise<string> {
const request: CodexRpcServerRequest = {
id: `request-${turnId}`,
method: "item/tool/call",
params: {
threadId: this.state.threadId,
turnId,
callId: `call-${turnId}`,
tool,
arguments: args,
},
};
const result = await this.#handler(request);
const items = result.contentItems as Array<Record<string, unknown>>;
return `The typed result drove this reply: ${String(items[0]?.text)}`;
}
}
function fakeTransportFactory(state: FakeProviderState): Phase7LiveTransportFactory {
return (options) => {
const evidence = {
runnerPid: 7100,
runnerProcessGroupId: 7100,
codexPid: 7200,
runnerExited: false,
runnerExitCode: null,
runnerSignal: null,
childEnvironmentKeys: ["CODEX_HOME", "HOME", "PATH"],
diagnostics: [],
};
const transport = new FakeCleanRoomTransport(state, () => {
evidence.runnerExited = true;
options.onEvidence?.(evidence);
});
options.onEvidence?.(evidence);
return { transport, evidence: () => ({ ...evidence }) };
};
}
interface CleanRoomPayload {
sessionId: string;
surface: string;
identity: { token: string; companyId: string; actorId: string; taskId: string; identifier: string };
limits: { maxTurns: number; maxMessageBytes: number };
view: Phase7IssueThreadSnapshot;
}
function items(view: Phase7IssueThreadSnapshot): Phase7ThreadItem[] {
return view.turns.flatMap((turn) => turn.items);
}
let server: Server;
let origin: string;
let middleware: Awaited<ReturnType<typeof createMiddleware>>;
let providerState: FakeProviderState;
const requestedUrls: string[] = [];
async function createMiddleware(state: FakeProviderState) {
const module = await import("../../scripts/phase7-issue-thread-server.mjs");
return module.createPhase7IssueThreadMiddleware({
bindHost: "127.0.0.1",
loadRunner: async () => await import("../index.js"),
transportFactory: fakeTransportFactory(state),
});
}
async function call(path: string, body?: unknown): Promise<CleanRoomPayload> {
const url = `${origin}${path}`;
requestedUrls.push(url);
const response = await fetch(
url,
body === undefined
? {}
: {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
const payload = (await response.json()) as CleanRoomPayload & { error?: string };
if (!response.ok) throw new Error(`${path} → ${response.status} ${payload.error ?? ""}`);
return payload;
}
async function status(path: string, body?: unknown): Promise<number> {
const response = await fetch(
`${origin}${path}`,
body === undefined
? {}
: {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
await response.text();
return response.status;
}
beforeEach(async () => {
requestedUrls.length = 0;
providerState = {
threadId: "codex-thread-clean-room",
providerSessionId: "codex-provider-clean-room",
nextTurn: 0,
transports: [],
};
middleware = await createMiddleware(providerState);
server = createServer((request, response) => {
void middleware(request, response, () => {
response.statusCode = 404;
response.end("not found");
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await middleware.close();
server.close();
await once(server, "close");
});
describe("Phase 7 clean-room chat server", () => {
it("opens a blank live thread with fresh mock identities and no canned evidence", async () => {
const opened = await call("/api/phase7/ui/cleanroom/session");
expect(opened.surface).toBe("cleanroom");
expect(opened.view.turns).toEqual([]);
expect(opened.view.mode).toBe("live");
expect(opened.view.identity.agentLabel).toBe("Real Codex");
expect(opened.view.identity.runnerLabel).toBe("Real runnerd");
expect(opened.view.identity.controlPlaneLabel).toBe("Mock Paperclip");
expect(opened.view.identity.replaySource).toBeNull();
expect(opened.view.replay).toBeNull();
expect(opened.view.composer.state).toBe("ready");
expect(opened.view.issue.identifier).toBe(opened.identity.identifier);
expect(opened.view.issue.identifier.startsWith("MCK-")).toBe(true);
// The run checked the mock issue out, which is the only mutation an
// open performs.
expect(opened.view.issue.status).toBe("in_progress");
expect(opened.view.evidence.calls).toEqual([]);
expect(opened.view.evidence.parity).toEqual([]);
expect(opened.view.evidence.traceability).toEqual([]);
});
it("publishes the real-API block as an inspectable record", async () => {
const opened = await call("/api/phase7/ui/cleanroom/session");
const guard = opened.view.evidence.control_plane.find((record) =>
record.id.startsWith("network-guard-"),
);
expect(guard?.outcome).toBe("no_real_paperclip_request");
expect(guard?.reason).toContain("Real Paperclip API requests: 0");
expect(guard?.reason).toContain("Child PAPERCLIP_* environment keys: none");
// Every request this test made went to its own loopback server.
expect(requestedUrls.every((url) => url.startsWith(origin))).toBe(true);
});
it("keeps credentials out of the projected clean-room view", async () => {
const opened = await call("/api/phase7/ui/cleanroom/session");
const after = await call("/api/phase7/ui/message", {
sessionId: opened.sessionId,
message: "Read the issue and report progress.",
});
const serialized = JSON.stringify(after.view);
for (const pattern of [
/bearer\s+[a-z0-9._-]+/i,
// Anchored: the clean room's own `task-cleanroom-<token>` ids embed
// `sk-`, and an unanchored probe reads that as a provider key.
/(?<![A-Za-z0-9])sk-[a-z0-9]{8,}/i,
/"api[_-]?key"\s*:/i,
/PAPERCLIP_API_KEY/,
/OPENAI_API_KEY/,
]) {
expect(pattern.test(serialized), String(pattern)).toBe(false);
}
});
it("runs an allowed semantic call that mutates only the mock control plane", async () => {
const opened = await call("/api/phase7/ui/cleanroom/session");
const after = await call("/api/phase7/ui/message", {
sessionId: opened.sessionId,
message: "Read the issue and report progress.",
});
const rendered = items(after.view);
expect(rendered.some((item) => item.kind === "user_message")).toBe(true);
expect(rendered.some((item) => item.kind === "agent_message")).toBe(true);
const call0 = rendered.find((item) => item.kind === "tool_activity");
expect(call0?.kind).toBe("tool_activity");
if (call0?.kind === "tool_activity") expect(call0.operationId).toBe("report_progress");
const durable = rendered.find((item) => item.kind === "durable_comment");
expect(durable?.kind).toBe("durable_comment");
if (durable?.kind === "durable_comment") {
expect(durable.body).toBe("Read the blank clean-room issue and recorded a first status.");
}
expect(after.view.evidence.calls[0]?.outcome).toBe("ok");
expect(after.view.evidence.state.length).toBeGreaterThan(0);
});
it("returns a typed denial for an ungranted call and changes no mock state", async () => {
const opened = await call("/api/phase7/ui/cleanroom/session");
const before = await call("/api/phase7/ui/message", {
sessionId: opened.sessionId,
message: "Read the issue and report progress.",
});
const revisionBefore = before.view.evidence.state.at(-1)?.toRevision ?? 0;
const after = await call("/api/phase7/ui/message", {
sessionId: opened.sessionId,
message: "Now decide the approval yourself.",
});
const denial = items(after.view).find((item) => item.kind === "denial");
expect(denial?.kind).toBe("denial");
if (denial?.kind !== "denial") return;
expect(denial.operationId).toBe("decide_approval");
expect(denial.reason.length).toBeGreaterThan(0);
const record = after.view.evidence.calls.find((entry) => entry.operationId === "decide_approval");
expect(record?.outcome).toBe("denied");
expect(record?.dispatchedCommand).toBe("rejected before dispatch");
// The denial produced no state diff, so the last revision is unchanged.
expect(after.view.evidence.state.at(-1)?.toRevision ?? 0).toBe(revisionBefore);
expect(
after.view.evidence.authorization.some(
(entry) => entry.operationId === "decide_approval" && !entry.allowed,
),
).toBe(true);
});
it("keeps one durable session across turns, an inline answer, reconnect, and stop", async () => {
const opened = await call("/api/phase7/ui/cleanroom/session");
const asked = await call("/api/phase7/ui/message", {
sessionId: opened.sessionId,
message: "Ask me a question before you continue.",
});
const card = items(asked.view).find((item) => item.kind === "interaction");
expect(card?.kind).toBe("interaction");
if (card?.kind !== "interaction") return;
expect(card.state).toBe("pending");
expect(asked.view.composer.state).toBe("waiting");
const answered = await call("/api/phase7/ui/interaction", {
sessionId: opened.sessionId,
interactionId: card.interactionId,
outcome: "answered",
result: { path: "a" },
});
expect(answered.view.composer.state).toBe("ready");
const resolved = items(answered.view).find(
(item) => item.kind === "interaction" && item.interactionId === card.interactionId,
);
expect(resolved?.kind === "interaction" && resolved.state).toBe("answered");
// Refresh: the same session id resumes the same durable chat.
const refreshed = await call(
`/api/phase7/ui/cleanroom/session?sessionId=${opened.sessionId}`,
);
expect(refreshed.sessionId).toBe(opened.sessionId);
expect(refreshed.view.turns.length).toBe(answered.view.turns.length);
const reconnected = await call("/api/phase7/ui/reconnect", { sessionId: opened.sessionId });
expect(reconnected.sessionId).toBe(opened.sessionId);
expect(items(reconnected.view).some((item) => item.kind === "user_message")).toBe(true);
const hanging = call("/api/phase7/ui/message", {
sessionId: opened.sessionId,
message: "Now hang on this turn until I stop you.",
});
await new Promise((resolve) => setTimeout(resolve, 5));
await call("/api/phase7/ui/interrupt", { sessionId: opened.sessionId });
const stopped = await hanging;
expect(stopped.view.turns.at(-1)?.stoppedByUser).toBe(true);
// Retry after the stop stays in the same session rather than opening one.
const retried = await call("/api/phase7/ui/message", {
sessionId: opened.sessionId,
message: "Read the issue and report progress.",
});
expect(retried.sessionId).toBe(opened.sessionId);
});
it("reset and new chat both rotate identities and retire the prior authority", async () => {
const opened = await call("/api/phase7/ui/cleanroom/session");
await call("/api/phase7/ui/message", {
sessionId: opened.sessionId,
message: "Read the issue and report progress.",
});
const reset = await call("/api/phase7/ui/reset", { sessionId: opened.sessionId });
expect(reset.sessionId).not.toBe(opened.sessionId);
expect(reset.identity.companyId).not.toBe(opened.identity.companyId);
expect(reset.identity.actorId).not.toBe(opened.identity.actorId);
expect(reset.identity.taskId).not.toBe(opened.identity.taskId);
expect(reset.view.turns).toEqual([]);
expect(await status("/api/phase7/ui/message", {
sessionId: opened.sessionId,
message: "still there?",
})).toBe(404);
const fresh = await call("/api/phase7/ui/cleanroom/session", { sessionId: reset.sessionId });
expect(fresh.sessionId).not.toBe(reset.sessionId);
expect(fresh.identity.token).not.toBe(reset.identity.token);
expect(fresh.view.turns).toEqual([]);
expect(await status("/api/phase7/ui/message", {
sessionId: reset.sessionId,
message: "still there?",
})).toBe(404);
});
it("bounds turns per chat with a named reason instead of an opaque failure", async () => {
const opened = await call("/api/phase7/ui/cleanroom/session");
for (let turn = 0; turn < opened.limits.maxTurns; turn += 1) {
await call("/api/phase7/ui/message", { sessionId: opened.sessionId, message: `turn ${turn}` });
}
const response = await fetch(`${origin}/api/phase7/ui/message`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionId: opened.sessionId, message: "one more" }),
});
const body = (await response.json()) as { error: string; message: string };
expect(response.status).toBe(429);
expect(body.error).toBe("turn_limit");
expect(body.message).toContain("Start a new chat");
});
it("keeps the preset scenario surface working and isolated from the clean room", async () => {
const scenario = await call("/api/phase7/ui/session?scenario=hb-baseline");
const cleanRoom = await call("/api/phase7/ui/cleanroom/session");
expect(scenario.view.issue.identifier).toBe("MCK-31");
expect(scenario.view.issue.fixtureProfile).toBe("hb-baseline");
expect(cleanRoom.sessionId).not.toBe(scenario.sessionId);
expect(cleanRoom.view.issue.identifier).not.toBe("MCK-31");
await call("/api/phase7/ui/message", {
sessionId: cleanRoom.sessionId,
message: "Read the issue and report progress.",
});
const scenarioAgain = await call(
`/api/phase7/ui/session?sessionId=${scenario.sessionId}`,
);
expect(scenarioAgain.view.turns).toEqual([]);
});
});
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import { PHASE7_SEMANTIC_TOOL_CATALOG } from "../semantic-tools/catalog.js";
import {
createPhase7SemanticPolicyContext,
exposedPhase7SemanticDescriptors,
} from "../semantic-tools/policy.js";
import { Phase7MockControlPlaneAdapter } from "../mock-core/phase7-mock-control-plane-adapter.js";
import {
assertPhase7CleanRoomSeedIsBlank,
createPhase7CleanRoomIdentity,
createPhase7CleanRoomSeed,
createPhase7CleanRoomSessionInput,
PHASE7_CLEAN_ROOM_CLAIMS,
PHASE7_CLEAN_ROOM_SCENARIO,
PHASE7_CLEAN_ROOM_WITHHELD_CLAIMS,
} from "./clean-room.js";
async function exposedOperations(): Promise<Set<string>> {
const { identity, input } = createPhase7CleanRoomSessionInput({ workingDirectory: "/tmp/none" });
const port = new Phase7MockControlPlaneAdapter(input.seed);
await port.start();
const context = await port.openFixtureRun({
identity: {
runId: "run-clean-room",
sessionId: "session-clean-room",
companyId: identity.companyId,
issueId: identity.taskId,
agentId: identity.actorId,
},
backendKind: "runner",
sourceInstanceId: "phase7-clean-room-test",
capabilities: input.capabilities ?? [],
});
const policy = createPhase7SemanticPolicyContext(
context,
PHASE7_CLEAN_ROOM_SCENARIO,
input.explicitClaims ?? [],
);
const exposed = new Set(
exposedPhase7SemanticDescriptors(policy).map((descriptor) => descriptor.operationId),
);
await port.stop();
return exposed;
}
describe("Phase 7 clean-room seed", () => {
it("seeds only a company, an agent, and one blank work issue", () => {
const identity = createPhase7CleanRoomIdentity({ token: "abc12345", sequence: 4242 });
const state = createPhase7CleanRoomSeed(identity);
expect(state.company.id).toBe("company-cleanroom-abc12345");
expect(state.actors).toHaveLength(1);
expect(state.tasks).toHaveLength(1);
expect(state.tasks[0]?.identifier).toBe("MCK-4242");
expect(state.tasks[0]?.status).toBe("todo");
expect(state.tasks[0]?.description).toBeNull();
expect(state.comments).toEqual([]);
expect(state.documents).toEqual([]);
expect(state.interactions).toEqual([]);
expect(state.artifacts).toEqual([]);
expect(state.approvals).toEqual([]);
expect(state.blockers).toEqual([]);
expect(state.audit).toEqual([]);
expect(() => assertPhase7CleanRoomSeedIsBlank(state)).not.toThrow();
});
it("refuses a seed that carries a transcript or any other prior record", () => {
const identity = createPhase7CleanRoomIdentity({ token: "abc12345", sequence: 1 });
const seeded = createPhase7CleanRoomSeed(identity);
seeded.comments.push({
id: "comment-1",
taskId: identity.taskId,
authorActorId: identity.actorId,
body: "a scripted opening line",
createdAt: "2026-08-10T00:00:00.000Z",
});
expect(() => assertPhase7CleanRoomSeedIsBlank(seeded)).toThrow(/must be blank; seeded: comments/);
});
it("mints a distinct mock tenant on every open", () => {
const identities = Array.from({ length: 8 }, () => createPhase7CleanRoomIdentity());
expect(new Set(identities.map((identity) => identity.companyId)).size).toBe(8);
expect(new Set(identities.map((identity) => identity.actorId)).size).toBe(8);
expect(new Set(identities.map((identity) => identity.taskId)).size).toBe(8);
for (const identity of identities) {
expect(identity.identifier.startsWith("MCK-")).toBe(true);
}
});
});
describe("Phase 7 clean-room exposure profile", () => {
it("is deterministic and independent of the conversation", async () => {
expect(await exposedOperations()).toEqual(await exposedOperations());
});
it("exposes the read, comment, document, interaction, and delegation tools", async () => {
const exposed = await exposedOperations();
for (const operationId of [
"get_task_context",
"report_progress",
"write_document",
"request_human_input",
"register_deliverable",
"create_task",
"search_tasks",
"request_approval",
"finish_task",
]) {
expect(exposed, `expected ${operationId} to be exposed`).toContain(operationId);
}
});
it("withholds the grants that keep a denial reachable in an unscripted chat", async () => {
const exposed = await exposedOperations();
for (const operationId of [
"decide_approval",
"control_workspace_service",
"generic_api_request",
]) {
expect(exposed, `expected ${operationId} to be withheld`).not.toContain(operationId);
}
for (const claim of PHASE7_CLEAN_ROOM_WITHHELD_CLAIMS) {
expect(PHASE7_CLEAN_ROOM_CLAIMS).not.toContain(claim);
}
});
it("only names claims the accepted catalog actually requires", () => {
const catalogClaims = new Set(
PHASE7_SEMANTIC_TOOL_CATALOG.flatMap((descriptor) => descriptor.requiredClaims),
);
for (const claim of [...PHASE7_CLEAN_ROOM_CLAIMS, ...PHASE7_CLEAN_ROOM_WITHHELD_CLAIMS]) {
// `control_plane:*` claims are enforced at the mock command boundary
// rather than by a catalog descriptor, so they are exempt.
if (claim.startsWith("control_plane:")) continue;
expect(catalogClaims, `unknown claim ${claim}`).toContain(claim);
}
});
});
@@ -0,0 +1,226 @@
import { randomInt, randomUUID } from "node:crypto";
import {
createPhase7FixtureState,
type Phase7FixtureState,
} from "../mock-core/phase7-control-plane-types.js";
import type { Phase7SemanticScenarioPolicy } from "../semantic-tools/types.js";
import { phase7FixtureRunCapabilities } from "./fixture-run-capabilities.js";
import type { CreatePhase7LiveSessionInput } from "./live-session.js";
/**
* Clean-room chat seed and policy (Phase 7 plan revision 5, track 7M).
*
* The scenario explorer seeds a recorded eval case: a transcript, scripted tool
* calls, parity verdicts, and fixture evidence. The clean room deliberately
* seeds none of that. It creates the smallest mock tenant that can scope a
* runner session — one company, one agent, one blank work issue — and hands it
* to the same real runnerd + real Codex loop the scenario path uses. What the
* model then does is driven only by the free-form conversation.
*
* Every clean-room open mints fresh identities. That is the mechanism behind
* `New chat` and `Reset`: a new tenant is a new tenant, not a cleared view of
* the old one, so the prior session's authority can be retired without any
* chance of the next chat inheriting its records.
*/
export const PHASE7_CLEAN_ROOM_SCENARIO_ID = "phase7-clean-room" as const;
/**
* Model-visible grants. This is the deterministic, inspectable half of the
* clean room: the exposure profile is fixed, the calls are not.
*
* The three omissions are load-bearing rather than accidental:
* `governance:approvals:decide` (the agent proposes, the board decides),
* `workspace:control` (no service lifecycle from a chat), and
* `test:generic_api_request` (the escape hatch stays off). Each produces a
* typed denial with a named code if the model reaches for it, which is what
* makes "denied" demonstrable in a chat nobody scripted.
*/
export const PHASE7_CLEAN_ROOM_CLAIMS: readonly string[] = Object.freeze([
"control_plane:interactions",
"control_plane:wakes",
"delegation:tasks:create",
"dependencies:write",
"discovery:agents:read",
"discovery:tasks:read",
"governance:approvals:comment",
"governance:approvals:read",
"governance:approvals:request",
"workspace:read",
]);
/** Grants that stay withheld so a denial is always reachable. */
export const PHASE7_CLEAN_ROOM_WITHHELD_CLAIMS: readonly string[] = Object.freeze([
"governance:approvals:decide",
"test:generic_api_request",
"workspace:control",
]);
export const PHASE7_CLEAN_ROOM_SCENARIO: Phase7SemanticScenarioPolicy = Object.freeze({
id: PHASE7_CLEAN_ROOM_SCENARIO_ID,
claims: PHASE7_CLEAN_ROOM_CLAIMS,
allowedInteractionKinds: [
"questions",
"confirmation",
"checkbox",
"suggest_tasks",
"item_verdicts",
] as const,
enableGenericApiRequest: false,
});
export interface Phase7CleanRoomIdentity {
/** Short opaque token that separates one clean room from the next. */
token: string;
/** Mock issue number. Rendered as `MCK-<sequence>` in the header. */
sequence: number;
companyId: string;
actorId: string;
taskId: string;
identifier: string;
}
export interface Phase7CleanRoomIdentityInput {
token?: string;
sequence?: number;
}
export function createPhase7CleanRoomIdentity(
input: Phase7CleanRoomIdentityInput = {},
): Phase7CleanRoomIdentity {
const token = (input.token ?? randomUUID().replaceAll("-", "").slice(0, 8)).toLowerCase();
const sequence = input.sequence ?? randomInt(1_000, 10_000);
return {
token,
sequence,
companyId: `company-cleanroom-${token}`,
actorId: `actor-cleanroom-${token}`,
taskId: `task-cleanroom-${token}`,
identifier: `MCK-${sequence}`,
};
}
export interface Phase7CleanRoomSeedOptions {
epochMs?: number;
}
/**
* The minimum records a runner session needs to be scoped at all: a company to
* own it, an agent to act as, and one blank issue to act on.
*/
export function createPhase7CleanRoomSeed(
identity: Phase7CleanRoomIdentity,
options: Phase7CleanRoomSeedOptions = {},
): Phase7FixtureState {
return createPhase7FixtureState({
...(options.epochMs === undefined ? {} : { epochMs: options.epochMs }),
company: {
id: identity.companyId,
name: "Mock Paperclip (clean room)",
issuePrefix: "MCK",
status: "active",
budgetId: `budget-${identity.companyId}`,
},
actors: [
{
id: identity.actorId,
companyId: identity.companyId,
name: "Mock Agent",
role: "engineer",
status: "active",
budgetId: `budget-${identity.actorId}`,
capabilityGrants: [],
},
],
tasks: [
{
id: identity.taskId,
companyId: identity.companyId,
identifier: identity.identifier,
title: "Clean-room chat",
description: null,
status: "todo",
priority: "medium",
workMode: "standard",
parentId: null,
assigneeActorId: identity.actorId,
checkoutRunId: null,
executionRunId: null,
startedAt: null,
completedAt: null,
},
],
});
}
/** Record classes that a clean room must not start with. */
const SEEDED_RECORD_KEYS = [
"comments",
"documents",
"interactions",
"approvals",
"artifacts",
"workProducts",
"blockers",
"workspaceServices",
"wakes",
"faults",
] as const satisfies ReadonlyArray<keyof Phase7FixtureState>;
/**
* Fails closed if a clean room was handed anything to remember. A blank thread
* is an acceptance criterion, so it is checked at the seam that creates the
* session rather than trusted to stay true.
*/
export function assertPhase7CleanRoomSeedIsBlank(state: Phase7FixtureState): void {
const seeded = SEEDED_RECORD_KEYS.filter((key) => (state[key] as unknown[]).length > 0);
if (seeded.length > 0) {
throw new Error(`Phase 7 clean-room seed must be blank; seeded: ${seeded.join(", ")}`);
}
if (state.tasks.length !== 1 || state.actors.length !== 1) {
throw new Error("Phase 7 clean-room seed must hold exactly one actor and one task");
}
}
export interface Phase7CleanRoomSessionOptions {
workingDirectory: string;
identity?: Phase7CleanRoomIdentity;
epochMs?: number;
turnTimeoutMs?: number;
}
export interface Phase7CleanRoomSessionInput {
identity: Phase7CleanRoomIdentity;
input: CreatePhase7LiveSessionInput;
}
/**
* Builds the `Phase7LiveSessionService.create` input for one clean room. The
* run holds the adapter claims the mock command boundary needs, while the model
* only ever sees the scenario claims — the union widens the port, never the
* tool catalog.
*/
export function createPhase7CleanRoomSessionInput(
options: Phase7CleanRoomSessionOptions,
): Phase7CleanRoomSessionInput {
const identity = options.identity ?? createPhase7CleanRoomIdentity();
const seed = createPhase7CleanRoomSeed(identity, {
...(options.epochMs === undefined ? {} : { epochMs: options.epochMs }),
});
assertPhase7CleanRoomSeedIsBlank(seed);
return {
identity,
input: {
seed,
workingDirectory: options.workingDirectory,
scenario: PHASE7_CLEAN_ROOM_SCENARIO,
capabilities: phase7FixtureRunCapabilities(PHASE7_CLEAN_ROOM_CLAIMS),
explicitClaims: [...PHASE7_CLEAN_ROOM_CLAIMS],
companyId: identity.companyId,
actorId: identity.actorId,
taskId: identity.taskId,
...(options.turnTimeoutMs === undefined ? {} : { turnTimeoutMs: options.turnTimeoutMs }),
},
};
}
@@ -1,3 +1,4 @@
export * from "./browser-index.js";
export * from "./clean-room.js";
export * from "./live-session.js";
export * from "./runnerd-codex-transport.js";
@@ -18,6 +18,8 @@ export default defineConfig({
"src/**/*.test.ts",
"src/**/*.test.tsx",
"devtools/browser/src/**/*.test.ts",
"devtools/issue-thread/src/**/*.test.ts",
"devtools/issue-thread/src/**/*.test.tsx",
"examples/phase7-explorer/src/**/*.test.ts",
"examples/phase7-explorer/src/**/*.test.tsx",
"scripts/phase2-browser-server.test.mjs",