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) =>