diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index f5eb29f0bfb..4fc32ec975d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -1,4 +1,11 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -18,6 +25,10 @@ import { type AcpxRuntimePortOpenOptions, type AcpxRuntimeTurn, } from "./runtime-host.js"; +import { + prepareAcpxRuntimeSandbox, + type AcpxRuntimeSandbox, +} from "./runtime-sandbox.js"; const temporaryDirectories: string[] = []; @@ -29,6 +40,18 @@ afterEach(async () => { ); }); +// Every wait below follows real sandbox preparation: private directories and +// files created with an fsync after each write (see runtime-sandbox.ts). A +// loaded CI runner can push that past the 1000ms vi.waitFor default, so every +// wait in this file goes through one named, bounded deadline instead. +const ACPX_ADMISSION_WAIT_TIMEOUT_MS = 10_000; + +async function waitForAcpxAdmission( + check: () => T | Promise, +): Promise { + return await vi.waitFor(check, { timeout: ACPX_ADMISSION_WAIT_TIMEOUT_MS }); +} + describe("ACPX runtime host", () => { it("rejects a pre-aborted admission before acquiring provider resources", async () => { const fixture = await hostFixture(); @@ -94,7 +117,7 @@ describe("ACPX runtime host", () => { expect(createRuntime).not.toHaveBeenCalled(); expect(fixture.commandClose).toHaveBeenCalledOnce(); const authPath = join(credentialHome, "auth.json"); - const contender = await vi.waitFor(() => + const contender = await waitForAcpxAdmission(() => stageManagedCodexCredential({ agentHomeDirectory: credentialHome, environment: { @@ -458,7 +481,9 @@ describe("ACPX runtime host", () => { ), ).rejects.toThrow(/initialization and cleanup failed/); - await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(2)); + await waitForAcpxAdmission(() => + expect(runtime.close).toHaveBeenCalledTimes(2), + ); await expect(readFile(authPath, "utf8")).resolves.toContain( "failed-admission", ); @@ -472,7 +497,7 @@ describe("ACPX runtime host", () => { ).rejects.toThrow("already has an active lease"); resolveRetryClose(); - await vi.waitFor(async () => { + await waitForAcpxAdmission(async () => { await expect(readFile(authPath)).rejects.toMatchObject({ code: "ENOENT", }); @@ -631,8 +656,12 @@ describe("ACPX runtime host", () => { const authPath = join(credentialHome, "auth.json"); const first = host.close({ reason: "runtime close pending" }); - await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledOnce()); - await vi.waitFor(() => expect(fixture.commandClose).toHaveBeenCalledOnce()); + await waitForAcpxAdmission(() => + expect(runtime.close).toHaveBeenCalledOnce(), + ); + await waitForAcpxAdmission(() => + expect(fixture.commandClose).toHaveBeenCalledOnce(), + ); const second = host.close({ reason: "same exact close" }); await expect(readFile(authPath, "utf8")).resolves.toBe("{}"); await expect( @@ -681,7 +710,9 @@ describe("ACPX runtime host", () => { ); const first = host.close({ reason: "first close stalls" }); - await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledOnce()); + await waitForAcpxAdmission(() => + expect(runtime.close).toHaveBeenCalledOnce(), + ); const second = host.close({ reason: "same pending owner" }); let settled = false; void Promise.all([first, second]).finally(() => { @@ -944,7 +975,9 @@ describe("ACPX runtime host", () => { reportRetainedCleanupFailure: vi.fn(), }, ); - await vi.waitFor(() => expect(openCommand).toHaveBeenCalledOnce()); + await waitForAcpxAdmission(() => + expect(openCommand).toHaveBeenCalledOnce(), + ); controller.abort(cancellation); await expect(opening).rejects.toBe(cancellation); @@ -955,7 +988,9 @@ describe("ACPX runtime host", () => { close: lateCommandClose, }); - await vi.waitFor(() => expect(lateCommandClose).toHaveBeenCalledOnce()); + await waitForAcpxAdmission(() => + expect(lateCommandClose).toHaveBeenCalledOnce(), + ); expect(openRuntime).not.toHaveBeenCalled(); }); @@ -998,7 +1033,9 @@ describe("ACPX runtime host", () => { stageCredential, }, ); - await vi.waitFor(() => expect(stageCredential).toHaveBeenCalledOnce()); + await waitForAcpxAdmission(() => + expect(stageCredential).toHaveBeenCalledOnce(), + ); controller.abort(cancellation); await expect(opening).rejects.toBe(cancellation); @@ -1010,10 +1047,10 @@ describe("ACPX runtime host", () => { close: lateCredentialClose, }); - await vi.waitFor(() => + await waitForAcpxAdmission(() => expect(lateCredentialClose).toHaveBeenCalledTimes(2), ); - await vi.waitFor(async () => + await waitForAcpxAdmission(async () => expect(readFile(lateCredentialPath)).rejects.toMatchObject({ code: "ENOENT", }), @@ -1066,7 +1103,9 @@ describe("ACPX runtime host", () => { }, fixture.dependencies({ openRuntime }), ); - await vi.waitFor(() => expect(openRuntime).toHaveBeenCalledOnce()); + await waitForAcpxAdmission(() => + expect(openRuntime).toHaveBeenCalledOnce(), + ); expect(receivedSignal).toBe(controller.signal); controller.abort(cancellation); @@ -1085,7 +1124,9 @@ describe("ACPX runtime host", () => { ).rejects.toThrow("already has an active lease"); runtimeAdmission.resolve(lateRuntime); - await vi.waitFor(() => expect(lateRuntime.close).toHaveBeenCalledTimes(2)); + await waitForAcpxAdmission(() => + expect(lateRuntime.close).toHaveBeenCalledTimes(2), + ); expect(lateRuntime.close).toHaveBeenNthCalledWith(1, { reason: "ACPX runtime admission aborted", }); @@ -1100,14 +1141,14 @@ describe("ACPX runtime host", () => { ).rejects.toThrow("already has an active lease"); retryClose.resolve(undefined); - await vi.waitFor(async () => { + await waitForAcpxAdmission(async () => { await expect(readFile(authPath)).rejects.toMatchObject({ code: "ENOENT", }); }); // File removal precedes kernel lease release. Wait for the lease itself so // this assertion cannot race between those two ordered cleanup steps. - const contender = await vi.waitFor(() => + const contender = await waitForAcpxAdmission(() => stageManagedCodexCredential({ agentHomeDirectory: credentialHome, environment: { @@ -1148,7 +1189,9 @@ describe("ACPX runtime host", () => { }), }, ); - await vi.waitFor(() => expect(openRuntime).toHaveBeenCalledOnce()); + await waitForAcpxAdmission(() => + expect(openRuntime).toHaveBeenCalledOnce(), + ); controller.abort(cancellation); await expect(opening).rejects.toBe(cancellation); @@ -1160,7 +1203,192 @@ describe("ACPX runtime host", () => { expect(fixture.commandClose).toHaveBeenCalledOnce(); providerCleanup.resolve(undefined); - await vi.waitFor(() => expect(credentialClose).toHaveBeenCalledOnce()); + await waitForAcpxAdmission(() => + expect(credentialClose).toHaveBeenCalledOnce(), + ); + }); + + it("removes an abandoned sandbox root once its delayed preparation settles after abort", async () => { + const fixture = await hostFixture(); + const rootGate = deferred(); + const prepareSandbox: NonNullable< + AcpxRuntimeHostDependencies["prepareSandbox"] + > = vi.fn((input) => + prepareAcpxRuntimeSandbox(input, { + afterRootOwned: () => rootGate.promise, + }), + ); + const openRuntime = vi.fn(async () => runtimePort()); + const controller = new AbortController(); + const cancellation = new Error("sandbox admission cancelled"); + + const unhandled: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandledRejection); + try { + const opening = AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "claude", + model: "claude-sonnet-5", + permissionMode: "deny-all", + signal: controller.signal, + }, + { + ...fixture.dependencies({ openRuntime }), + prepareSandbox, + }, + ); + await waitForAcpxAdmission(() => + expect(prepareSandbox).toHaveBeenCalledOnce(), + ); + + controller.abort(cancellation); + await expect(opening).rejects.toBe(cancellation); + expect(openRuntime).not.toHaveBeenCalled(); + + rootGate.resolve(); + const sandbox: AcpxRuntimeSandbox = + await prepareSandbox.mock.results[0]!.value; + await waitForAcpxAdmission(() => + expect(stat(sandbox.root)).rejects.toMatchObject({ code: "ENOENT" }), + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); + + it("keeps a later admission's live root when an earlier aborted admission's cleanup settles late", async () => { + const fixture = await hostFixture(); + const rootGate = deferred(); + const prepareSandbox: NonNullable< + AcpxRuntimeHostDependencies["prepareSandbox"] + > = vi.fn((input) => + prepareAcpxRuntimeSandbox(input, { + afterRootOwned: () => rootGate.promise, + }), + ); + const firstOpenRuntime = vi.fn(async () => runtimePort()); + const controller = new AbortController(); + const cancellation = new Error("first admission cancelled"); + + const opening = AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "codex", + model: "gpt-5.6-sol", + permissionMode: "deny-all", + environment: { + PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: '{"owner":"first"}', + }, + signal: controller.signal, + }, + { + ...fixture.dependencies({ openRuntime: firstOpenRuntime }), + prepareSandbox, + }, + ); + await waitForAcpxAdmission(() => + expect(prepareSandbox).toHaveBeenCalledOnce(), + ); + + controller.abort(cancellation); + await expect(opening).rejects.toBe(cancellation); + expect(firstOpenRuntime).not.toHaveBeenCalled(); + + // A second, independent admission for the same normalized session id + // resolves to the same deterministic root while the first admission's + // cleanup is still pending behind the gate. + const secondRuntime = runtimePort(); + const secondHost = await AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "codex", + model: "gpt-5.6-sol", + permissionMode: "deny-all", + environment: { + PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: '{"owner":"second"}', + }, + }, + fixture.dependencies({ openRuntime: async () => secondRuntime }), + ); + const authPath = join(secondHost.runtimeRoot(), "codex-home", "auth.json"); + await expect(readFile(authPath, "utf8")).resolves.toContain("second"); + + // Let the first admission's delayed sandbox preparation finish and its + // retained cleanup run. A superseded owner must leave the live root + // alone. + rootGate.resolve(); + await prepareSandbox.mock.results[0]!.value; + await new Promise((resolve) => setTimeout(resolve, 50)); + await expect(readFile(authPath, "utf8")).resolves.toContain("second"); + + await secondHost.close({ reason: "test complete" }); + }); + + it("removes a codex credential staged after an aborted admission's sandbox preparation resolves", async () => { + const fixture = await hostFixture(); + const rootGate = deferred(); + const prepareSandbox: NonNullable< + AcpxRuntimeHostDependencies["prepareSandbox"] + > = vi.fn(async (input) => { + const sandbox = await prepareAcpxRuntimeSandbox(input, { + afterRootOwned: () => rootGate.promise, + }); + // Stage a real Codex credential into the sandbox before it is handed + // back, so the root that gets removed later is not empty: it must + // sweep a credential staged after the sandbox itself was built too. + await stageManagedCodexCredential({ + agentHomeDirectory: sandbox.agentHomeDirectory, + environment: { + PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: '{"staged":"after-abort"}', + }, + }); + return sandbox; + }); + const openRuntime = vi.fn(async () => runtimePort()); + const controller = new AbortController(); + const cancellation = new Error( + "sandbox admission cancelled before credential staging", + ); + + const opening = AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "codex", + model: "gpt-5.6-sol", + permissionMode: "deny-all", + environment: { PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: "{}" }, + signal: controller.signal, + }, + { + ...fixture.dependencies({ openRuntime }), + prepareSandbox, + }, + ); + await waitForAcpxAdmission(() => + expect(prepareSandbox).toHaveBeenCalledOnce(), + ); + + controller.abort(cancellation); + await expect(opening).rejects.toBe(cancellation); + expect(openRuntime).not.toHaveBeenCalled(); + + rootGate.resolve(); + const sandbox: AcpxRuntimeSandbox = + await prepareSandbox.mock.results[0]!.value; + const authPath = join(sandbox.agentHomeDirectory, "auth.json"); + await expect(readFile(authPath, "utf8")).resolves.toContain("staged"); + + await waitForAcpxAdmission(() => + expect(readFile(authPath)).rejects.toMatchObject({ code: "ENOENT" }), + ); + await expect(stat(sandbox.root)).rejects.toMatchObject({ code: "ENOENT" }); }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index c568b613b11..de7ce7470c8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -39,6 +39,7 @@ import { } from "./recovery-identity.js"; import { prepareAcpxRuntimeSandbox, + removeOwnedAcpxRuntimeSandboxRoot, type AcpxRuntimeSandbox, } from "./runtime-sandbox.js"; import type { AcpxExpectedSessionIdentity } from "./sidecar-protocol.js"; @@ -130,7 +131,8 @@ export interface AcpxRetainedCleanupFailure { | "provider_lifetime" | "command" | "runtime" - | "tool_bridge"; + | "tool_bridge" + | "sandbox"; attempt: number; error: unknown; } @@ -141,6 +143,8 @@ export interface AcpxRuntimeHostDependencies { ) => Promise; /** Internal test seam for aborting credential acquisition. */ stageCredential?: typeof stageManagedCodexCredential; + /** Internal test seam for aborting sandbox preparation. */ + prepareSandbox?: typeof prepareAcpxRuntimeSandbox; openRuntime(options: AcpxRuntimePortOpenOptions): Promise; /** Internal test seam for the post-handshake admission deadline. */ admissionVerificationTimeoutMs?: number; @@ -324,13 +328,20 @@ export class AcpxRuntimeHost { retainRuntimeHostCleanup(ownedCleanup); }; try { - const sandbox = await runAbortableAdmissionStage(options.signal, () => - prepareAcpxRuntimeSandbox({ - binding, - agent: options.agent, - environment: options.environment, - }), - ); + const sandbox = await acquireAbortableAdmissionResource({ + signal: options.signal, + acquire: () => + (dependencies.prepareSandbox ?? prepareAcpxRuntimeSandbox)({ + binding, + agent: options.agent, + environment: options.environment, + }), + resource: "sandbox", + releaseLate: (lateSandbox) => + removeOwnedAcpxRuntimeSandboxRoot(lateSandbox), + reportFailure: (failure) => + dependencies.reportRetainedCleanupFailure(failure), + }); if (options.agent === "codex") { credential = await acquireAbortableAdmissionResource({ signal: options.signal, diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index 9b97e54ba27..fd98c211dae 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -128,6 +128,26 @@ describe("ACPX runtime sandbox", () => { ); }); + it("removes a partially built root when preparation fails after claiming it", async () => { + const fixture = await sandboxFixture("codex"); + const failure = new Error("simulated sandbox preparation failure"); + + await expect( + prepareAcpxRuntimeSandbox( + { binding: fixture.binding, agent: "codex" }, + { + afterRootOwned: async () => { + throw failure; + }, + }, + ), + ).rejects.toBe(failure); + + await expect(stat(fixture.binding.runtimeRoot)).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + it.runIf(process.platform !== "win32")( "repairs existing directory permissions through its no-follow handle", async () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 384f58544c9..11f9af27c05 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -14,6 +14,7 @@ import { readFile, realpath, rename, + rm, stat, unlink, type FileHandle, @@ -39,6 +40,31 @@ const PRIVATE_FILE_MODE = 0o600; const MAX_SANDBOX_ENVIRONMENT_BYTES = 512 * 1024; const MAX_WORKSPACE_RECORD_BYTES = 64 * 1024; +interface AcpxRuntimeSandboxRootOwner { + readonly token: symbol; + readonly dev: bigint; + readonly ino: bigint; +} + +// The runtime root path is deterministic per normalized session id, so a +// path-only delete cannot prove a root still belongs to the admission that +// created it. Each successful claim registers the current owner here, +// keyed by root path. A later claim for the same path (a legitimate +// re-opened session) replaces the entry. Cleanup for a stale owner checks +// this registry and the captured directory identity before it deletes +// anything, so a superseded owner's cleanup leaves a live root alone. +const acpxRuntimeSandboxRootOwners = new Map< + string, + AcpxRuntimeSandboxRootOwner +>(); + +// Associates each returned sandbox object with the exact owner it was built +// under, without exposing the ownership token on the public sandbox shape. +const acpxRuntimeSandboxRootOwnerByResult = new WeakMap< + AcpxRuntimeSandbox, + AcpxRuntimeSandboxRootOwner & { readonly root: string } +>(); + export interface AcpxRuntimeSandbox { root: string; stateDirectory: string; @@ -200,7 +226,8 @@ export async function readAcpxRecoveryWorkspace( const lease: AcpxRecoveryWorkspaceLease = { path: physicalWorkspace, assertHeld() { - if (closed) throw new Error("ACPX recovery workspace lease is closed"); + if (closed) + throw new Error("ACPX recovery workspace lease is closed"); assertPinnedDirectory( physicalNamespace, physicalNamespace, @@ -239,11 +266,7 @@ export async function readAcpxRecoveryWorkspace( await recordHandle.close(); } } finally { - await closeRecoveryHandles([ - workspaceHandle, - rootHandle, - namespaceHandle, - ]); + await closeRecoveryHandles([workspaceHandle, rootHandle, namespaceHandle]); } } @@ -310,8 +333,7 @@ async function closeRecoveryHandles( ); const errors = results .filter( - (result): result is PromiseRejectedResult => - result.status === "rejected", + (result): result is PromiseRejectedResult => result.status === "rejected", ) .map((result) => result.reason); if (errors.length > 0) { @@ -319,12 +341,23 @@ async function closeRecoveryHandles( } } +export interface AcpxRuntimeSandboxPrepareDependencies { + /** + * Internal test seam. Runs once the runtime root is claimed and owned, + * before the rest of the sandbox is built. + */ + afterRootOwned?: () => Promise; +} + /** Prepare the private filesystem and environment visible to an ACPX agent. */ -export async function prepareAcpxRuntimeSandbox(input: { - binding: AcpxRecoveryBinding; - agent: QualifiedAcpxAgent; - environment?: NodeJS.ProcessEnv; -}): Promise { +export async function prepareAcpxRuntimeSandbox( + input: { + binding: AcpxRecoveryBinding; + agent: QualifiedAcpxAgent; + environment?: NodeJS.ProcessEnv; + }, + dependencies: AcpxRuntimeSandboxPrepareDependencies = {}, +): Promise { const expectedRoot = input.binding.runtimeRoot; if (resolve(expectedRoot) !== expectedRoot) { throw new Error("ACPX runtime root must be an absolute normalized path"); @@ -343,97 +376,198 @@ export async function prepareAcpxRuntimeSandbox(input: { expectedRoot, physicalAcpxDirectory, ); - const stateDirectory = await ensurePrivateDirectory( - join(root, "acpx-state"), - root, - ); - const homeDirectory = await ensurePrivateDirectory(join(root, "home"), root); - const configDirectory = await ensurePrivateDirectory( - join(root, "config"), - root, - ); - const dataDirectory = await ensurePrivateDirectory(join(root, "data"), root); - const cacheDirectory = await ensurePrivateDirectory( - join(root, "cache"), - root, - ); - const agentHomeDirectory = await ensurePrivateDirectory( - join(root, `${input.agent}-home`), - root, - ); - const workspaceRecordPath = join(root, "workspace"); - await writePrivateFile( - workspaceRecordPath, - `${input.binding.workspacePath}\n`, - ); - if (input.agent === "pi") { - await writePrivateFile( - join(agentHomeDirectory, "settings.json"), - `${JSON.stringify({ - quietStartup: true, - defaultProjectTrust: "never", - enableInstallTelemetry: false, - })}\n`, + // Claim ownership as soon as the root exists, before any slower step (a + // credential write, a later directory sync) can run. A later admission for + // the same deterministic path claims this same record and supersedes it; + // an aborted admission's own late cleanup then finds it is no longer the + // registered owner and leaves the live root alone. + const owner = await claimAcpxRuntimeSandboxRoot(root); + try { + await dependencies.afterRootOwned?.(); + const stateDirectory = await ensurePrivateDirectory( + join(root, "acpx-state"), + root, ); - } + const homeDirectory = await ensurePrivateDirectory( + join(root, "home"), + root, + ); + const configDirectory = await ensurePrivateDirectory( + join(root, "config"), + root, + ); + const dataDirectory = await ensurePrivateDirectory( + join(root, "data"), + root, + ); + const cacheDirectory = await ensurePrivateDirectory( + join(root, "cache"), + root, + ); + const agentHomeDirectory = await ensurePrivateDirectory( + join(root, `${input.agent}-home`), + root, + ); + const workspaceRecordPath = join(root, "workspace"); + await writePrivateFile( + workspaceRecordPath, + `${input.binding.workspacePath}\n`, + ); + if (input.agent === "pi") { + await writePrivateFile( + join(agentHomeDirectory, "settings.json"), + `${JSON.stringify({ + quietStartup: true, + defaultProjectTrust: "never", + enableInstallTelemetry: false, + })}\n`, + ); + } - const sanitizedSpawnInput = createSanitizedAcpxSpawnInput( - input.environment, - input.agent, - ); - // The sanitizer deliberately returns an opaque, frozen launch boundary. - // Build the sandbox-owned mutable copy only from that projected environment - // before adding paths that were created and validated above. - const launchEnvironment: NodeJS.ProcessEnv = { - ...sanitizedSpawnInput.env, - }; - Object.assign(launchEnvironment, { - HOME: homeDirectory, - XDG_CONFIG_HOME: configDirectory, - XDG_DATA_HOME: dataDirectory, - XDG_CACHE_HOME: cacheDirectory, - PAPERCLIP_ACPX_PROFILE: input.agent, - PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", - ...(input.agent === "pi" - ? { - PI_CODING_AGENT_DIR: agentHomeDirectory, - PI_SKIP_VERSION_CHECK: "1", - PI_TELEMETRY: "0", - } - : {}), - ...(input.agent === "claude" - ? { CLAUDE_CONFIG_DIR: agentHomeDirectory } - : {}), - ...(input.agent === "codex" - ? { - CODEX_HOME: agentHomeDirectory, - NO_BROWSER: "1", - ...(launchEnvironment.CODEX_API_KEY || - launchEnvironment.OPENAI_API_KEY - ? { DEFAULT_AUTH_REQUEST: JSON.stringify({ methodId: "api-key" }) } - : {}), - } - : {}), - }); - validateEnvironmentSize(launchEnvironment); - const persistedEnvironment = Object.fromEntries( - Object.entries(launchEnvironment).filter( - ([name, value]) => - typeof value === "string" && isPersistableEnvironmentName(name), - ), - ); - return { - root, - stateDirectory, - homeDirectory, - configDirectory, - dataDirectory, - cacheDirectory, - agentHomeDirectory, - workspaceRecordPath, - launchEnvironment: Object.freeze({ ...launchEnvironment }), - persistedEnvironment: Object.freeze(persistedEnvironment), + const sanitizedSpawnInput = createSanitizedAcpxSpawnInput( + input.environment, + input.agent, + ); + // The sanitizer deliberately returns an opaque, frozen launch boundary. + // Build the sandbox-owned mutable copy only from that projected + // environment before adding paths that were created and validated above. + const launchEnvironment: NodeJS.ProcessEnv = { + ...sanitizedSpawnInput.env, + }; + Object.assign(launchEnvironment, { + HOME: homeDirectory, + XDG_CONFIG_HOME: configDirectory, + XDG_DATA_HOME: dataDirectory, + XDG_CACHE_HOME: cacheDirectory, + PAPERCLIP_ACPX_PROFILE: input.agent, + PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", + ...(input.agent === "pi" + ? { + PI_CODING_AGENT_DIR: agentHomeDirectory, + PI_SKIP_VERSION_CHECK: "1", + PI_TELEMETRY: "0", + } + : {}), + ...(input.agent === "claude" + ? { CLAUDE_CONFIG_DIR: agentHomeDirectory } + : {}), + ...(input.agent === "codex" + ? { + CODEX_HOME: agentHomeDirectory, + NO_BROWSER: "1", + ...(launchEnvironment.CODEX_API_KEY || + launchEnvironment.OPENAI_API_KEY + ? { + DEFAULT_AUTH_REQUEST: JSON.stringify({ + methodId: "api-key", + }), + } + : {}), + } + : {}), + }); + validateEnvironmentSize(launchEnvironment); + const persistedEnvironment = Object.fromEntries( + Object.entries(launchEnvironment).filter( + ([name, value]) => + typeof value === "string" && isPersistableEnvironmentName(name), + ), + ); + const sandbox: AcpxRuntimeSandbox = { + root, + stateDirectory, + homeDirectory, + configDirectory, + dataDirectory, + cacheDirectory, + agentHomeDirectory, + workspaceRecordPath, + launchEnvironment: Object.freeze({ ...launchEnvironment }), + persistedEnvironment: Object.freeze(persistedEnvironment), + }; + acpxRuntimeSandboxRootOwnerByResult.set(sandbox, { ...owner, root }); + return sandbox; + } catch (error) { + let cleanupError: unknown = null; + try { + await revalidateAndRemoveOwnedAcpxRuntimeSandboxRoot(root, owner); + } catch (thrown) { + cleanupError = thrown; + } + if (cleanupError !== null) { + throw new AggregateError( + [error, cleanupError], + "ACPX sandbox preparation failed and its partial root could not be removed", + ); + } + throw error; + } +} + +async function claimAcpxRuntimeSandboxRoot( + root: string, +): Promise { + const entry = await lstat(root, { bigint: true }); + if (entry.isSymbolicLink() || !entry.isDirectory()) { + throw new Error("ACPX sandbox root must be a real directory"); + } + const owner: AcpxRuntimeSandboxRootOwner = { + token: Symbol("acpx-sandbox-root-owner"), + dev: entry.dev, + ino: entry.ino, }; + acpxRuntimeSandboxRootOwners.set(root, owner); + return owner; +} + +async function revalidateAndRemoveOwnedAcpxRuntimeSandboxRoot( + root: string, + owner: AcpxRuntimeSandboxRootOwner, +): Promise { + const current = acpxRuntimeSandboxRootOwners.get(root); + if (current === undefined || current.token !== owner.token) { + // A later admission has since claimed this deterministic root. It is + // live; a superseded owner must never delete it. + return; + } + let entry: BigIntStats; + try { + entry = await lstat(root, { bigint: true }); + } catch (error) { + if (errorCode(error) === "ENOENT") { + acpxRuntimeSandboxRootOwners.delete(root); + return; + } + throw error; + } + if ( + entry.isSymbolicLink() || + !entry.isDirectory() || + entry.dev !== owner.dev || + entry.ino !== owner.ino + ) { + throw new Error("ACPX sandbox root identity changed before cleanup"); + } + await rm(root, { recursive: true }); + if (acpxRuntimeSandboxRootOwners.get(root) === current) { + acpxRuntimeSandboxRootOwners.delete(root); + } +} + +/** + * Remove a sandbox root through the exact ownership capability that created + * it. Revalidates the root's directory identity and refuses the delete when + * a later admission has since claimed the same deterministic path or the + * identity no longer matches. + */ +export async function removeOwnedAcpxRuntimeSandboxRoot( + sandbox: AcpxRuntimeSandbox, +): Promise { + const owner = acpxRuntimeSandboxRootOwnerByResult.get(sandbox); + if (!owner) { + throw new Error("ACPX sandbox root ownership capability is unavailable"); + } + await revalidateAndRemoveOwnedAcpxRuntimeSandboxRoot(owner.root, owner); } async function ensurePrivateDirectory(