diff --git a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts index a828a68c891..2ca967d38f9 100644 --- a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts +++ b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts @@ -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([]); + }); +}); diff --git a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx index d86f6f63ef7..1920d2ec515 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx @@ -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(null); + const [identity, setIdentity] = useState(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(null); + const [actionError, setActionError] = useState(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(["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>) => { + 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 ( -
-

- {error} -

-
+
+ +
+

+ {chat + ? `The clean-room chat could not start a real Codex session: ${error}` + : error} +

+ {chat ? ( +

+ The clean room only runs against real Codex through real runnerd, so it does not fall + back to a fixture or a recording. +

+ ) : null} + +
+
); } if (snapshot === null) { - return
; + return ( +
+ +
+

+ {chat + ? "Starting real runnerd and a real Codex session for a fresh mock tenant…" + : "Loading…"} +

+
+
+ ); } const showThread = layout !== "segment" || segment === "thread"; @@ -420,13 +543,19 @@ export function App() { return (
+ + + {actionError !== null ? ( +

+ + {actionError} + +

+ ) : null} + {snapshot.connection.state === "reconnecting" ? (

@@ -528,17 +671,36 @@ export function App() { }} >

- {snapshot.turns.map((turn) => ( - - ))} + {snapshot.turns.length === 0 ? ( +
+

Start a clean-room chat

+

+ This is a blank thread on a brand-new mock tenant:{" "} + {snapshot.issue.identifier} in{" "} + Mock Paperclip (clean room). Nothing has been said, called, or + recorded yet. +

+

+ 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. +

+
+ ) : ( + snapshot.turns.map((turn) => ( + + )) + )}
{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" >

- Reset scenario? + {chat ? "Reset this chat?" : "Reset scenario?"}

- 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."}

diff --git a/packages/paperclip-runner/devtools/issue-thread/src/Composer.tsx b/packages/paperclip-runner/devtools/issue-thread/src/Composer.tsx index 6a695930230..d4bbf815ce1 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/Composer.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/Composer.tsx @@ -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(null); @@ -130,7 +141,7 @@ export function Composer(props: ComposerProps) { ) : model.state === "disabled" ? ( ) : null} diff --git a/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx b/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx index d833baea047..397e246b668 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx @@ -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(null); const turnActive = snapshot.composer.state === "streaming" || snapshot.composer.state === "sending"; @@ -83,36 +92,54 @@ export function IssueHeader(props: IssueHeaderProps) { {snapshot.identity.controlPlaneLabel} + {chat && cleanRoomToken !== null ? ( + + Clean room {cleanRoomToken} + + ) : null}
{snapshot.issue.runState} - - + {chat ? ( + + ) : ( + <> + + - + + + )} + {chat ? null : ( + <> + + + + )}
) : null} diff --git a/packages/paperclip-runner/devtools/issue-thread/src/SurfaceNav.tsx b/packages/paperclip-runner/devtools/issue-thread/src/SurfaceNav.tsx new file mode 100644 index 00000000000..6a45440edea --- /dev/null +++ b/packages/paperclip-runner/devtools/issue-thread/src/SurfaceNav.tsx @@ -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 ( + + ); +} diff --git a/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css b/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css index 9cb7fb0eec5..b56151c85e1 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css +++ b/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css @@ -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); diff --git a/packages/paperclip-runner/devtools/issue-thread/src/live-client.ts b/packages/paperclip-runner/devtools/issue-thread/src/live-client.ts index 5ed363191f8..c9e72072412 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/live-client.ts +++ b/packages/paperclip-runner/devtools/issue-thread/src/live-client.ts @@ -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 { + 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 { @@ -22,9 +64,7 @@ async function post(path: string, body: unknown): Promise { 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 { 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 { return post("/session", { scenario }); }, + /** Reconnect to a clean room, or open one when there is nothing to resume. */ + async loadCleanRoom(sessionId: string | null): Promise { + 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 { + return post("/cleanroom/session", sessionId === null ? {} : { sessionId }); + }, send(sessionId: string, message: string): Promise { 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; } diff --git a/packages/paperclip-runner/devtools/issue-thread/src/route.test.ts b/packages/paperclip-runner/devtools/issue-thread/src/route.test.ts new file mode 100644 index 00000000000..4ddadb1a826 --- /dev/null +++ b/packages/paperclip-runner/devtools/issue-thread/src/route.test.ts @@ -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", + ); + }); +}); diff --git a/packages/paperclip-runner/devtools/issue-thread/src/route.ts b/packages/paperclip-runner/devtools/issue-thread/src/route.ts index 8c9431bf127..83b442cd5ba 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/route.ts +++ b/packages/paperclip-runner/devtools/issue-thread/src/route.ts @@ -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/?shot=&panel=
&rec=&at=&seg=thread|evidence` + * `#/chat?panel=
&rec=&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): 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); diff --git a/packages/paperclip-runner/docs/architecture.md b/packages/paperclip-runner/docs/architecture.md index 5200d1e3e3c..871b951793b 100644 --- a/packages/paperclip-runner/docs/architecture.md +++ b/packages/paperclip-runner/docs/architecture.md @@ -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 diff --git a/packages/paperclip-runner/docs/index.md b/packages/paperclip-runner/docs/index.md index c19ed8d5751..74f76442547 100644 --- a/packages/paperclip-runner/docs/index.md +++ b/packages/paperclip-runner/docs/index.md @@ -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) diff --git a/packages/paperclip-runner/docs/phase-07-clean-room-chat.md b/packages/paperclip-runner/docs/phase-07-clean-room-chat.md new file mode 100644 index 00000000000..62c91600ce3 --- /dev/null +++ b/packages/paperclip-runner/docs/phase-07-clean-room-chat.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/` | `#/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-` 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. diff --git a/packages/paperclip-runner/docs/phase-07-execution-modes.md b/packages/paperclip-runner/docs/phase-07-execution-modes.md index 47281480b93..c0b2d6d6e3f 100644 --- a/packages/paperclip-runner/docs/phase-07-execution-modes.md +++ b/packages/paperclip-runner/docs/phase-07-execution-modes.md @@ -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 | diff --git a/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md b/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md index c1a9497ae2c..9753ed6d906 100644 --- a/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md +++ b/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md @@ -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 diff --git a/packages/paperclip-runner/docs/phase-07-verification-commands.md b/packages/paperclip-runner/docs/phase-07-verification-commands.md index b86c0396190..46cf02e46b9 100644 --- a/packages/paperclip-runner/docs/phase-07-verification-commands.md +++ b/packages/paperclip-runner/docs/phase-07-verification-commands.md @@ -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) diff --git a/packages/paperclip-runner/docs/tutorials/phase-07-clean-room-chat.md b/packages/paperclip-runner/docs/tutorials/phase-07-clean-room-chat.md new file mode 100644 index 00000000000..858b884cb90 --- /dev/null +++ b/packages/paperclip-runner/docs/tutorials/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 ` 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. diff --git a/packages/paperclip-runner/knowledge/evidence/2026-08-10-phase-07m-clean-room-verification.md b/packages/paperclip-runner/knowledge/evidence/2026-08-10-phase-07m-clean-room-verification.md new file mode 100644 index 00000000000..f67ef6ecf47 --- /dev/null +++ b/packages/paperclip-runner/knowledge/evidence/2026-08-10-phase-07m-clean-room-verification.md @@ -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-` 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-` 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 `(? 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(); diff --git a/packages/paperclip-runner/scripts/phase7-issue-thread-server.mjs b/packages/paperclip-runner/scripts/phase7-issue-thread-server.mjs index fd4caf4909e..4bb454a5cb6 100644 --- a/packages/paperclip-runner/scripts/phase7-issue-thread-server.mjs +++ b/packages/paperclip-runner/scripts/phase7-issue-thread-server.mjs @@ -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} */ + /** + * @type {Map} + */ 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; diff --git a/packages/paperclip-runner/scripts/phase7-issue-thread-smoke.mjs b/packages/paperclip-runner/scripts/phase7-issue-thread-smoke.mjs index ccdb6426094..90d653360bc 100644 --- a/packages/paperclip-runner/scripts/phase7-issue-thread-smoke.mjs +++ b/packages/paperclip-runner/scripts/phase7-issue-thread-smoke.mjs @@ -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. + /(? 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(); diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index f15e416e6e4..47251c18386 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -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"; diff --git a/packages/paperclip-runner/src/issue-thread/issue-thread.test.ts b/packages/paperclip-runner/src/issue-thread/issue-thread.test.ts index 538fa0b8640..f3bce91c159 100644 --- a/packages/paperclip-runner/src/issue-thread/issue-thread.test.ts +++ b/packages/paperclip-runner/src/issue-thread/issue-thread.test.ts @@ -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. + /(? ({ + 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", diff --git a/packages/paperclip-runner/src/phase7/clean-room-server.test.ts b/packages/paperclip-runner/src/phase7/clean-room-server.test.ts new file mode 100644 index 00000000000..35e774f73ca --- /dev/null +++ b/packages/paperclip-runner/src/phase7/clean-room-server.test.ts @@ -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 { + #values: CodexRpcNotification[] = []; + #waiters: Array<(value: IteratorResult) => 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 { + 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): Promise> { + 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) : {}; + 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 { + return this.queue; + } + + setServerRequestHandler(handler: CodexServerRequestHandler): void { + this.#handler = handler; + } + + async close(): Promise { + 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 { + 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): Promise { + 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>; + 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>; +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 { + 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 { + 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-` ids embed + // `sk-`, and an unanchored probe reads that as a provider key. + /(? { + 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([]); + }); +}); diff --git a/packages/paperclip-runner/src/phase7/clean-room.test.ts b/packages/paperclip-runner/src/phase7/clean-room.test.ts new file mode 100644 index 00000000000..85311e66175 --- /dev/null +++ b/packages/paperclip-runner/src/phase7/clean-room.test.ts @@ -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> { + 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); + } + }); +}); diff --git a/packages/paperclip-runner/src/phase7/clean-room.ts b/packages/paperclip-runner/src/phase7/clean-room.ts new file mode 100644 index 00000000000..273c25858f1 --- /dev/null +++ b/packages/paperclip-runner/src/phase7/clean-room.ts @@ -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-` 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; + +/** + * 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 }), + }, + }; +} diff --git a/packages/paperclip-runner/src/phase7/index.ts b/packages/paperclip-runner/src/phase7/index.ts index 6eadfa0206f..6529ba01494 100644 --- a/packages/paperclip-runner/src/phase7/index.ts +++ b/packages/paperclip-runner/src/phase7/index.ts @@ -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"; diff --git a/packages/paperclip-runner/vitest.config.ts b/packages/paperclip-runner/vitest.config.ts index c102a0029d3..ea305f4cec9 100644 --- a/packages/paperclip-runner/vitest.config.ts +++ b/packages/paperclip-runner/vitest.config.ts @@ -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",