diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 825e8dd5a1..2ceee74365 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url"; import { expect, it, vi } from "vitest"; import type { ControlPlanePort } from "../contracts/control-plane-port.js"; +import { bindAcpxAgentFiles } from "../drivers/acpx/agent-files-binding.js"; import type { NativeExecutionInputV1 } from "../contracts/native-execution.js"; import type { NativeSession, @@ -485,6 +486,61 @@ it("carries the provider attachment seed across consecutive authority rotations" }); }); +it("restores ACPX with the current registered copy after the old run copy is collected", async () => { + const root = await mkdtemp(join(tmpdir(), "runnerd-acpx-context-rotation-")); + const priorRoot = join(root, "prior-run-copy"); + const currentRoot = join(root, "current-run-copy"); + await Promise.all([mkdir(priorRoot), mkdir(currentRoot)]); + const context = (rootPath: string) => ({ + instructions: { + workingCopy: { kind: "agent_files", rootPath, entryPath: "AGENTS.md" }, + }, + }) as NativeRuntimeContextSnapshot; + const prior = context(priorRoot); + const current = context(currentRoot); + const desired = { + runnerInstanceId: "runner", environmentLeaseId: "lease", runId: "new-run", + normalizedSessionId: "same-session", turnId: "new-turn", itemId: "new-item", + }; + const provider = { + kind: "acpx", agent: "copilot", runId: "old-run", + normalizedSessionId: "same-session", commandDigest: "sha256:immutable", + model: "explicit-model", runtimeContext: prior, + }; + const state = { + runAttachTemplate: { provider, workspace: { cwd: root } }, + commands: [{ type: "turn.start", payload: { text: "must not replay" } }], + }; + try { + await rm(priorRoot, { recursive: true }); + expect(() => bindAcpxAgentFiles(prior, [])).toThrowError( + expect.objectContaining({ code: "ENOENT" }), + ); + const restored = runnerdRecoveryInternals.rotatedRunAttachPayload( + state, desired, null, undefined, current, + ); + const restoredProvider = restored.provider as typeof provider; + expect(restoredProvider).toEqual({ ...provider, runId: desired.runId, runtimeContext: current }); + expect(bindAcpxAgentFiles(restoredProvider.runtimeContext, [])?.root).toContain("current-run-copy"); + expect(restored).not.toHaveProperty("text"); + expect(state.runAttachTemplate.provider.runtimeContext).toBe(prior); + // A reopened provider can retain the current grant on another restoration. + const same = runnerdRecoveryInternals.rotatedRunAttachPayload( + { runAttachTemplate: restored }, desired, null, undefined, current, + ); + expect(same).toEqual(restored); + expect((same.provider as typeof provider).runtimeContext).not.toBe(current); + // Live warm attachment has no new provider process and must preserve its + // existing context until settlement, rather than replace its filesystem grant. + const live = runnerdRecoveryInternals.rotatedRunAttachPayload( + state, desired, null, undefined, + ); + expect((live.provider as typeof provider).runtimeContext).toEqual(prior); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + it("replays the durable run attachment outcome and latest provider identity", () => { expect( runnerdRecoveryInternals.recoveredRunAttachment({ diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 2176cbd4d1..5ec9d1ab50 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -418,6 +418,7 @@ function rotatedRunAttachPayload( authorizedTools: Record | null, completionContract: { revision: string; criterionIds: readonly string[] } | undefined, + runtimeContext?: NativeRuntimeContextSnapshot | null, ): Record { const commands = Array.isArray(state.commands) ? state.commands.map(record) @@ -443,6 +444,7 @@ function rotatedRunAttachPayload( desired, authorizedTools, completionContract, + runtimeContext, ); } @@ -452,12 +454,19 @@ function retargetRunAttachPayload( authorizedTools: Record | null, completionContract: { revision: string; criterionIds: readonly string[] } | undefined, + runtimeContext?: NativeRuntimeContextSnapshot | null, ): Record { const payload = structuredClone(seedPayload); const provider = record(payload.provider); if (provider.kind === "acpx" || provider.provider === "acpx") { provider.runId = desired.runId; provider.normalizedSessionId = desired.normalizedSessionId; + // A run-scoped registered instruction copy is collected after shutdown. + // Restore the durable provider identity with the current authenticated + // context, never the prior run's now-stale filesystem grant. + if (runtimeContext !== undefined) { + provider.runtimeContext = structuredClone(runtimeContext); + } payload.provider = provider; } if (authorizedTools !== null) payload.authorizedTools = authorizedTools; @@ -5140,6 +5149,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { desiredIdentity, this.#authorizedTools, this.options.resumeCompletionContract, + runtimeContext, ); if (provider === "codex") { // These controller-owned, token-free paths belong to the new run.