diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts new file mode 100644 index 0000000000..e879fb2bb5 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -0,0 +1,489 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { + VerifiedAcpxCommandLease, + VerifiedAcpxInstallation, +} from "./installation-integrity.js"; +import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; +import { + AcpxRuntimeHost, + type AcpxRuntimeHostDependencies, + type AcpxRuntimePort, +} from "./runtime-host.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +describe("ACPX runtime host", () => { + it("rejects a pre-aborted admission before acquiring provider resources", async () => { + const fixture = await hostFixture(); + const controller = new AbortController(); + const cancellation = new Error("admission cancelled before start"); + controller.abort(cancellation); + const openRuntime = vi.fn(async () => runtimePort()); + const verifyInstallation = vi.fn( + fixture.dependencies({ openRuntime }).verifyInstallation!, + ); + + await expect( + AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "claude", + model: "claude-sonnet-5", + permissionMode: "deny-all", + signal: controller.signal, + }, + { + ...fixture.dependencies({ openRuntime }), + verifyInstallation, + }, + ), + ).rejects.toBe(cancellation); + + expect(verifyInstallation).not.toHaveBeenCalled(); + expect(openRuntime).not.toHaveBeenCalled(); + expect(fixture.commandClose).not.toHaveBeenCalled(); + }); + + it("composes admission, isolation, model verification, and cleanup", async () => { + const fixture = await hostFixture(); + let capturedEnvironment: Readonly = {}; + const runtime = runtimePort({ + onClose: vi.fn(async () => undefined), + }); + const dependencies = fixture.dependencies({ + openRuntime: async (options) => { + capturedEnvironment = options.launchEnvironment; + await writeFile( + join(options.launchEnvironment.CODEX_HOME!, "auth.json"), + '{"provider_generated":true}', + ); + return runtime; + }, + }); + + const host = await AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "codex", + model: "gpt-5.6-sol", + permissionMode: "approve-reads", + environment: { + PATH: process.env.PATH, + OPENAI_API_KEY: "launch-secret", + HTTPS_PROXY: "https://proxy-user:proxy-secret@example.test", + }, + systemInstructions: "Use the supplied runtime context.", + }, + dependencies, + ); + expect(host.identity()).toMatchObject({ + schema: "paperclip.runner.acpx-identity.v1", + acpxRecordId: "record-1", + requestedModel: "gpt-5.6-sol", + permissionMode: "approve-reads", + }); + expect(capturedEnvironment.OPENAI_API_KEY).toBe("launch-secret"); + expect(host.persistedEnvironment().OPENAI_API_KEY).toBeUndefined(); + expect(host.persistedEnvironment().HTTPS_PROXY).toBeUndefined(); + const authPath = join(host.runtimeRoot(), "codex-home", "auth.json"); + await expect(readFile(authPath, "utf8")).resolves.toContain( + "provider_generated", + ); + + await host.close({ reason: "test complete" }); + await expect(readFile(authPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(runtime.close).toHaveBeenCalledOnce(); + expect(fixture.commandClose).toHaveBeenCalledOnce(); + }); + + it("selects and verifies Claude's qualified reported model", async () => { + const fixture = await hostFixture(); + let selected = false; + const setModel = vi.fn(async (model: string) => { + expect(model).toBe("claude-sonnet-5"); + selected = true; + }); + const runtime = runtimePort({ + getStatus: async () => ({ + models: { + currentModelId: selected ? "sonnet" : "default", + availableModelIds: ["default", "sonnet"], + }, + }), + setModel, + }); + const host = await AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "claude", + model: "claude-sonnet-5", + permissionMode: "deny-all", + }, + fixture.dependencies({ openRuntime: async () => runtime }), + ); + + expect(setModel).toHaveBeenCalledOnce(); + expect(host.identity().effectiveModel).toBe("claude-sonnet-5"); + await host.close({ reason: "verified" }); + }); + + it("rejects recovery drift before opening the provider", async () => { + const fixture = await hostFixture(); + const openRuntime = vi.fn(async () => runtimePort()); + + await expect( + AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "claude", + model: "claude-sonnet-5", + permissionMode: "approve-reads", + expectedIdentity: { + kind: "acpx", + normalizedSessionId: fixture.options.normalizedSessionId, + acpxRecordId: "record-1", + backendSessionId: "backend-1", + agentSessionId: "agent-1", + profileDigest: resolveQualifiedAcpxProfile( + "claude", + "claude-sonnet-5", + ).commandDigest, + workspaceDigest: `sha256:${"0".repeat(64)}`, + requestedModel: "claude-sonnet-5", + effectiveModel: "claude-sonnet-5", + permissionMode: "approve-reads", + }, + }, + fixture.dependencies({ openRuntime }), + ), + ).rejects.toThrow(/immutable session configuration/); + expect(openRuntime).not.toHaveBeenCalled(); + }); + + it("rejects an injected installation that does not match the profile", async () => { + const fixture = await hostFixture(); + const openRuntime = vi.fn(async () => runtimePort()); + const dependencies = fixture.dependencies({ openRuntime }); + dependencies.verifyInstallation = async () => ({ + commandDigest: `sha256:${"f".repeat(64)}`, + agentServerPackageJsonPath: join(fixture.root, "package.json"), + agentRuntimePackageJsonPath: null, + openCommand: async () => { + throw new Error("mismatched installation must not open"); + }, + }); + + await expect( + AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "claude", + model: "claude-sonnet-5", + permissionMode: "approve-all", + }, + dependencies, + ), + ).rejects.toThrow(/does not match its profile/); + expect(openRuntime).not.toHaveBeenCalled(); + }); + + it("cleans credentials and command leases when provider open fails", async () => { + const fixture = await hostFixture(); + let authPath = ""; + await expect( + AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "codex", + model: "gpt-5.6-sol", + permissionMode: "approve-all", + environment: { + PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: + '{"tokens":{"access_token":"canary"}}', + }, + }, + fixture.dependencies({ + openRuntime: async (options) => { + authPath = join(options.launchEnvironment.CODEX_HOME!, "auth.json"); + throw new Error("provider failed"); + }, + }), + ), + ).rejects.toThrow("provider failed"); + await expect(readFile(authPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(fixture.commandClose).toHaveBeenCalledOnce(); + }); + + it("attempts every cleanup when runtime shutdown fails", async () => { + const fixture = await hostFixture(); + let failClose = true; + const runtime = runtimePort({ + onClose: vi.fn(async () => { + if (failClose) throw new Error("runtime close failed"); + }), + }); + const host = await AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "codex", + model: "gpt-5.6-sol", + permissionMode: "approve-all", + environment: { + PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: "{}", + }, + }, + fixture.dependencies({ openRuntime: async () => runtime }), + ); + const authPath = join(host.runtimeRoot(), "codex-home", "auth.json"); + + await expect(host.close({ reason: "first close" })).rejects.toThrow( + /cleanup failed/, + ); + await expect(readFile(authPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(fixture.commandClose).toHaveBeenCalledOnce(); + failClose = false; + await expect( + host.close({ reason: "retry close" }), + ).resolves.toBeUndefined(); + }); + + it("closes a command lease that resolves after admission is aborted", async () => { + const fixture = await hostFixture(); + const commandAdmission = deferred(); + const lateCommandClose = vi.fn(async () => undefined); + const openCommand = vi.fn(() => commandAdmission.promise); + const openRuntime = vi.fn(async () => runtimePort()); + const controller = new AbortController(); + const cancellation = new Error("command admission cancelled"); + const profile = resolveQualifiedAcpxProfile("claude", "claude-sonnet-5"); + const opening = AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "claude", + model: "claude-sonnet-5", + permissionMode: "deny-all", + signal: controller.signal, + }, + { + verifyInstallation: async () => ({ + commandDigest: profile.commandDigest, + agentServerPackageJsonPath: join(fixture.root, "package.json"), + agentRuntimePackageJsonPath: null, + openCommand, + }), + openRuntime, + reportRetainedCleanupFailure: vi.fn(), + }, + ); + await vi.waitFor(() => expect(openCommand).toHaveBeenCalledOnce()); + + controller.abort(cancellation); + await expect(opening).rejects.toBe(cancellation); + commandAdmission.resolve({ + spawn: () => { + throw new Error("late command must not spawn"); + }, + close: lateCommandClose, + }); + + await vi.waitFor(() => expect(lateCommandClose).toHaveBeenCalledOnce()); + expect(openRuntime).not.toHaveBeenCalled(); + }); + + it("closes a credential lease that resolves after admission is aborted", async () => { + const fixture = await hostFixture(); + const lateCredentialPath = join(fixture.root, "late-auth.json"); + await writeFile(lateCredentialPath, '{"access_token":"canary"}'); + const credentialAdmission = deferred<{ + path: string; + mode: "inline_json"; + close(): Promise; + }>(); + const cleanupFailure = new Error("transient credential cleanup failure"); + let cleanupAttempts = 0; + const lateCredentialClose = vi.fn(async () => { + cleanupAttempts += 1; + if (cleanupAttempts === 1) throw cleanupFailure; + await rm(lateCredentialPath); + }); + const reportRetainedCleanupFailure = vi.fn(); + const stageCredential = vi.fn(() => credentialAdmission.promise); + const openRuntime = vi.fn(async () => runtimePort()); + const controller = new AbortController(); + const cancellation = new Error("credential admission cancelled"); + const opening = AcpxRuntimeHost.open( + { + ...fixture.options, + agent: "codex", + model: "gpt-5.6-sol", + permissionMode: "deny-all", + signal: controller.signal, + }, + { + ...fixture.dependencies({ + openRuntime, + reportRetainedCleanupFailure, + }), + stageCredential, + }, + ); + await vi.waitFor(() => expect(stageCredential).toHaveBeenCalledOnce()); + + controller.abort(cancellation); + await expect(opening).rejects.toBe(cancellation); + credentialAdmission.resolve({ + path: lateCredentialPath, + mode: "inline_json", + close: lateCredentialClose, + }); + + await vi.waitFor(() => + expect(lateCredentialClose).toHaveBeenCalledTimes(2), + ); + await vi.waitFor(async () => + expect(readFile(lateCredentialPath)).rejects.toMatchObject({ + code: "ENOENT", + }), + ); + expect(reportRetainedCleanupFailure).toHaveBeenCalledOnce(); + expect(reportRetainedCleanupFailure).toHaveBeenCalledWith({ + resource: "credential", + attempt: 1, + error: cleanupFailure, + }); + expect(openRuntime).not.toHaveBeenCalled(); + expect(fixture.commandClose).not.toHaveBeenCalled(); + }); + + it("forwards cancellation and closes a runtime that resolves after abort", async () => { + const fixture = await hostFixture(); + const runtimeAdmission = deferred(); + const lateRuntime = runtimePort(); + let receivedSignal: AbortSignal | undefined; + const openRuntime = vi.fn((options) => { + receivedSignal = options.signal; + return runtimeAdmission.promise; + }); + const controller = new AbortController(); + const cancellation = new Error("runtime 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: "{}", + }, + signal: controller.signal, + }, + fixture.dependencies({ openRuntime }), + ); + await vi.waitFor(() => expect(openRuntime).toHaveBeenCalledOnce()); + expect(receivedSignal).toBe(controller.signal); + + controller.abort(cancellation); + await expect(opening).rejects.toBe(cancellation); + expect(fixture.commandClose).toHaveBeenCalledOnce(); + runtimeAdmission.resolve(lateRuntime); + + await vi.waitFor(() => + expect(lateRuntime.close).toHaveBeenCalledWith({ + reason: "ACPX runtime admission aborted", + }), + ); + }); +}); + +function runtimePort( + input: { + getStatus?: AcpxRuntimePort["getStatus"]; + setModel?: NonNullable; + onClose?: AcpxRuntimePort["close"]; + } = {}, +): AcpxRuntimePort & { close: ReturnType } { + return { + identity: async () => ({ + acpxRecordId: "record-1", + backendSessionId: "backend-1", + agentSessionId: "agent-1", + }), + getStatus: + input.getStatus ?? + (async () => ({ + models: { + currentModelId: "gpt-5.6-sol", + availableModelIds: ["gpt-5.6-sol"], + }, + })), + ...(input.setModel ? { setModel: input.setModel } : {}), + close: vi.fn(input.onClose ?? (async () => undefined)), + }; +} + +async function hostFixture() { + const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-host-")); + temporaryDirectories.push(root); + const runtimeDirectory = join(root, "runtime"); + const workingDirectory = join(root, "workspace"); + await Promise.all([mkdir(runtimeDirectory), mkdir(workingDirectory)]); + const commandClose = vi.fn(async () => undefined); + const command: VerifiedAcpxCommandLease = { + spawn: () => { + throw new Error("test command is not spawnable"); + }, + close: commandClose, + }; + return { + root, + commandClose, + options: { + runtimeDirectory, + normalizedSessionId: "normalized-session-1", + workingDirectory, + }, + dependencies( + input: Pick & + Partial< + Pick + >, + ): AcpxRuntimeHostDependencies { + return { + verifyInstallation: async (profile) => + ({ + commandDigest: profile.commandDigest, + agentServerPackageJsonPath: join(root, "package.json"), + agentRuntimePackageJsonPath: null, + openCommand: async () => command, + }) satisfies VerifiedAcpxInstallation, + openRuntime: input.openRuntime, + reportRetainedCleanupFailure: + input.reportRetainedCleanupFailure ?? vi.fn(), + }; + }, + }; +} + +function deferred(): { + promise: Promise; + resolve(value: T): void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts new file mode 100644 index 0000000000..c6ec72dc4f --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -0,0 +1,443 @@ +import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js"; +import { + stageManagedCodexCredential, + type ManagedCodexCredentialLease, +} from "./codex-credentials.js"; +import { + verifyQualifiedAcpxInstallation, + type VerifiedAcpxCommandLease, + type VerifiedAcpxInstallation, +} from "./installation-integrity.js"; +import { + requireVerifiedAcpxModel, + type AcpxModelStatus, +} from "./model-verification.js"; +import { acpxRuntimePermissionPolicy } from "./permission-policy.js"; +import { + resolveQualifiedAcpxProfile, + type QualifiedAcpxAgent, + type QualifiedAcpxProfile, +} from "./qualified-profiles.js"; +import { + createAcpxIdentityRecord, + createAcpxRecoveryBinding, + verifyExpectedAcpxIdentity, + type AcpxIdentityRecord, + type AcpxRecoveryBinding, +} from "./recovery-identity.js"; +import { + prepareAcpxRuntimeSandbox, + type AcpxRuntimeSandbox, +} from "./runtime-sandbox.js"; +import type { AcpxExpectedSessionIdentity } from "./sidecar-protocol.js"; + +export interface AcpxRuntimePortIdentity { + acpxRecordId: string; + backendSessionId: string; + agentSessionId: string; +} + +/** Minimal third-party ACP runtime surface admitted by the host boundary. */ +export interface AcpxRuntimePort { + identity(): Promise; + getStatus(): Promise; + setModel?(model: string): Promise; + close(input: { reason: string }): Promise; +} + +export interface AcpxRuntimePortOpenOptions { + command: VerifiedAcpxCommandLease; + profile: QualifiedAcpxProfile; + cwd: string; + stateDirectory: string; + providerSessionKey: string; + permissionMode: NativeAcpxPermissionMode; + permissionPolicy: ReturnType; + launchEnvironment: Readonly; + systemInstructions: string; + /** Abort provider admission and clean any runtime that resolves too late. */ + signal?: AbortSignal; +} + +export interface AcpxRetainedCleanupFailure { + resource: "credential" | "command" | "runtime"; + attempt: number; + error: unknown; +} + +export interface AcpxRuntimeHostDependencies { + verifyInstallation?: ( + profile: QualifiedAcpxProfile, + ) => Promise; + /** Internal test seam for aborting credential acquisition. */ + stageCredential?: typeof stageManagedCodexCredential; + openRuntime(options: AcpxRuntimePortOpenOptions): Promise; + /** + * Required observability channel for resources acquired after admission was + * aborted. Implementations must not throw from this callback. + */ + reportRetainedCleanupFailure(failure: AcpxRetainedCleanupFailure): void; +} + +export interface OpenAcpxRuntimeHostOptions { + runtimeDirectory: string; + normalizedSessionId: string; + workingDirectory: string; + agent: QualifiedAcpxAgent; + model: string; + permissionMode: NativeAcpxPermissionMode; + systemInstructions?: string; + environment?: NodeJS.ProcessEnv; + managedCodexCredentialSourcePath?: string; + expectedIdentity?: AcpxExpectedSessionIdentity; + /** Abort admission without admitting resources that resolve afterward. */ + signal?: AbortSignal; +} + +const activeRuntimeHostCleanupOwners = new Set>(); +const RETAINED_CLEANUP_RETRY_INITIAL_DELAY_MS = 10; +const RETAINED_CLEANUP_RETRY_MAX_DELAY_MS = 1_000; + +export class AcpxRuntimeHost { + readonly #runtime: AcpxRuntimePort; + readonly #binding: AcpxRecoveryBinding; + readonly #identity: AcpxIdentityRecord; + readonly #sandbox: AcpxRuntimeSandbox; + readonly #credential: ManagedCodexCredentialLease | null; + readonly #command: VerifiedAcpxCommandLease; + #closed = false; + + private constructor(input: { + runtime: AcpxRuntimePort; + binding: AcpxRecoveryBinding; + identity: AcpxIdentityRecord; + sandbox: AcpxRuntimeSandbox; + credential: ManagedCodexCredentialLease | null; + command: VerifiedAcpxCommandLease; + }) { + this.#runtime = input.runtime; + this.#binding = input.binding; + this.#identity = input.identity; + this.#sandbox = input.sandbox; + this.#credential = input.credential; + this.#command = input.command; + } + + static async open( + options: OpenAcpxRuntimeHostOptions, + dependencies: AcpxRuntimeHostDependencies, + ): Promise { + options.signal?.throwIfAborted(); + const profile = resolveQualifiedAcpxProfile(options.agent, options.model); + const binding = await runAbortableAdmissionStage(options.signal, () => + createAcpxRecoveryBinding({ + runtimeDirectory: options.runtimeDirectory, + normalizedSessionId: options.normalizedSessionId, + workingDirectory: options.workingDirectory, + profile, + requestedModel: options.model, + permissionMode: options.permissionMode, + }), + ); + if (options.expectedIdentity) { + verifyExpectedAcpxIdentity(options.expectedIdentity, binding, null); + } + if ( + options.agent !== "codex" && + options.managedCodexCredentialSourcePath !== undefined + ) { + throw new Error( + "Managed Codex credentials require the Codex ACPX profile", + ); + } + + const installation = await runAbortableAdmissionStage(options.signal, () => + (dependencies.verifyInstallation ?? verifyQualifiedAcpxInstallation)( + profile, + ), + ); + if (installation.commandDigest !== profile.commandDigest) { + throw new Error("Verified ACPX installation does not match its profile"); + } + let command: VerifiedAcpxCommandLease | null = null; + let credential: ManagedCodexCredentialLease | null = null; + let runtime: AcpxRuntimePort | null = null; + try { + const sandbox = await runAbortableAdmissionStage(options.signal, () => + prepareAcpxRuntimeSandbox({ + binding, + agent: options.agent, + environment: options.environment, + }), + ); + if (options.agent === "codex") { + credential = await acquireAbortableAdmissionResource({ + signal: options.signal, + acquire: () => + (dependencies.stageCredential ?? stageManagedCodexCredential)({ + agentHomeDirectory: sandbox.agentHomeDirectory, + environment: options.environment, + sourcePath: options.managedCodexCredentialSourcePath, + }), + resource: "credential", + releaseLate: (lateCredential) => lateCredential.close(), + reportFailure: (failure) => + dependencies.reportRetainedCleanupFailure(failure), + }); + } + command = await acquireAbortableAdmissionResource({ + signal: options.signal, + acquire: () => installation.openCommand(), + resource: "command", + releaseLate: (lateCommand) => lateCommand.close(), + reportFailure: (failure) => + dependencies.reportRetainedCleanupFailure(failure), + }); + runtime = await acquireAbortableAdmissionResource({ + signal: options.signal, + acquire: () => + dependencies.openRuntime({ + command: command!, + profile, + cwd: binding.workspacePath, + stateDirectory: sandbox.stateDirectory, + providerSessionKey: binding.profileSessionKey, + permissionMode: binding.permissionMode, + permissionPolicy: acpxRuntimePermissionPolicy( + binding.permissionMode, + ), + launchEnvironment: sandbox.launchEnvironment, + systemInstructions: boundedInstructions(options.systemInstructions), + ...(options.signal === undefined ? {} : { signal: options.signal }), + }), + resource: "runtime", + releaseLate: (lateRuntime) => + lateRuntime.close({ + reason: "ACPX runtime admission aborted", + }), + reportFailure: (failure) => + dependencies.reportRetainedCleanupFailure(failure), + }); + await runAbortableAdmissionStage(options.signal, () => + requireVerifiedAcpxModel(runtime!, profile), + ); + const runtimeIdentity = await runAbortableAdmissionStage( + options.signal, + () => runtime!.identity(), + ); + const observedIdentity: AcpxExpectedSessionIdentity = { + kind: "acpx", + normalizedSessionId: binding.normalizedSessionId, + ...runtimeIdentity, + profileDigest: binding.profileDigest, + workspaceDigest: binding.workspaceDigest, + requestedModel: binding.requestedModel, + effectiveModel: binding.effectiveModel, + permissionMode: binding.permissionMode, + }; + const identity = createAcpxIdentityRecord(observedIdentity, binding); + if (options.expectedIdentity) { + verifyExpectedAcpxIdentity(options.expectedIdentity, binding, identity); + } + options.signal?.throwIfAborted(); + return new AcpxRuntimeHost({ + runtime, + binding, + identity, + sandbox, + credential, + command, + }); + } catch (error) { + const cleanupError = await cleanupRuntimeResources( + runtime, + credential, + command, + "ACPX runtime initialization failed", + ); + if (cleanupError) { + throw new AggregateError( + [error, ...cleanupError.errors], + "ACPX runtime initialization and cleanup failed", + ); + } + throw error; + } + } + + identity(): AcpxIdentityRecord { + return structuredClone(this.#identity); + } + + binding(): AcpxRecoveryBinding { + return structuredClone(this.#binding); + } + + runtimeRoot(): string { + return this.#sandbox.root; + } + + persistedEnvironment(): Readonly { + return Object.freeze({ ...this.#sandbox.persistedEnvironment }); + } + + async close(input: { reason: string }): Promise { + if (this.#closed) return; + const error = await cleanupRuntimeResources( + this.#runtime, + this.#credential, + this.#command, + boundedReason(input.reason), + ); + if (error) throw error; + this.#closed = true; + } +} + +async function runAbortableAdmissionStage( + signal: AbortSignal | undefined, + operation: () => Promise, +): Promise { + if (signal === undefined) return await operation(); + signal.throwIfAborted(); + const pending = Promise.resolve().then(operation); + return await raceAdmissionWithAbort(pending, signal); +} + +async function acquireAbortableAdmissionResource(input: { + signal: AbortSignal | undefined; + acquire: () => Promise; + resource: AcpxRetainedCleanupFailure["resource"]; + releaseLate: (resource: T) => Promise; + reportFailure: (failure: AcpxRetainedCleanupFailure) => void; +}): Promise { + if (input.signal === undefined) return await input.acquire(); + input.signal.throwIfAborted(); + const pending = Promise.resolve().then(input.acquire); + try { + return await raceAdmissionWithAbort(pending, input.signal); + } catch (error) { + if (input.signal.aborted) { + retainRuntimeHostCleanup( + pending.then((resource) => + releaseRetainedAdmissionResource({ + resource, + resourceKind: input.resource, + release: input.releaseLate, + reportFailure: input.reportFailure, + }), + ), + ); + } + throw error; + } +} + +function raceAdmissionWithAbort( + pending: Promise, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const settle = (operation: () => void): void => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + operation(); + }; + const onAbort = (): void => settle(() => reject(signal.reason)); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) { + onAbort(); + return; + } + void pending.then( + (value) => settle(() => resolve(value)), + (error: unknown) => settle(() => reject(error)), + ); + }); +} + +async function releaseRetainedAdmissionResource(input: { + resource: T; + resourceKind: AcpxRetainedCleanupFailure["resource"]; + release: (resource: T) => Promise; + reportFailure: (failure: AcpxRetainedCleanupFailure) => void; +}): Promise { + let attempt = 0; + let retryDelayMs = RETAINED_CLEANUP_RETRY_INITIAL_DELAY_MS; + for (;;) { + attempt += 1; + try { + await input.release(input.resource); + return; + } catch (error) { + try { + input.reportFailure({ + resource: input.resourceKind, + attempt, + error, + }); + } catch { + // The required reporter is observational. A broken reporter must not + // relinquish ownership of the resource that still needs cleanup. + } + await waitForRetainedCleanupRetry(retryDelayMs); + retryDelayMs = Math.min( + retryDelayMs * 2, + RETAINED_CLEANUP_RETRY_MAX_DELAY_MS, + ); + } + } +} + +async function waitForRetainedCleanupRetry(delayMs: number): Promise { + await new Promise((resolve) => { + const timer = setTimeout(resolve, delayMs); + timer.unref?.(); + }); +} + +function retainRuntimeHostCleanup(cleanup: Promise): void { + activeRuntimeHostCleanupOwners.add(cleanup); + void cleanup + .finally(() => activeRuntimeHostCleanupOwners.delete(cleanup)) + .catch(() => undefined); +} + +async function cleanupRuntimeResources( + runtime: AcpxRuntimePort | null, + credential: ManagedCodexCredentialLease | null, + command: VerifiedAcpxCommandLease | null, + reason: string, +): Promise { + const errors: unknown[] = []; + for (const close of [ + runtime ? () => runtime.close({ reason }) : null, + credential ? () => credential.close() : null, + command ? () => command.close() : null, + ]) { + if (!close) continue; + try { + await close(); + } catch (error) { + errors.push(error); + } + } + return errors.length > 0 + ? new AggregateError(errors, "ACPX runtime cleanup failed") + : null; +} + +function boundedInstructions(value: string | undefined): string { + const instructions = value ?? ""; + if (Buffer.byteLength(instructions) > 256 * 1024) { + throw new Error("ACPX system instructions exceed their bounded size"); + } + return instructions; +} + +function boundedReason(value: string): string { + const reason = value.trim().slice(0, 1_000); + return reason || "ACPX runtime closed"; +}