From b852cdec59d07a476ccc2da4dcb1a99147daaf49 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 21 Oct 2026 10:05:02 -0500 Subject: [PATCH] fix: carry server-owned workspace continuations through runner bridges Co-Authored-By: Paperclip --- packages/paperclip-runner/acpx-profiles.json | 4 +- .../src/bin/fake-codex-app-server.rs | 17 ++++ .../src/generated_acpx_profiles.rs | 2 +- .../runner-core/src/provider_backend.rs | 44 +++++++--- .../scripts/copilot-profile-identity.mjs | 4 +- .../src/cli/acpx-runtime-sidecar.test.ts | 4 +- .../src/contracts/completion-result.test.ts | 16 ++++ .../src/contracts/completion-result.ts | 14 ++++ .../drivers/acpx/copilot-installation.test.ts | 16 +++- .../src/drivers/acpx/generated-profiles.ts | 4 +- .../src/drivers/acpx/profile-compatibility.ts | 4 +- ...app-server-driver.semantic-results.test.ts | 4 +- .../drivers/completion-continuation.test.ts | 14 ++++ .../src/drivers/completion-continuation.ts | 7 +- .../src/live/runnerd-codex-transport.ts | 3 +- .../live/runnerd-saved-continuation.test.ts | 56 +++++++++++++ .../copilot-profile-v55-identity.json | 84 +++++++++++++++++++ tests/runner-e2e/daytona-image-content.ts | 1 + 18 files changed, 270 insertions(+), 28 deletions(-) create mode 100644 packages/paperclip-runner/src/live/runnerd-saved-continuation.test.ts create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v55-identity.json diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 409c8417edd..7f295a51e6b 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -32,12 +32,12 @@ "requiresProviderPolicy": true }, "copilot": { - "agentProfileVersion": 54, + "agentProfileVersion": 55, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:171d865b9b6ff2eda3926dc957caf2993cd7e1fa1b4359e8b60b83f589bd67fa", + "commandDigest": "sha256:2c9e7f0fee36d2f7f0b253a86f3587b7bf2d9406f774b3b91e13678881ee7d48", "requiresProviderPolicy": true }, "claude": { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs index 49860a9e06d..1c05cf279bc 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs @@ -659,6 +659,11 @@ fn run() -> Result<(), Box> { let expected_canonical_task_context_file = argument(&args, "--expected-canonical-task-context-file"); let emit_tool_call = args.iter().any(|value| value == "--emit-tool-call"); + let terminal_result = argument(&args, "--terminal-result-file") + .map(|path| -> Result> { + Ok(serde_json::from_str(&fs::read_to_string(path)?)?) + }) + .transpose()?; let replay_completed_tool_call = args .iter() .any(|value| value == "--replay-completed-tool-call"); @@ -976,6 +981,12 @@ fn run() -> Result<(), Box> { } continue; } + if message.get("method").is_none() && message.get("id") == Some(&json!("terminal-result-request")) { + let success = message.pointer("/result/success") == Some(&json!(true)); + log_call(call_log.as_deref(), if success { "terminal-result:accepted" } else { "terminal-result:rejected" })?; + finish_turn(&state_path, &mut state, if success { "completed" } else { "failed" })?; + continue; + } if message.get("method").is_none() && message.get("id") == Some(&json!("tool-request-1")) { if message.pointer("/result/success") == Some(&json!(false)) { log_call(call_log.as_deref(), "tool-response:failure")?; @@ -1706,6 +1717,12 @@ fn run() -> Result<(), Box> { "arguments":{"query":{"PRIVATE_QUERY":"private search"}} }}), )?; + } else if let Some(result) = &terminal_result { + send(json!({ + "id": "terminal-result-request", "method": "item/tool/call", + "params": { "threadId": state.thread_id, "turnId": provider_turn_id, + "callId": "terminal-result-call", "tool": "paperclip_finish", "arguments": result } + }))?; } else if emit_tool_call { send(json!({ "id": "tool-request-1", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index 6aba8aa96d9..6d51ac7c1b8 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: None, agent_runtime_version: None, command_digest: - "sha256:171d865b9b6ff2eda3926dc957caf2993cd7e1fa1b4359e8b60b83f589bd67fa", + "sha256:2c9e7f0fee36d2f7f0b253a86f3587b7bf2d9406f774b3b91e13678881ee7d48", requires_provider_policy: true, }, "claude" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index 6dab8eb9b25..d77386b494e 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -557,13 +557,14 @@ fn admit_terminal_tool_authority( "paperclip_finish" => { matches!(disposition.as_str(), "done" | "needs_review") || (disposition == "yielded" - && matches!( - input - .get("continuation") - .and_then(|continuation| continuation.get("kind")) - .and_then(Value::as_str), - Some("response_wake" | "monitor" | "case_handoff") - )) + && (input.get("continuation").map_or(true, Value::is_null) + || matches!( + input + .get("continuation") + .and_then(|continuation| continuation.get("kind")) + .and_then(Value::as_str), + Some("response_wake" | "monitor" | "case_handoff" | "same_agent") + ))) } "paperclip_block" => disposition == "blocked", _ => false, @@ -5311,7 +5312,27 @@ mod tests { } #[test] - fn terminal_tool_authority_rejects_an_unbound_yield() { + fn terminal_tool_authority_preserves_controller_resolved_workspace_yields() { + for continuation in [None, Some(Value::Null), Some(json!({ + "kind": "same_agent", + "summary": "Continue the prepared repository.", + "idempotencyKey": "repository:task:receipt:run" + }))] { + let mut state = opencode_result_state(); + let mut result = valid_opencode_result(); + result["reportedWorkDisposition"] = json!("yielded"); + if let Some(value) = continuation { + result["continuation"] = value; + } + admit_terminal_tool_authority(&mut state, "paperclip_finish", &result, false).unwrap(); + assert_eq!(state.active_provider_result_disposition.as_deref(), Some("yielded")); + assert_eq!(state.active_provider_result_fingerprint.as_deref(), Some(semantic_value_digest(&result).as_str())); + assert!(state.validate().is_ok()); + } + } + + #[test] + fn terminal_tool_authority_does_not_record_a_controller_rejected_yield() { let mut state = opencode_result_state(); let mut result = valid_opencode_result(); result["reportedWorkDisposition"] = json!("yielded"); @@ -5321,9 +5342,10 @@ mod tests { "idempotencyKey": "same-agent-1" }); - assert!( - admit_terminal_tool_authority(&mut state, "paperclip_finish", &result, false,).is_err() - ); + admit_terminal_tool_authority(&mut state, "paperclip_finish", &result, true).unwrap(); + assert!(state.active_provider_result_fingerprint.is_none()); + assert!(state.active_provider_result_disposition.is_none()); + assert!(state.validate().is_ok()); } #[test] diff --git a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs index f687226cc3b..3401ada114c 100644 --- a/packages/paperclip-runner/scripts/copilot-profile-identity.mjs +++ b/packages/paperclip-runner/scripts/copilot-profile-identity.mjs @@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url"; import { resolve, dirname } from "node:path"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const fixture = resolve(root, "test/fixtures/copilot-profile-v54-identity.json"); +const fixture = resolve(root, "test/fixtures/copilot-profile-v55-identity.json"); const historical = JSON.parse(readFileSync(resolve(root, "test/fixtures/copilot-profile-v12-identity.json"), "utf8")); -const declaration = { ...historical.declaration, agentProfileVersion: 54 }; +const declaration = { ...historical.declaration, agentProfileVersion: 55 }; delete declaration.permissionIdentitySourceSha256; const sources = { displayEventSourceSha256: "src/drivers/acpx/copilot-events.ts", diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index a032ac93e35..248950de9d8 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -317,10 +317,10 @@ describe("qualified ACPX runtime sidecar", () => { } }); - it.each([true, false])("resolves an omitted continuation before the production sidecar validator only with a saved monitor: %s", async eligible => { + it.each([["monitor", true], ["monitor", false], ["same_agent", true], ["same_agent", false]] as const)("resolves an omitted continuation before the production sidecar validator with saved %s authority=%s", async (kind, eligible) => { const raw = { reportedWorkDisposition: "yielded", summary: "The review fix is published.", evidence: [], verification: [], completionClaim: { contractRevision: "1", objectiveSatisfied: false, criteria: [], remainingWork: [{ description: "Wait for checks.", blocksCompletion: true }] } }; - const continuation = { kind: "monitor", summary: "Wait for the saved task monitor.", idempotencyKey: "task-monitor:task:deadline" }; + const continuation = { kind, summary: "Continue from saved task state.", idempotencyKey: "saved:task:receipt" }; const tools = new Map(), emitted: any[] = []; const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult }); const commands = loadActualToolCommands(tools); diff --git a/packages/paperclip-runner/src/contracts/completion-result.test.ts b/packages/paperclip-runner/src/contracts/completion-result.test.ts index 6f908589f6b..d230ef4ef21 100644 --- a/packages/paperclip-runner/src/contracts/completion-result.test.ts +++ b/packages/paperclip-runner/src/contracts/completion-result.test.ts @@ -5,6 +5,7 @@ import { PRP_BLOCK_RESULT_OUTPUT_SCHEMA, PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA, PRP_BLOCK_TOOL_DESCRIPTION, + PRP_COMPLETION_RESULT_BRIDGE_INPUT_SCHEMA, PRP_COMPLETION_RESULT_OUTPUT_SCHEMA, PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA, PRP_COMPLETION_TOOL_DESCRIPTION, @@ -59,6 +60,21 @@ describe("provider-neutral completion result schema", () => { expect(validate(report)).toBe(false); }); + it("carries a normalized workspace continuation only on the internal bridge", () => { + const bridge = new Ajv2020({ strict: false }).compile(PRP_COMPLETION_RESULT_BRIDGE_INPUT_SCHEMA); + const provider = new Ajv2020({ strict: false }).compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA); + const report = { ...baseResult, reportedWorkDisposition: "yielded", continuation: { + kind: "same_agent", summary: "Use the staged task checkout.", idempotencyKey: "repository:task:receipt:run" } }; + expect(bridge(report)).toBe(true); + expect(provider(report)).toBe(false); + for (const invalid of [ + { ...report, reportedWorkDisposition: "done" }, + { ...report, continuation: { ...report.continuation, kind: "retry" } }, + { ...report, continuation: { ...report.continuation, idempotencyKey: "" } }, + ]) expect(bridge(invalid)).toBe(false); + expect(bridge(baseResult)).toBe(true); + }); + it("allows done with no verification and no actionable attention", () => { expect(validate(structuredClone(baseResult))).toBe(true); }); diff --git a/packages/paperclip-runner/src/contracts/completion-result.ts b/packages/paperclip-runner/src/contracts/completion-result.ts index 0165b049781..47862d1fdcb 100644 --- a/packages/paperclip-runner/src/contracts/completion-result.ts +++ b/packages/paperclip-runner/src/contracts/completion-result.ts @@ -384,6 +384,20 @@ export const PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA = { else: { properties: { continuation: { type: "null" } } }, } as const; +export const PRP_COMPLETION_RESULT_BRIDGE_INPUT_SCHEMA = { + ...PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA, + properties: { + ...PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA.properties, + continuation: { + ...PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA.properties.continuation, + properties: { + ...waitContinuationSchema.properties, + kind: { type: "string", enum: [...waitContinuationSchema.properties.kind.enum, "same_agent"] }, + }, + }, + }, +} as const; + export const PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA = { type: "object", additionalProperties: true, diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index 1d4f846e328..4b978c6dbf1 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -87,9 +87,21 @@ vi.mock("./installation-integrity.js", () => ({ verifyNativeAcpxInstallation: vi describe("Copilot build-owned installation", () => { it("admits the current profile with environment launch preparation", () => { - expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(54); + expect(QUALIFIED_ACPX_PROFILES.copilot.agentProfileVersion).toBe(55); expect(QUALIFIED_ACPX_PROFILES.copilot.qualificationStatus).toBeUndefined(); - expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:171d865b9b6ff2eda3926dc957caf2993cd7e1fa1b4359e8b60b83f589bd67fa"); + expect(QUALIFIED_ACPX_PROFILES.copilot.commandDigest).toBe("sha256:2c9e7f0fee36d2f7f0b253a86f3587b7bf2d9406f774b3b91e13678881ee7d48"); + }); + + it("preserves v54 while binding current workspace continuation revalidation in v55", () => { + const prior = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v54-identity.json", import.meta.url), "utf8")); + const current = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v55-identity.json", import.meta.url), "utf8")); + expect(prior.commandDigest).toBe("sha256:171d865b9b6ff2eda3926dc957caf2993cd7e1fa1b4359e8b60b83f589bd67fa"); + expect(Object.keys(current.declaration).filter(key => current.declaration[key] !== prior.declaration[key]).sort()).toEqual([ + "agentProfileVersion", "profileCompatibilitySourceSha256", "runtimeRequestBridgeSourceSha256", + "semanticCompletionContractSourceSha256", "semanticContinuationSourceSha256", + ]); + expect(isSupportedAcpxProfileVersion("copilot", 54)).toBe(true); + expect(current.commandDigest).not.toBe(prior.commandDigest); }); it("preserves v53 while binding repository setup completion guidance in v54", () => { diff --git a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts index bb4f3e36a56..0138382b642 100644 --- a/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/generated-profiles.ts @@ -48,12 +48,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = { "protocolVersion": 1, "acpxVersion": "0.13.1", "agent": "copilot", - "agentProfileVersion": 54, + "agentProfileVersion": 55, "agentServerPackage": "@github/copilot", "agentServerVersion": "1.0.88", "agentRuntimePackage": null, "agentRuntimeVersion": null, - "commandDigest": "sha256:171d865b9b6ff2eda3926dc957caf2993cd7e1fa1b4359e8b60b83f589bd67fa", + "commandDigest": "sha256:2c9e7f0fee36d2f7f0b253a86f3587b7bf2d9406f774b3b91e13678881ee7d48", "permissionPolicy": "interactive" }, "claude": { diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts index 2dbd82f1d3a..ab37fdba06a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts @@ -2,13 +2,13 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js"; /** Decodable historical profile revisions, not permission to launch them. * Exact current profile/package/digest admission is checked separately. */ -type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53; +type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54; type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"]; export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion; const historicalVersions: Readonly> = { pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], - grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53], + grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54], cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], }; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.semantic-results.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.semantic-results.test.ts index e00e90ab37b..8982c48fd5e 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.semantic-results.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.semantic-results.test.ts @@ -47,7 +47,7 @@ import { RUNNERD_CANONICAL_ITEM } from "./codex-driver-values.js"; describe("Codex app-server Codex driver", () => { it.each([ - ["monitor", false], ["monitor", true], ["same_agent", false], ["same_agent", true], + ["monitor", false], ["monitor", true], ["same_agent", false], ["same_agent", true], ["same_agent", "explicit"], ] as const)("uses saved %s continuation with nullable input=%s", async (kind, nullable) => { const transport = new FakeCodexTransport(); const continuation = { kind, summary: "Continue from saved task state", idempotencyKey: "saved:task:receipt" }; @@ -59,7 +59,7 @@ describe("Codex app-server Codex driver", () => { }); try { await session.startTurn({ message: { role: "user", text: "Check the published PR later." } }); - const report = { ...structuredClone(result), reportedWorkDisposition: "yielded", ...(nullable ? { continuation: null } : {}) }; + const report = { ...structuredClone(result), reportedWorkDisposition: "yielded", ...(nullable === "explicit" ? { continuation } : nullable ? { continuation: null } : {}) }; const response = await transport.invoke({ id: "finish-monitor", method: "item/tool/call", params: { threadId: "thread-1", turnId: "turn-1", callId: "finish-monitor", tool: "paperclip_finish", arguments: report, } }); diff --git a/packages/paperclip-runner/src/drivers/completion-continuation.test.ts b/packages/paperclip-runner/src/drivers/completion-continuation.test.ts index 91c09564866..62051110a60 100644 --- a/packages/paperclip-runner/src/drivers/completion-continuation.test.ts +++ b/packages/paperclip-runner/src/drivers/completion-continuation.test.ts @@ -25,6 +25,20 @@ describe("controller-owned completion continuation", () => { .toEqual({ ...yielded, continuation: preparation }); }); + it("rechecks a sidecar-resolved workspace continuation against current server authority", async () => { + const preparation = { kind: "same_agent", summary: "Continue with the prepared repository.", idempotencyKey: "repository:task:receipt:run" }; + const report = { ...yielded, continuation: preparation }; + const read = vi.fn().mockResolvedValue({ ...context, completionContinuation: preparation }); + const resolved = await resolveCompletionContinuation(report, "run", read); + expect(resolved).toEqual(report); + expect(read).toHaveBeenCalledExactlyOnceWith(); + for (const response of [null, { ...context, completionContinuation: { ...preparation, idempotencyKey: "foreign" } }, + { ...context, completionContinuation: { ...preparation, summary: "changed" } }, + { ...context, completionContinuation: preparation, run: { id: "foreign" } }]) { + await expect(resolveCompletionContinuation(report, "run", async () => response)).rejects.toThrow(); + } + }); + it.each([ null, { ...context, run: { id: "other" } }, { ...context, activeTask: { executionRunId: "other" } }, { ...context, completionContinuation: null }, { ...context, completionContinuation: { kind: "response_wake" } }, diff --git a/packages/paperclip-runner/src/drivers/completion-continuation.ts b/packages/paperclip-runner/src/drivers/completion-continuation.ts index 31606f5c213..05fd4ad8b75 100644 --- a/packages/paperclip-runner/src/drivers/completion-continuation.ts +++ b/packages/paperclip-runner/src/drivers/completion-continuation.ts @@ -5,7 +5,8 @@ export async function resolveCompletionContinuation( readTaskContext?: () => Promise, ): Promise { const report = object(value); - if (report.reportedWorkDisposition !== "yielded" || report.continuation != null) return value; + const supplied = object(report.continuation); + if (report.reportedWorkDisposition !== "yielded" || (report.continuation != null && supplied.kind !== "same_agent")) return value; const response = readTaskContext ? await readTaskContext() : null; const text = object(response).type === "inputText" ? object(response).text : response; let context: Record; @@ -19,6 +20,10 @@ export async function resolveCompletionContinuation( typeof continuation.idempotencyKey !== "string" || !continuation.idempotencyKey.trim()) { throw new Error("Cannot yield without a continuation: no eligible saved monitor or workspace continuation exists on this task. Save a monitor for a deferred check, provide an explicit response_wake for a real response wait, or continue the unfinished work. Do not repeat completed actions."); } + if (report.continuation != null && (continuation.kind !== "same_agent" || supplied.summary !== continuation.summary || + supplied.idempotencyKey !== continuation.idempotencyKey)) { + throw new Error("The supplied workspace continuation does not match current server-owned task state. Read the current task context and preserve its authority."); + } return { ...report, continuation: { kind: continuation.kind, summary: continuation.summary, idempotencyKey: continuation.idempotencyKey } }; } diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index e7b8218256c..bc5046222c0 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1,3 +1,4 @@ +import { PRP_COMPLETION_TOOL_NAME, PRP_COMPLETION_RESULT_BRIDGE_INPUT_SCHEMA } from "../contracts/completion-result.js"; import { RunnerLaunchPreparationStore } from "./runner-launch-preparation-store.js"; import type { PreparedRunnerProviderLaunch, RunnerProviderLaunchRequirements } from "./runner-provider-launch.js"; import { validatePreparedRunnerLaunch, runnerProviderLaunchRequirements, createRunnerProviderLaunch, createRunnerdCodexAppServerArgs, trustedRuntimeReadOnlyRoots } from "./runner-provider-launch.js"; @@ -3211,7 +3212,7 @@ function authorizedToolSet( operationId: String(tool.name ?? ""), version: 1, description: String(tool.description ?? ""), - inputSchema: record(tool.inputSchema), + inputSchema: tool.name === PRP_COMPLETION_TOOL_NAME ? PRP_COMPLETION_RESULT_BRIDGE_INPUT_SCHEMA : record(tool.inputSchema), responseSchema: {}, })) .sort((left, right) => diff --git a/packages/paperclip-runner/src/live/runnerd-saved-continuation.test.ts b/packages/paperclip-runner/src/live/runnerd-saved-continuation.test.ts new file mode 100644 index 00000000000..8f750ea11e7 --- /dev/null +++ b/packages/paperclip-runner/src/live/runnerd-saved-continuation.test.ts @@ -0,0 +1,56 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { expect, it, vi } from "vitest"; +import { CodexAppServerDriver } from "../drivers/codex/codex-app-server-driver.js"; +import { createCodexTaskEnvelope } from "../contracts/codex.js"; +import { createCapabilityRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "./runnerd-codex-transport.js"; +import { collectUntilTerminal } from "../drivers/codex/codex-app-server-driver.test-support.js"; + +it.each(["omitted", "null", "resolved"] as const)("retains a server-owned workspace yield through the actual Rust bridge (%s)", async form => { + const root = await mkdtemp(join(tmpdir(), "runnerd-saved-continuation-")), runId = "run-saved-workspace"; + const continuation = { kind: "same_agent", summary: "Continue with the prepared task repository.", idempotencyKey: "repository:task:receipt:run" }; + const report = { schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", summary: "The repository is staged; the requested verification continues.", + completionClaim: { contractRevision: "1", objectiveSatisfied: false, criteria: [{ criterionId: "objective", status: "not_satisfied", evidenceRefs: [] }], + remainingWork: [{ description: "Read the prepared repository and deliver its result.", blocksCompletion: true }] }, + evidence: [], verification: [], attentionRequests: [], artifacts: [], + ...(form === "resolved" ? { continuation } : form === "null" ? { continuation: null } : {}) }; + const reportFile = join(root, "provider-report.json"), callLog = join(root, "provider-calls.txt"); + await writeFile(reportFile, JSON.stringify(report)); + const workspace = join(root, "workspace"); + await Promise.all([mkdir(join(root, "home")), mkdir(workspace)]); + const environment = { PATH: process.env.PATH, HOME: join(root, "home"), CODEX_HOME: join(root, "codex-home"), LANG: "C.UTF-8" }; + const bundle = createCapabilityRunnerdCodexTransport({ + runnerBinary: process.env.PAPERCLIP_ATTACH_TRANSITION_RUNNER ?? defaultCapabilityRunnerdBinary(), + codexCommand: resolve(import.meta.dirname, "../../runner/target/debug/fake-codex-app-server"), + codexArgs: ["--state-file", join(root, "provider-state.json"), "--call-log", callLog, "--terminal-result-file", reportFile], + stateDirectory: root, environment, + prpIdentity: { runnerInstanceId: "runner-saved-workspace", environmentLeaseId: "lease-saved-workspace", runId, normalizedSessionId: "session-saved-workspace", turnId: "turn-saved-workspace", itemId: "item-saved-workspace" }, + }); + const dynamicToolHandler = vi.fn(async () => ({ run: { id: runId }, activeTask: { executionRunId: runId }, completionContinuation: continuation })); + const completionFeedback = vi.fn(async () => "The saved repository continuation is accepted."); + const driver = new CodexAppServerDriver({ approvalPolicy: "never", includeCollaborationModeInstructions: false, environment, + taskEnvelope: createCodexTaskEnvelope({ objective: "Verify the prepared repository.", contractRevision: "1", criteria: [{ id: "objective", requirement: "Deliver the result." }], constraints: [] }), + dynamicTools: [{ name: "get_task_context", description: "Read current task authority.", inputSchema: { type: "object", properties: {}, additionalProperties: false } }], + dynamicToolHandler, completionFeedback, transportFactory: () => bundle.transport, requireProviderSessionIdentity: true }); + let session: Awaited> | undefined; + try { + session = await driver.openSession({ runId, normalizedSessionId: "session-saved-workspace", workingDirectory: workspace }); + await session.startTurn({ message: { role: "user", text: "Continue with the prepared checkout." } }); + const events = await collectUntilTerminal(session.events()); + expect(completionFeedback).toHaveBeenCalledExactlyOnceWith({ ...report, continuation }); + expect(dynamicToolHandler).toHaveBeenCalledOnce(); + expect(events.filter(event => event.eventType === "run.result.proposed")).toHaveLength(1); + expect(events.at(-1)?.eventType).toBe("turn.completed"); + expect((await session.snapshot()).semanticResult?.result).toEqual({ ...report, continuation }); + expect(await readFile(callLog, "utf8")).toContain("terminal-result:accepted"); + const journal = JSON.parse(await readFile(join(root, "control-plane/control-plane-state.json"), "utf8")); + const terminals = journal.committedEvents.filter((event: { eventType: string }) => event.eventType === "run.terminal"); + expect(terminals).toHaveLength(1); + expect(terminals[0].envelope.payload).toMatchObject({ runId, eventType: "run.terminal", payload: { runTerminalState: "succeeded", reportedWorkDisposition: "yielded" } }); + } finally { + await session?.close(); + await bundle.transport.close(); + await rm(root, { recursive: true, force: true }); + } +}, 30_000); diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v55-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v55-identity.json new file mode 100644 index 00000000000..2e9a43ec690 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v55-identity.json @@ -0,0 +1,84 @@ +{ + "commandDigest": "sha256:2c9e7f0fee36d2f7f0b253a86f3587b7bf2d9406f774b3b91e13678881ee7d48", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 55, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "de2d38d702b3c574161af0c0c04790fb6f6347916eb3916ce7a131a8c229121b", + "permissionAdapterSourceSha256": "ee5929ed2ee647ad0369465dee9d51a00b2dd28e1ff735b43e969ddc9c164668", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "ad6770c69269087951790e2e87e22ac088e3d432769adf17ebc865ed512e6c4f", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "89e3dde25ff35bfd4d7f520fd9d14af3cb9027f182a2d51431d298f1015be580", + "toolEvidenceSourceSha256": "6eef5039afc75bb851eaf9cd82a6f099e4bd398ca2deb59dd66f5b2a81a7a706", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "cc90b59bdfe6a82f7fa3e819e8671f71f7b14047e68942e8086618ffbaacf59f", + "semanticDirectDriverSourceSha256": "ff572638f2cb5d9f8249ff8e2f412311c1e3af209ef0bf7e5ea9f10a21d91103", + "semanticValidationSourceSha256": "02fbb92681c78c5de5d2f1aae25aabc25a0e5df6b0ff4baccac652192a8da975", + "semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e", + "semanticCompletionContractSourceSha256": "3343df6362252d44d12b079ff78c8f6539d1544f3ce5ec36ff36db70ab0fac31", + "semanticValidatorsSourceSha256": "f22d9caf2035b600abc1a6625ae23ca97f1409d7e29ed5b7264bb8f9784c7dc9", + "semanticSchemaBundleSourceSha256": "016391a087cc217aafed4ba1f4efafc6e4881a7525952dce824b7f4d24530603", + "semanticSidecarProtocolSourceSha256": "03c0c95b900f9dc18f29f6be27b8c79bc0bcab29780190d46a74e95328081236", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1", + "displayEventSourceSha256": "d72f9a6035c247bc6a42c067c05753076c54322c4b58984e93922f3325dc6877", + "displayProjectionSourceSha256": "75952606b26dd93bbc582c1f0a4424487377272ea745fb3a27ceae031fd5dc7e", + "configuredEnvironmentSourceSha256": "5c9008d121979a3a8987e6b8a5752172eafdc4a2b5d725a73f3bbb8fb83be8ff", + "configuredEnvironmentRustSourceSha256": "85c87fae62b8a9377e4f0a4c00302c7519fae4397b1c48253f8f2b7ba7faf994", + "profileActivitySourceSha256": "4fdb6b0f9125fd8372ba6f27945fa5c8a3d3b2ec891df544092e3dc7a75d5282", + "profileExtensionsSourceSha256": "a80ddc95eb4505095bfdbbf85c758199949ab20be9c84b34c77f762eb7180571", + "profileInstallationSourceSha256": "e4aacb08015fa9104dce2b0c7d150cc432231c8a3e07d2cec2a325b388f7f382", + "attachmentPolicySourceSha256": "4f7638b7cc64e18f6a4ba33fd46de2b3d49225e32aeab41f5c695b268fa4f8e2", + "singleReadEvidenceSourceSha256": "4069393405089bde613cecec04f5123c426594811da4c668bd96f0e7e283550f", + "nativeSessionRuntimeSourceSha256": "e8a3bee66a1ff1c6f251b5ae57453ffb908336da9c4a6d98f41f94f7ee94a8e8", + "nativeRuntimeContextSourceSha256": "df93bc8bcfa9dfdcf26ad6a69ea0bd3e56ef186c5e962b243fb2288f7074584b", + "nativeSessionContractSourceSha256": "22a8c96ce41ba4f03e976fc37f0cc7c5d05cee99fa789941da31ee1dab440b90", + "harnessSessionContractSourceSha256": "03f4cd7f76f34f6d82e009b66aa51f18f6e23dc235b660778060e35b6ea07412", + "harnessBackendSourceSha256": "dc827e281ae6731545b166ee4d709a4f67f594a556dcbe63c70fe8f1aca26249", + "harnessSessionSourceSha256": "81361733e86b56dd5a66415aefc6f72e69be24acc8d3417370154ebb7fef6e94", + "transportContractSourceSha256": "4fa9593226ff3280bc12140bfcbc2c0986e4cc8d4733381c334138dd03b08f46", + "nativeBackendSourceSha256": "2406483fd35a564971c22e10fc2bad26bae16ff5a3fb6c65690fcf156522ab6d", + "nativeBackendFactorySourceSha256": "7e431c926b76d53c4c08e3a4105353dcd34bd940f6588a1a68f79631f6feeb44", + "nativeInstructionsSourceSha256": "ee8df5d85b263d5d2d0dc9ede8602cb7daf6388687108fe05e2371f5f56c3492", + "providerLaunchSourceSha256": "cba71368f82ae8d300123efa09f23744e19e82142788fa9ad238338fefec9624", + "launchPreparationStoreSourceSha256": "ff6025568bbc0aa6ce545772d73dfca2d3da226740bd3a7b386b069e76cff296", + "runtimeRequestBridgeSourceSha256": "fb2e91e9a7f4b10ae0d936e1e1c74ac9095733ef67fe816491be45791048efa1", + "runtimeRequestFacadeSourceSha256": "d0ed28e0c5ddad34db8f437e704c8a755eb0b47f27e08e44185f66d1d2205295", + "runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211", + "guardedInstallationSourceSha256": "f6d0c33de38259efefa0f4e0f9737cd30cfd3996a8e875e3fbdb8c72dc133dca", + "nativeExecutionSourceSha256": "fc7c6568feb0d8d709e9baa51839420c9b90336b4865a9b8405aba06e4d1e9c4", + "profileCompatibilitySourceSha256": "d4edec781031a44f7e96a1e4ef398af4675529b7ec0f9c56c4915d9ec81a20cf", + "installationSourceSha256": "a1c7c6f103b898216973e77ffbac0ef7572e250aa574d1a4065270e9dfa0dc2d", + "assetOwnershipSourceSha256": "1785e3f31e5dad5a13b53e25d8cf9482a9bd89a27b6c127830aed6b81acc605c", + "assetInstallerSourceSha256": "d3bd9514e5920d0cc3567aa50487fb534c57a6f66b8da0d3f22c5f6e499c841f", + "credentialBindingSourceSha256": "54fd09ad430dac04df98d829061f6701e73b5bf2a0f4de836d2ac36ee511009f", + "sidecarErrorSourceSha256": "b09af4577f8bf42eee7036b2ad9842202cfc763e72b53c3287f82c6090cdad80", + "profileSourceSha256": "f5538b3dbefa7634a332b87d5ededcefb56aa7e5790cede1c7a0d948a548a456", + "metadataProbeSourceSha256": "be4c8416edd27061109bc1f33d0c26071b4facbb3f651beaae6b3c0275cce727", + "permissionToolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticContinuationSourceSha256": "d248e47a0fe7f2536d1e64cea0b0b410f34104800cd643d995b4473104ecc8e0", + "runtimeHostSourceSha256": "60e5454f030e87f2afa8b1fb120b26adb126b6b17d0e5e82f49c77f76f466cbb", + "runtimeAdapterSourceSha256": "e4c764c2c3c7dd655a965b39472bd1ad8d577d1e7258b25594b171c9429752b8", + "runtimeSandboxSourceSha256": "92dfa53f5a3ed0062913ae417eeccec243b430d444495efde2a8e9c4c02e78ea", + "cancellationSourceSha256": "a07d1a9e982dec0ae130c1ad1efa5bc75c6a39e013b730ba3ee45256f09ceeca", + "agentFilesSourceSha256": "fb32f6badc13dfa22f231479509b631b2f0eba0c99b1ab9f3cd5c0c601aa03ba", + "nativeDistributionSourceSha256": "aed327c389e957483a503cb1bb8b247ee7cd7ff4b528fb4567c39bcb4577690b" + } +} diff --git a/tests/runner-e2e/daytona-image-content.ts b/tests/runner-e2e/daytona-image-content.ts index 9ae25ba8c5f..cfd6e80df92 100644 --- a/tests/runner-e2e/daytona-image-content.ts +++ b/tests/runner-e2e/daytona-image-content.ts @@ -59,6 +59,7 @@ export const DAYTONA_IMAGE_INPUT_PATHS = [ "packages/paperclip-runner/test/fixtures/copilot-profile-v48-identity.json", "packages/paperclip-runner/test/fixtures/copilot-profile-v49-identity.json", "packages/paperclip-runner/test/fixtures/copilot-profile-v54-identity.json", + "packages/paperclip-runner/test/fixtures/copilot-profile-v55-identity.json", "packages/paperclip-runner/scripts/materialize-cursor-distribution.mjs", "packages/paperclip-runner/scripts/cursor-runtime-patch.mjs", "packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs",