From cf27ba2869b1f1e2e46618c29fc8e8ef2261f1d4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 11 Aug 2026 11:17:28 +0000 Subject: [PATCH] fix(runner): recreate clean-room scratch roots Co-Authored-By: Paperclip --- .../devtools/issue-thread/index.html | 2 +- .../scripts/phase7-issue-thread-server.mjs | 19 ++++++++---- .../src/phase7/clean-room-server.test.ts | 30 +++++++++++++++++-- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/paperclip-runner/devtools/issue-thread/index.html b/packages/paperclip-runner/devtools/issue-thread/index.html index 8c344f1635e..ff5100b74d6 100644 --- a/packages/paperclip-runner/devtools/issue-thread/index.html +++ b/packages/paperclip-runner/devtools/issue-thread/index.html @@ -4,7 +4,7 @@ - 🫧 Mock Paperclip · Issue thread + 🧯 Mock Paperclip · Issue thread
diff --git a/packages/paperclip-runner/scripts/phase7-issue-thread-server.mjs b/packages/paperclip-runner/scripts/phase7-issue-thread-server.mjs index c48b1446dd3..ea581d9450a 100644 --- a/packages/paperclip-runner/scripts/phase7-issue-thread-server.mjs +++ b/packages/paperclip-runner/scripts/phase7-issue-thread-server.mjs @@ -1,5 +1,5 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; @@ -120,8 +120,12 @@ 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)); +async function createWorkingDirectory(root, prefix = "phase7-issue-thread-") { + // Managed previews can outlive the heartbeat that launched them. Paperclip + // removes that heartbeat's scratch directory when the run ends, so ensure + // the inherited parent still exists before every later session is minted. + await mkdir(root, { recursive: true }); + return mkdtemp(resolve(root, prefix)); } /** Mock-only fixture seed. Identifiers use the reserved `MCK-` prefix (§1.3). */ @@ -171,6 +175,7 @@ class RouteError extends Error { export function createPhase7IssueThreadMiddleware(options = {}) { const load = options.loadRunner ?? loadRunner; + const workingDirectoryRoot = options.scratchRoot ?? scratchRoot(); let bootstrap = null; let bindHost = options.bindHost ?? "127.0.0.1"; /** @@ -195,7 +200,8 @@ export function createPhase7IssueThreadMiddleware(options = {}) { bootstrap = (async () => { const runner = await load(); runner.assertPhase4bLoopbackBindHost(bindHost); - const workingDirectory = options.workingDirectory ?? (await createWorkingDirectory()); + const workingDirectory = + options.workingDirectory ?? (await createWorkingDirectory(workingDirectoryRoot)); const service = new runner.Phase7LiveSessionService({ store: new runner.InMemoryPhase7LiveSessionStore(), ...(options.transportFactory === undefined @@ -322,7 +328,10 @@ export function createPhase7IssueThreadMiddleware(options = {}) { 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 workingDirectory = await createWorkingDirectory( + workingDirectoryRoot, + "phase7-clean-room-", + ); const { identity, input } = runner.createPhase7CleanRoomSessionInput({ workingDirectory }); let session; try { diff --git a/packages/paperclip-runner/src/phase7/clean-room-server.test.ts b/packages/paperclip-runner/src/phase7/clean-room-server.test.ts index e4301021554..a00980da0b7 100644 --- a/packages/paperclip-runner/src/phase7/clean-room-server.test.ts +++ b/packages/paperclip-runner/src/phase7/clean-room-server.test.ts @@ -1,6 +1,9 @@ import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -468,13 +471,16 @@ let server: Server; let origin: string; let middleware: Awaited>; let providerState: FakeProviderState; +let scratchParent: string; +let scratchRoot: string; const requestedUrls: string[] = []; -async function createMiddleware(state: FakeProviderState) { +async function createMiddleware(state: FakeProviderState, root: string) { const module = await import("../../scripts/phase7-issue-thread-server.mjs"); return module.createPhase7IssueThreadMiddleware({ bindHost: "127.0.0.1", loadRunner: async () => await import("../index.js"), + scratchRoot: root, transportFactory: fakeTransportFactory(state), }); } @@ -520,6 +526,10 @@ async function status(path: string, body?: unknown, browser: Jar = jar): Promise beforeEach(async () => { requestedUrls.length = 0; + scratchParent = await mkdtemp(resolve(tmpdir(), "phase7-clean-room-server-test-")); + // Start with a missing child so the normal open path also proves that the + // middleware creates its configured scratch root. + scratchRoot = resolve(scratchParent, "run-scratch"); jar = new Jar(); providerState = { threadId: "codex-thread-clean-room", @@ -529,7 +539,7 @@ beforeEach(async () => { gate: new Gate(), diagnostic: null, }; - middleware = await createMiddleware(providerState); + middleware = await createMiddleware(providerState, scratchRoot); server = createServer((request, response) => { void middleware(request, response, () => { response.statusCode = 404; @@ -545,6 +555,7 @@ afterEach(async () => { await middleware.close(); server.close(); await once(server, "close"); + await rm(scratchParent, { recursive: true, force: true }); }); describe("Phase 7 clean-room chat server", () => { @@ -570,6 +581,21 @@ describe("Phase 7 clean-room chat server", () => { expect(opened.view.evidence.traceability).toEqual([]); }); + it("recreates a removed run scratch root before starting a later chat", async () => { + const opened = await call("/api/phase7/ui/cleanroom/session"); + + // Managed services survive the heartbeat that launched them, while the + // harness removes this run-owned parent as soon as that heartbeat exits. + await rm(scratchRoot, { recursive: true, force: true }); + + const fresh = await call("/api/phase7/ui/cleanroom/session", { + sessionId: opened.sessionId, + }); + expect(fresh.sessionId).not.toBe(opened.sessionId); + expect(fresh.identity.token).not.toBe(opened.identity.token); + expect(fresh.view.composer.state).toBe("ready"); + }); + 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) =>