fix(runner): own and clean an ACPX sandbox root abandoned by an aborted admission

The sandbox stage was the only admission step that did not retain its
pending promise across an abort. When abort won the race while sandbox
preparation was still in flight, nothing observed that preparation once
it finally settled: a late filesystem error became an unhandled
rejection, and a fully built root leaked on disk forever.

Give the sandbox stage the same retained-cleanup treatment every other
admission resource already gets. Each successful preparation claims
exclusive, inode-identity-backed ownership of its deterministic runtime
root. Retained cleanup revalidates that identity and ownership before it
deletes anything, so a later admission for the same session supersedes a
stale owner instead of losing its live root to a delayed delete.

Add one named, bounded deadline for every wait in the host test file that
follows real sandbox preparation, replacing the inherited 1000ms default
that caused the original flake under load.

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Priya RamanandPaperclip committed 2026-09-03 07:33:31 +00:00
1 parent 4d30efa8e3
commit c38d46f0cb
4 files changed
+520 -127

No files matched your search

@@ -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<T>(
check: () => T | Promise<T>,
): Promise<T> {
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<void>();
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<void>();
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<void>();
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" });
});
});
@@ -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<VerifiedAcpxInstallation>;
/** Internal test seam for aborting credential acquisition. */
stageCredential?: typeof stageManagedCodexCredential;
/** Internal test seam for aborting sandbox preparation. */
prepareSandbox?: typeof prepareAcpxRuntimeSandbox;
openRuntime(options: AcpxRuntimePortOpenOptions): Promise<AcpxRuntimePort>;
/** 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,
@@ -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 () => {
@@ -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<void>;
}
/** Prepare the private filesystem and environment visible to an ACPX agent. */
export async function prepareAcpxRuntimeSandbox(input: {
binding: AcpxRecoveryBinding;
agent: QualifiedAcpxAgent;
environment?: NodeJS.ProcessEnv;
}): Promise<AcpxRuntimeSandbox> {
export async function prepareAcpxRuntimeSandbox(
input: {
binding: AcpxRecoveryBinding;
agent: QualifiedAcpxAgent;
environment?: NodeJS.ProcessEnv;
},
dependencies: AcpxRuntimeSandboxPrepareDependencies = {},
): Promise<AcpxRuntimeSandbox> {
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<AcpxRuntimeSandboxRootOwner> {
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<void> {
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<void> {
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(