From 44ee24fa19b3bc51f9e3318daacf1e093e588b94 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 12:43:02 -0500 Subject: [PATCH 1/3] fix: preserve ACPX instruction framing across remote recovery Co-Authored-By: Paperclip --- .../src/live/runnerd-codex-transport.test.ts | 38 ++++++++++++++++++ .../src/live/runnerd-codex-transport.ts | 40 +++++++++++++++---- 2 files changed, 71 insertions(+), 7 deletions(-) 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 debd0c6e3f..154c336310 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -643,6 +643,44 @@ it("retargets only composed instruction framing and retains legacy custom instru }); +it.each([false, true])("preserves ACPX asset framing across cold, warm, and post-idle attachment (guidance=%s)", (guidance) => { + const source = assignedRuntimeContext("/controller/skills", "/controller/bundle"); + const cold = assignedRuntimeContext("/boat/skills", "/boat/context/cold"); + const resumed = assignedRuntimeContext("/boat/skills", "/boat/context/resumed"); + const custom = "Managed instructions with literal /controller/bundle example."; + const original = composeNativeSystemInstructions(cold, custom); + const coldText = runnerdRecoveryInternals.withComputerProcessInstructions( + original, guidance, cold, + ); + const suffix = (context: NativeRuntimeContextSnapshot) => + composeNativeSystemInstructions(context, "").slice(context.prompt.text.length); + expect(coldText.endsWith(suffix(cold))).toBe(true); + const state = { runAttachTemplate: { provider: { + kind: "acpx", agent: "claude", runId: "cold-run", runtimeContext: cold, instructions: coldText, + } } }; + const desired = { + runnerInstanceId: "runner", environmentLeaseId: "workspace", runId: "warm-run", + normalizedSessionId: "same-session", turnId: "turn", itemId: "item", + }; + const warm = runnerdRecoveryInternals.rotatedRunAttachPayload(state, desired, null, undefined); + expect((warm.provider as { instructions: string }).instructions).toBe(coldText); + const recover = (text: string) => runnerdRecoveryInternals.rotatedRunAttachPayload( + { runAttachTemplate: warm }, { ...desired, runId: "post-idle-run" }, null, undefined, resumed, + { text, context: source }, + ).provider as { instructions: string; runtimeContext: NativeRuntimeContextSnapshot }; + const recovered = recover(composeNativeSystemInstructions(source, custom)); + expect(recovered.runtimeContext).toEqual(resumed); + expect(recovered.instructions.endsWith(suffix(resumed))).toBe(true); + // This is the immutable-prefix equality required by Claude's guest guard. + expect(recovered.instructions.slice(0, -suffix(resumed).length)) + .toBe(coldText.slice(0, -suffix(cold).length)); + expect(recovered.instructions).toContain(custom); + expect(recovered.instructions).not.toContain("Read-only instruction sibling root: /controller/"); + // Never silently substitute old managed instructions for current grants. + expect(recover(composeNativeSystemInstructions(source, "Changed managed instructions")).instructions) + .toContain("Changed managed instructions"); +}); + 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 14894434b8..2846d9c8a2 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -501,9 +501,20 @@ function retargetRunAttachPayload( // context, never the prior run's now-stale filesystem grant. if (runtimeContext !== undefined) { if (currentInstructions !== undefined) { - provider.instructions = currentInstructions.context - ? retargetComposedInstructions(currentInstructions.text, currentInstructions.context, runtimeContext) + // Controller guidance is part of the durable provider profile. A + // controller upgrade must not inject it into an existing session; new + // managed instructions still use this run's authenticated snapshot. + const instructions = typeof provider.instructions === "string" + && provider.instructions.includes(COMPUTER_PROCESS_INSTRUCTIONS) + ? withComputerProcessInstructions( + currentInstructions.text, + true, + currentInstructions.context, + ) : currentInstructions.text; + provider.instructions = currentInstructions.context + ? retargetComposedInstructions(instructions, currentInstructions.context, runtimeContext) + : instructions; } else if (typeof provider.instructions === "string" && provider.runtimeContext) { provider.instructions = retargetComposedInstructions( provider.instructions, @@ -3443,9 +3454,23 @@ export function trustedRuntimeReadOnlyRoots( return [...roots]; } -function withComputerProcessInstructions(instructions: string, persistentAgentHome?: string): string { - if (!persistentAgentHome) return instructions; - return `${instructions}\n\nComputer runtime: files in AGENT_HOME persist across turns. A dev server needed between warm turns must survive the shell tool's process-group cleanup: use a detached session, for example \`nohup setsid dev-server.log 2>&1 &\`, and verify its listener from a separate command after the launching command returns. Detached processes still belong to this runner and stop when its warm timeout expires. Do not create services or change runner ownership to keep them alive.`; +const COMPUTER_PROCESS_INSTRUCTIONS = `Computer runtime: files in AGENT_HOME persist across turns. A dev server needed between warm turns must survive the shell tool's process-group cleanup: use a detached session, for example \`nohup setsid dev-server.log 2>&1 &\`, and verify its listener from a separate command after the launching command returns. Detached processes still belong to this runner and stop when its warm timeout expires. Do not create services or change runner ownership to keep them alive.`; + +function withComputerProcessInstructions( + instructions: string, + computerRuntime: boolean, + context?: NativeRuntimeContextSnapshot | null, +): string { + if (!computerRuntime) return instructions; + if (context) { + const suffix = composeNativeSystemInstructions(context, "").slice(context.prompt.text.length); + if (suffix && instructions.startsWith(context.prompt.text) && instructions.endsWith(suffix)) { + // Keep the canonical asset block last: ACPX verifies this framing when + // rotating a registered filesystem grant after idle or process recovery. + return `${instructions.slice(0, -suffix.length)}\n\n${COMPUTER_PROCESS_INSTRUCTIONS}${suffix}`; + } + } + return `${instructions}\n\n${COMPUTER_PROCESS_INSTRUCTIONS}`; } export function createRunnerdCodexAppServerArgs(input: { @@ -4833,7 +4858,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { record(params.config).include_collaboration_mode_instructions !== false; const unboundBaseInstructions = withComputerProcessInstructions(String( params.developerInstructions ?? params.baseInstructions ?? "You are a Paperclip agent.", - ), this.options.persistentAgentHome); + ), Boolean(this.options.persistentAgentHome), sourceRuntimeContext); const baseInstructions = sourceRuntimeContext && runtimeContext ? unboundBaseInstructions.replaceAll( @@ -5450,7 +5475,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { runtimeContext, this.options.baseInstructions === undefined ? undefined - : { text: withComputerProcessInstructions(this.options.baseInstructions, this.options.persistentAgentHome), context: sourceRuntimeContext }, + : { text: this.options.baseInstructions, context: sourceRuntimeContext }, ); if (provider === "codex") { // These controller-owned, token-free paths belong to the new run. @@ -7088,6 +7113,7 @@ export const runnerdLaunchProfileInternals = Object.freeze({ }); export const runnerdRecoveryInternals = Object.freeze({ + withComputerProcessInstructions, completedMaintenanceTerminalReceipt, completedMaintenanceTerminalReplayMatches, readControlPlaneState, From daa711a097b6168392d80ab1e0e9a46b758a3e47 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 12:44:30 -0500 Subject: [PATCH 2/3] fix: retarget only managed asset framing during cold startup Co-Authored-By: Paperclip --- .../src/live/runnerd-codex-transport.test.ts | 8 +++++--- .../paperclip-runner/src/live/runnerd-codex-transport.ts | 6 ++---- 2 files changed, 7 insertions(+), 7 deletions(-) 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 154c336310..be4bbb3564 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -648,9 +648,11 @@ it.each([false, true])("preserves ACPX asset framing across cold, warm, and post const cold = assignedRuntimeContext("/boat/skills", "/boat/context/cold"); const resumed = assignedRuntimeContext("/boat/skills", "/boat/context/resumed"); const custom = "Managed instructions with literal /controller/bundle example."; - const original = composeNativeSystemInstructions(cold, custom); - const coldText = runnerdRecoveryInternals.withComputerProcessInstructions( - original, guidance, cold, + const original = composeNativeSystemInstructions(source, custom); + const coldText = runnerdRecoveryInternals.retargetComposedInstructions( + runnerdRecoveryInternals.withComputerProcessInstructions(original, guidance, source), + source, + cold, ); const suffix = (context: NativeRuntimeContextSnapshot) => composeNativeSystemInstructions(context, "").slice(context.prompt.text.length); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 2846d9c8a2..4b270e81fd 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -4861,10 +4861,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ), Boolean(this.options.persistentAgentHome), sourceRuntimeContext); const baseInstructions = sourceRuntimeContext && runtimeContext - ? unboundBaseInstructions.replaceAll( - sourceRuntimeContext.instructions.bundle.rootPath, - runtimeContext.instructions.bundle.rootPath, - ) + ? retargetComposedInstructions(unboundBaseInstructions, sourceRuntimeContext, runtimeContext) : unboundBaseInstructions; const acpxProfile = provider === "acpx" @@ -7114,6 +7111,7 @@ export const runnerdLaunchProfileInternals = Object.freeze({ export const runnerdRecoveryInternals = Object.freeze({ withComputerProcessInstructions, + retargetComposedInstructions, completedMaintenanceTerminalReceipt, completedMaintenanceTerminalReplayMatches, readControlPlaneState, From 471ff0e5508b846044f0241e4aee3253a82559e7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 12:45:49 -0500 Subject: [PATCH 3/3] test: retain instruction framing acceptance in stacked PR Co-Authored-By: Paperclip --- .../src/live/runnerd-codex-transport.test.ts | 40 ------------------- 1 file changed, 40 deletions(-) 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 be4bbb3564..debd0c6e3f 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -643,46 +643,6 @@ it("retargets only composed instruction framing and retains legacy custom instru }); -it.each([false, true])("preserves ACPX asset framing across cold, warm, and post-idle attachment (guidance=%s)", (guidance) => { - const source = assignedRuntimeContext("/controller/skills", "/controller/bundle"); - const cold = assignedRuntimeContext("/boat/skills", "/boat/context/cold"); - const resumed = assignedRuntimeContext("/boat/skills", "/boat/context/resumed"); - const custom = "Managed instructions with literal /controller/bundle example."; - const original = composeNativeSystemInstructions(source, custom); - const coldText = runnerdRecoveryInternals.retargetComposedInstructions( - runnerdRecoveryInternals.withComputerProcessInstructions(original, guidance, source), - source, - cold, - ); - const suffix = (context: NativeRuntimeContextSnapshot) => - composeNativeSystemInstructions(context, "").slice(context.prompt.text.length); - expect(coldText.endsWith(suffix(cold))).toBe(true); - const state = { runAttachTemplate: { provider: { - kind: "acpx", agent: "claude", runId: "cold-run", runtimeContext: cold, instructions: coldText, - } } }; - const desired = { - runnerInstanceId: "runner", environmentLeaseId: "workspace", runId: "warm-run", - normalizedSessionId: "same-session", turnId: "turn", itemId: "item", - }; - const warm = runnerdRecoveryInternals.rotatedRunAttachPayload(state, desired, null, undefined); - expect((warm.provider as { instructions: string }).instructions).toBe(coldText); - const recover = (text: string) => runnerdRecoveryInternals.rotatedRunAttachPayload( - { runAttachTemplate: warm }, { ...desired, runId: "post-idle-run" }, null, undefined, resumed, - { text, context: source }, - ).provider as { instructions: string; runtimeContext: NativeRuntimeContextSnapshot }; - const recovered = recover(composeNativeSystemInstructions(source, custom)); - expect(recovered.runtimeContext).toEqual(resumed); - expect(recovered.instructions.endsWith(suffix(resumed))).toBe(true); - // This is the immutable-prefix equality required by Claude's guest guard. - expect(recovered.instructions.slice(0, -suffix(resumed).length)) - .toBe(coldText.slice(0, -suffix(cold).length)); - expect(recovered.instructions).toContain(custom); - expect(recovered.instructions).not.toContain("Read-only instruction sibling root: /controller/"); - // Never silently substitute old managed instructions for current grants. - expect(recover(composeNativeSystemInstructions(source, "Changed managed instructions")).instructions) - .toContain("Changed managed instructions"); -}); - it("replays the durable run attachment outcome and latest provider identity", () => { expect( runnerdRecoveryInternals.recoveredRunAttachment({