fix(runner): recreate clean-room scratch roots

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-11 11:17:28 +00:00
1 parent 3165f057a3
commit cf27ba2869
3 files changed
+43 -8

No files matched your search

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>🫧 Mock Paperclip · Issue thread</title>
<title>🧯 Mock Paperclip · Issue thread</title>
</head>
<body>
<div id="root"></div>
@@ -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 {
@@ -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<ReturnType<typeof createMiddleware>>;
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) =>