From 6dc0efd6db0cca2e29a21590612fdd88b34d454e Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 11 Sep 2026 14:44:56 -0500 Subject: [PATCH] Refresh verified provider launch leases after cold model selection --- .../acpx/codex-runtime-adapter.test.ts | 55 +++++++++++++ .../src/drivers/acpx/codex-runtime-adapter.ts | 10 +++ .../drivers/acpx/command-lease-owner.test.ts | 79 +++++++++++++++++++ .../src/drivers/acpx/command-lease-owner.ts | 58 ++++++++++++++ .../src/drivers/acpx/runtime-host.ts | 9 +++ 5 files changed, 211 insertions(+) create mode 100644 packages/paperclip-runner/src/drivers/acpx/command-lease-owner.test.ts create mode 100644 packages/paperclip-runner/src/drivers/acpx/command-lease-owner.ts diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts index a2570ee484..e9ce49fbdf 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts @@ -13,6 +13,7 @@ import { describe, expect, it, vi } from "vitest"; import type { VerifiedAcpxCommandLease } from "./installation-integrity.js"; import { openCodexAcpxRuntime } from "./codex-runtime-adapter.js"; +import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js"; import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; import type { AcpxRuntimePortOpenOptions } from "./runtime-host.js"; @@ -1367,6 +1368,60 @@ describe("Codex ACPX runtime adapter", () => { await port.close({ reason: "test complete" }); }); + it("uses a fresh single-use command after a cold model control consumes its launch", async () => { + const runtime = fakeRuntime(); + const freshCommand = () => { + const command = fakeCommand(); + vi.mocked(command.spawn).mockReturnValueOnce(fakeChild()).mockImplementation(() => { + throw new Error("Verified ACPX command lease is closed"); + }); + return command; + }; + const first = freshCommand(); + const second = freshCommand(); + const openCommand = vi.fn(async () => second); + const owner = createAcpxCommandLeaseOwner(first, openCommand); + let runtimeOptions: AcpRuntimeOptions; + vi.mocked(runtime.setConfigOption!).mockImplementation(async () => { + runtimeOptions.spawnAgent!({ command: "ignored", args: [], options: {} }); + }); + vi.mocked(runtime.startTurn).mockImplementation(() => { + runtimeOptions.spawnAgent!({ command: "ignored", args: [], options: {} }); + return { + requestId: "cold-turn", + promptStarted: Promise.resolve(), + events: { async *[Symbol.asyncIterator]() {} }, + result: Promise.resolve({ status: "completed" }), + cancel: vi.fn(), + closeStream: vi.fn(), + }; + }); + const port = await openCodexAcpxRuntime( + { + ...openOptions(owner.command), + refreshConsumedCommand: owner.refreshConsumedCommand, + }, + { + createRegistry: () => registry(), + createStore: () => store(), + awaitProviderOwnership: providerOwnershipEstablished, + awaitProviderExit: providerOwnershipEstablished, + createRuntime: (options) => { + runtimeOptions = options; + return runtime; + }, + }, + ); + await port.setModel!("gpt-5.6-sol"); + expect(openCommand).toHaveBeenCalledOnce(); + const turn = port.startTurn({ text: "Resume", requestId: "cold-turn" }); + await expect(turn.result).resolves.toMatchObject({ status: "completed" }); + expect(first.spawn).toHaveBeenCalledOnce(); + expect(second.spawn).toHaveBeenCalledOnce(); + await port.close({ reason: "test complete" }); + await owner.command.close(); + }); + it("admits a verified provider that starts with the first recovered turn", async () => { const runtime = fakeRuntime(); const child = fakeChild(); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index 8c1ad01c2b..808033fbf0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -265,6 +265,7 @@ export async function openQualifiedAcpxRuntime( update.goal === null ? null : structuredClone(update.goal), ); }; + const commandLaunches = { count: 0, refreshConsumedCommand: options.refreshConsumedCommand }; const runtimeOptions: GoalAwareAcpRuntimeOptions = { cwd: options.cwd, sessionStore, @@ -327,6 +328,7 @@ export async function openQualifiedAcpxRuntime( // handshake cannot create a provider process after authority is gone. options.signal?.throwIfAborted(); options.assertWorkspaceHeld?.(); + commandLaunches.count += 1; return children.add( options.command.spawn(input.args, input.options, { credentialFenceFds, @@ -431,6 +433,7 @@ export async function openQualifiedAcpxRuntime( children, runtimeCloseTimeoutMs, goalState, + commandLaunches, ); } catch (error) { const cleanupReason = "ACPX runtime identity validation failed"; @@ -857,6 +860,7 @@ function runtimePort( children: SpawnedChildSet, runtimeCloseTimeoutMs: number, goalState: AcpxRuntimeGoalState, + commandLaunches: { count: number; refreshConsumedCommand?: () => Promise }, ): AcpxRuntimePort { type RuntimeCloseAttempt = { readonly outcome: Promise; @@ -1161,6 +1165,7 @@ function runtimePort( // only for this control call, and verify ownership before return. const finishOwnershipAdmission = children.beginLifetimeOwnershipAdmission(); + const spawnsBeforeControl = commandLaunches.count; try { await runtime.setConfigOption?.({ handle, @@ -1170,6 +1175,11 @@ function runtimePort( } finally { await finishOwnershipAdmission(); } + // Cold ACP config calls open and close a temporary connection. + // A later prompt needs a newly verified single-use launch snapshot. + if (commandLaunches.count > spawnsBeforeControl) { + await commandLaunches.refreshConsumedCommand?.(); + } }, } : {}), diff --git a/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.test.ts b/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.test.ts new file mode 100644 index 0000000000..1fd684fe19 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.test.ts @@ -0,0 +1,79 @@ +import type { ChildProcess } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; +import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js"; +import type { VerifiedAcpxCommandLease } from "./installation-integrity.js"; + +function lease() { + let consumed = false; + return { + spawn: vi.fn(() => { + if (consumed) throw new Error("single-use command already consumed"); + consumed = true; + return {} as ChildProcess; + }), + close: vi.fn(async () => { + consumed = true; + }), + } satisfies VerifiedAcpxCommandLease; +} + +describe("ACPX verified command lease owner", () => { + it("refreshes only consumed snapshots and preserves single-use spawn enforcement", async () => { + const first = lease(); + const second = lease(); + const open = vi.fn(async () => second); + const owner = createAcpxCommandLeaseOwner(first, open); + await owner.refreshConsumedCommand(); + expect(open).not.toHaveBeenCalled(); + owner.command.spawn(); + expect(() => owner.command.spawn()).toThrow("already consumed"); + await Promise.all([owner.refreshConsumedCommand(), owner.refreshConsumedCommand()]); + expect(open).toHaveBeenCalledOnce(); + owner.command.spawn(); + expect(second.spawn).toHaveBeenCalledOnce(); + expect(() => owner.command.spawn()).toThrow("already consumed"); + await owner.command.close(); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).toHaveBeenCalledOnce(); + expect(() => owner.command.spawn()).toThrow("closing"); + await expect(owner.refreshConsumedCommand()).rejects.toThrow("closing"); + }); + + it("retains a replacement acquired during shutdown and retries its failed cleanup", async () => { + const first = lease(); + const replacement = lease(); + replacement.close.mockRejectedValueOnce(new Error("close failed")); + let acquired!: (value: VerifiedAcpxCommandLease) => void; + const owner = createAcpxCommandLeaseOwner( + first, + () => new Promise((resolve) => { + acquired = resolve; + }), + ); + owner.command.spawn(); + const refresh = owner.refreshConsumedCommand(); + const rejectedRefresh = expect(refresh).rejects.toThrow("closed during refresh"); + await Promise.resolve(); + const close = owner.command.close(); + const rejectedClose = expect(close).rejects.toThrow("leases did not close"); + acquired(replacement); + await rejectedRefresh; + await rejectedClose; + expect(replacement.spawn).not.toHaveBeenCalled(); + await owner.command.close(); + expect(replacement.close).toHaveBeenCalledTimes(2); + expect(first.close).toHaveBeenCalledOnce(); + }); + + it("fails closed when fresh command verification fails", async () => { + const initial = lease(); + const owner = createAcpxCommandLeaseOwner(initial, async () => { + throw new Error("installation changed"); + }); + owner.command.spawn(); + await expect(owner.refreshConsumedCommand()).rejects.toThrow("installation changed"); + expect(() => owner.command.spawn()).toThrow("already consumed"); + await owner.command.close(); + expect(initial.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.ts b/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.ts new file mode 100644 index 0000000000..38896668ca --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/command-lease-owner.ts @@ -0,0 +1,58 @@ +import type { VerifiedAcpxCommandLease } from "./installation-integrity.js"; + +/** Keep each launch single-use while owning replacements for transient ACP controls. */ +export function createAcpxCommandLeaseOwner( + initial: VerifiedAcpxCommandLease, + openCommand: () => Promise, +) { + const leases = new Set([initial]); + let current = initial; + let consumed = false; + let closing = false; + let refresh: Promise | null = null; + const command: VerifiedAcpxCommandLease = { + spawn(...args) { + if (closing) throw new Error("Verified ACPX command owner is closing"); + consumed = true; + return current.spawn(...args); + }, + async close() { + closing = true; + // Late acquisitions remain owned. Retry every lease whose close fails. + await refresh?.catch(() => undefined); + const failures: unknown[] = []; + for (const lease of leases) { + try { + await lease.close(); + leases.delete(lease); + } catch (error) { + failures.push(error); + } + } + if (failures.length) throw new AggregateError(failures, "ACPX command leases did not close"); + }, + }; + return { + command, + async refreshConsumedCommand(): Promise { + if (closing) throw new Error("Verified ACPX command owner is closing"); + if (!consumed) return; + if (!refresh) { + refresh = Promise.resolve() + .then(openCommand) + .then((replacement) => { + leases.add(replacement); + if (closing) throw new Error("Verified ACPX command owner closed during refresh"); + current = replacement; + consumed = false; + }); + } + const pending = refresh; + try { + await pending; + } finally { + if (refresh === pending) refresh = null; + } + }, + }; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index ebc3cb482c..996e1d4761 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -20,6 +20,7 @@ import { type VerifiedAcpxCommandLease, type VerifiedAcpxInstallation, } from "./installation-integrity.js"; +import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js"; import { requireVerifiedAcpxModel, type AcpxModelStatus, @@ -117,6 +118,8 @@ export interface AcpxRuntimePort { export interface AcpxRuntimePortOpenOptions { command: VerifiedAcpxCommandLease; + /** Replace a consumed launch snapshot after an ephemeral control session. */ + refreshConsumedCommand?: () => Promise; profile: QualifiedAcpxProfile; cwd: string; stateDirectory: string; @@ -401,6 +404,11 @@ export class AcpxRuntimeHost { reportFailure: (failure) => dependencies.reportRetainedCleanupFailure(failure), }); + const commandOwner = createAcpxCommandLeaseOwner( + command, + () => installation.openCommand(), + ); + command = commandOwner.command; toolBridge = options.semanticTools ? await acquireAbortableAdmissionResource({ signal: options.signal, @@ -421,6 +429,7 @@ export class AcpxRuntimeHost { options.assertWorkspaceHeld?.(); return dependencies.openRuntime({ command: command!, + refreshConsumedCommand: commandOwner.refreshConsumedCommand, profile, cwd: binding.workspacePath, stateDirectory: sandbox.stateDirectory,