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 3490a4c1b0..18059de632 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 @@ -1619,56 +1619,71 @@ fn run() -> Result<(), Box> { }))?; } if emit_post_completion_passive_statuses { - if let Some(gate) = post_completion_notification_gate.as_ref() { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - while !gate.is_file() { - if std::time::Instant::now() >= deadline { - return Err( - "post-completion notification gate timed out".into() - ); + let gate = post_completion_notification_gate.clone(); + let thread_id = state.thread_id.clone(); + let tail_turn_id = provider_turn_id.clone(); + // Goal reconciliation reads must remain responsive while + // the test holds back the post-terminal passive notices. + thread::spawn(move || { + let result = (|| -> io::Result<()> { + if let Some(gate) = gate.as_ref() { + let deadline = + std::time::Instant::now() + Duration::from_secs(5); + while !gate.is_file() { + if std::time::Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "post-completion notification gate timed out", + )); + } + thread::sleep(Duration::from_millis(1)); + } } - thread::sleep(Duration::from_millis(1)); + for notification in [ + json!({ + "method": "deprecationNotice", + "params": {"summary": "A provider setting is deprecated", "details": null} + }), + json!({ + "method": "remoteControl/status/changed", + "params": {"status": "disabled", "environmentId": null} + }), + json!({ + "method": "mcpServer/startupStatus/updated", + "params": {"name": "codex_apps", "status": "ready", "error": null} + }), + json!({ + "method": "account/rateLimits/updated", + "params": {"rateLimits": {}} + }), + json!({ + "method": "rawResponseItem/completed", + "params": {"threadId": thread_id, "turnId": tail_turn_id, "item": {"id": "raw-tail", "type": "reasoning"}} + }), + json!({ + "method": "rawResponse/completed", + "params": {"threadId": thread_id, "turnId": tail_turn_id, "response": {"id": "response-tail"}} + }), + json!({ + "method": "thread/goal/updated", + "params": {"threadId": thread_id, "goal": "finish the turn"} + }), + json!({ + "method": "thread/goal/cleared", + "params": {"threadId": thread_id} + }), + ] { + send(notification)?; + } + if let Some(gate) = gate.as_ref() { + fs::write(gate.with_extension("emitted"), b"emitted")?; + } + Ok(()) + })(); + if let Err(error) = result { + eprintln!("post-completion passive tail failed: {error}"); } - } - for notification in [ - json!({ - "method": "deprecationNotice", - "params": {"summary": "A provider setting is deprecated", "details": null} - }), - json!({ - "method": "remoteControl/status/changed", - "params": {"status": "disabled", "environmentId": null} - }), - json!({ - "method": "mcpServer/startupStatus/updated", - "params": {"name": "codex_apps", "status": "ready", "error": null} - }), - json!({ - "method": "account/rateLimits/updated", - "params": {"rateLimits": {}} - }), - json!({ - "method": "rawResponseItem/completed", - "params": {"threadId": state.thread_id, "turnId": provider_turn_id, "item": {"id": "raw-tail", "type": "reasoning"}} - }), - json!({ - "method": "rawResponse/completed", - "params": {"threadId": state.thread_id, "turnId": provider_turn_id, "response": {"id": "response-tail"}} - }), - json!({ - "method": "thread/goal/updated", - "params": {"threadId": state.thread_id, "goal": "finish the turn"} - }), - json!({ - "method": "thread/goal/cleared", - "params": {"threadId": state.thread_id} - }), - ] { - send(notification)?; - } - if let Some(gate) = post_completion_notification_gate.as_ref() { - fs::write(gate.with_extension("emitted"), b"emitted")?; - } + }); } if emit_post_completion_foreign_turn { let gate = post_completion_notification_gate.clone(); 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 45ad16975f..9ebcda1bd6 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 @@ -101,7 +101,10 @@ describe("Codex ACPX runtime adapter", () => { }); }); - it.each([["claude" as const, "claude-sonnet-5", "claude-sonnet-5"]])( + it.each([ + ["claude" as const, "claude-sonnet-5", "claude-sonnet-5"], + ["claude" as const, "custom-claude-model", "custom-claude-model"], + ])( "opens the qualified %s session through the verified lease", async (agent, model, providerModel) => { const runtime = fakeRuntime(); @@ -109,7 +112,7 @@ describe("Codex ACPX runtime adapter", () => { const options = openOptions(command); let runtimeOptions: AcpRuntimeOptions | undefined; options.profile = resolveQualifiedAcpxProfile(agent, model); - options.launchEnvironment = { PATH: "/verified/bin" }; + options.launchEnvironment = { PATH: "/verified/bin", ANTHROPIC_CUSTOM_MODEL_OPTION: "stale-model" }; await openCodexAcpxRuntime(options, { createRegistry: ({ overrides }) => { @@ -128,6 +131,7 @@ describe("Codex ACPX runtime adapter", () => { expect(runtimeOptions?.spawnEnvironment?.()).toEqual({ PATH: "/verified/bin", PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", + ANTHROPIC_CUSTOM_MODEL_OPTION: providerModel, }); expect(runtime.ensureSession).toHaveBeenCalledWith( expect.objectContaining({ 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 b92419eec6..a5c1d2d456 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -317,7 +317,14 @@ export async function openQualifiedAcpxRuntime( spawnEnvironment: () => ({ ...definedEnvironment(options.launchEnvironment), ...(options.profile.agent === "claude" - ? { PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1" } + ? { + PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", + // Claude ACP otherwise maps concrete IDs back to rolling picker + // aliases (e.g. claude-sonnet-5 -> sonnet). Advertise the exact + // host-requested ID so model verification stays exact on resume + // and before the first billable prompt. + ANTHROPIC_CUSTOM_MODEL_OPTION: options.profile.reportedModelId, + } : {}), }), spawnCwd: options.cwd, diff --git a/server/src/__tests__/redaction.test.ts b/server/src/__tests__/redaction.test.ts index a4c903ec77..1d721a2713 100644 --- a/server/src/__tests__/redaction.test.ts +++ b/server/src/__tests__/redaction.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { PRP_V1_EVENT_TYPES, + PRP_V2_EVENT_TYPES, REDACTED_EVENT_VALUE, redactAgentAdapterConfig, redactEventPayload, @@ -23,6 +24,36 @@ describe("redaction", () => { expect([...PRP_V1_EVENT_TYPES]).toEqual(schema.properties.eventType.enum); }); + it("keeps the discriminator allowlist in exact PRP v2 schema parity", () => { + const schema = JSON.parse(readFileSync(new URL( + "../../../packages/paperclip-runner/protocol/schemas/event-v2.schema.json", + import.meta.url, + ), "utf8")) as { properties: { eventType: { enum: string[] } } }; + expect([...PRP_V2_EVENT_TYPES].sort()).toEqual(schema.properties.eventType.enum.sort()); + }); + + it("preserves v2 goal envelopes while redacting secrets and mismatched versions", () => { + for (const eventType of PRP_V2_EVENT_TYPES) { + const envelope = { + schema: "paperclip.prp.event.v2", schemaVersion: 2, eventType, + payload: { apiKey: "secret", opaque: "aaa.bbb.ccc" }, + }; + expect(sanitizeRecord(envelope)).toEqual({ + ...envelope, + payload: { apiKey: REDACTED_EVENT_VALUE, opaque: REDACTED_EVENT_VALUE }, + }); + } + for (const envelope of [ + { schema: "paperclip.prp.event.v1", schemaVersion: 1, eventType: "session.goal.snapshot" }, + { schema: "paperclip.prp.event.v2", schemaVersion: 1, eventType: "session.goal.snapshot" }, + { schema: "paperclip.prp.event.v1", schemaVersion: 2, eventType: "session.goal.snapshot" }, + { schema: "paperclip.prp.event.v2", schemaVersion: 2, eventType: "aaa.bbb.ccc" }, + { schema: "paperclip.prp.event.v3", schemaVersion: 3, eventType: "session.goal.snapshot" }, + ]) { + expect(sanitizeRecord(envelope).eventType).toBe(REDACTED_EVENT_VALUE); + } + }); + it("preserves every discriminator in the cross-language replay stream", () => { const fixture = JSON.parse( readFileSync( diff --git a/server/src/redaction.ts b/server/src/redaction.ts index ebbbff26af..925a405f1e 100644 --- a/server/src/redaction.ts +++ b/server/src/redaction.ts @@ -110,6 +110,7 @@ export const PAPERCLIP_PUBLIC_SCHEMA_IDS = new Set([ "paperclip.prp.command.v1", "paperclip.prp.contract_manifest.v1", "paperclip.prp.event.v1", + "paperclip.prp.event.v2", "paperclip.prp.fixture.v1", "paperclip.prp.identity.v1", "paperclip.prp.semantic_tool.v1", @@ -194,7 +195,7 @@ export const PAPERCLIP_PUBLIC_SCHEMA_IDS = new Set([ // Keep this closed catalog aligned with PRP v1's event.schema.json. These // values are public protocol discriminators, but their dotted shape overlaps // the deliberately broad JWT heuristic. They are exempt only in the -// discriminator field of a PRP v1 event envelope; the same string anywhere +// discriminator field of a supported PRP event envelope; the same string anywhere // else remains subject to redaction. export const PRP_V1_EVENT_TYPES = new Set([ "runner.connected", @@ -303,6 +304,17 @@ export const PRP_V1_EVENT_TYPES = new Set([ "issue.status.decision.superseded", "run.terminal", ]); +// V2 removes backpressure and semantic reconciliation, and adds goal events. +// Exact parity with both protocol schemas is enforced by redaction.test.ts. +export const PRP_V2_EVENT_TYPES = new Set([ + ...[...PRP_V1_EVENT_TYPES].filter( + (type) => type !== "runner.backpressure" && type !== "semantic_tool.reconciled", + ), + "session.capabilities.updated", + "session.goal.snapshot", + "session.goal.updated", + "session.goal.cleared", +]); const NATIVE_RUN_SPAN_SCHEMA = "paperclip.run-performance-span.v1"; const NATIVE_RUN_SPAN_FIELDS = ["span", "parentSpan"] as const; const NATIVE_RUN_SPAN_NAMES = new Set([ @@ -843,10 +855,13 @@ function isKnownPrpEventDiscriminator( ): value is string { return ( key === "eventType" && - container.schema === "paperclip.prp.event.v1" && - container.schemaVersion === 1 && typeof value === "string" && - PRP_V1_EVENT_TYPES.has(value) + ((container.schema === "paperclip.prp.event.v1" && + container.schemaVersion === 1 && + PRP_V1_EVENT_TYPES.has(value)) || + (container.schema === "paperclip.prp.event.v2" && + container.schemaVersion === 2 && + PRP_V2_EVENT_TYPES.has(value))) ); } diff --git a/tests/runner-e2e/failure-classifier.ts b/tests/runner-e2e/failure-classifier.ts index 0073d906ae..c480b1e9dd 100644 --- a/tests/runner-e2e/failure-classifier.ts +++ b/tests/runner-e2e/failure-classifier.ts @@ -3,7 +3,7 @@ import type { FailureClass } from "./types.js"; const TRANSIENT = /(?:\b429\b|\b5\d\d\b|rate.?limit|ECONN(?:RESET|REFUSED)|socket hang up|network (?:error|interruption|timeout)|service unavailable|(?:provider|server|bootstrap|browser|webserver|health|daytona|sandbox|ingress|preview|connection|harness).*(?:temporar|timed? out|timeout|closed|failed|unavailable|interrupt|reset|refused|create|start|connect)|(?:timed? out|timeout).*(?:provider|server|bootstrap|browser|webserver|health|daytona|sandbox|ingress|preview|connection|harness))/i; const PERMANENT = - /(?:missing (?:credential|fixture secret)|invalid.*(?:credential|api key)|unauthorized|forbidden|qualification|model.*(?:unsupported|incompatible)|artifact.*incompatible|runner_remote_.*(?:incompatible|unavailable)|immutable image digest)/i; + /(?:retryable=false|effective_model_mismatch|missing (?:credential|fixture secret)|invalid.*(?:credential|api key)|unauthorized|forbidden|qualification|model.*(?:unsupported|incompatible)|artifact.*incompatible|runner_remote_.*(?:incompatible|unavailable)|immutable image digest)/i; const CANDIDATE = /(?:matcher|expected.*observed|marker|issue status|run status|runtime mode|wrong output|missing output)/i; diff --git a/tests/runner-e2e/run-observations.ts b/tests/runner-e2e/run-observations.ts index e0f18d49ce..0f87acc55c 100644 --- a/tests/runner-e2e/run-observations.ts +++ b/tests/runner-e2e/run-observations.ts @@ -277,3 +277,13 @@ export function numberedPlanStepCount(body: string | null | undefined) { ); }).length; } + +/** Validate the persisted protocol version without discarding v1 upgrade history. */ +export function hasConsistentPrpEventVersion( + envelope: Record, + protocolSchemaVersion: unknown, +): boolean { + return (envelope.schemaVersion === 1 || envelope.schemaVersion === 2) + && envelope.schema === `paperclip.prp.event.v${envelope.schemaVersion}` + && protocolSchemaVersion === envelope.schemaVersion; +} diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index c23d36aa5a..9a2f879ea2 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -12,6 +12,7 @@ import { setupLiveFixtures, type LiveFixtureValues } from "./live-fixtures.js"; import { evaluateMatcher, type MatcherResult } from "./matchers.js"; import { acceptedPlanSessionResetFailures, + hasConsistentPrpEventVersion, hasTerminalMalformedPlanConfirmation, isControlPlaneGovernedResponseWait, isNonExecutingReviewFenceRun, @@ -471,12 +472,8 @@ function nativeRunEventIntegrityFailures( } const envelope = record(event.payload?.prpEvent); if (Object.keys(envelope).length === 0) continue; - if ( - envelope.schema !== "paperclip.prp.event.v1" || - envelope.schemaVersion !== 1 || - event.protocolSchemaVersion !== 1 - ) { - failures.push(`run ${run.id} exposed a malformed PRP v1 envelope`); + if (!hasConsistentPrpEventVersion(envelope, event.protocolSchemaVersion)) { + failures.push(`run ${run.id} exposed a malformed PRP envelope`); } if (envelope.runId !== run.id) { failures.push( diff --git a/tests/runner-e2e/support.test.ts b/tests/runner-e2e/support.test.ts index d22ba6a568..fed018a617 100644 --- a/tests/runner-e2e/support.test.ts +++ b/tests/runner-e2e/support.test.ts @@ -35,6 +35,7 @@ import { } from "./ports.js"; import { acceptedPlanSessionResetFailures, + hasConsistentPrpEventVersion, hasTerminalMalformedPlanConfirmation, isControlPlaneGovernedResponseWait, isNonExecutingReviewFenceRun, @@ -737,6 +738,14 @@ describe("runner E2E failure policy", () => { }); describe("runner E2E server isolation", () => { + it("does not retry a provider rejection explicitly marked nonretryable", () => { + const failure = classifyFailure(new Error( + "failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=false, classification=effective_model_mismatch)", + )); + expect(failure).toBe("permanent_infrastructure"); + expect(shouldRetryFailure(failure)).toBe(false); + }); + it("shares restart control files beneath the isolated temporary root", () => { expect(runnerE2EServerControlPaths("/tmp/cell")).toEqual({ controlDirectory: path.join("/tmp/cell", "control"), @@ -1159,3 +1168,21 @@ describe("runner E2E macOS shared-memory cleanup", () => { ]); }); }); + +describe("persisted native event versions", () => { + it.each([1, 2])("accepts matching PRP v%i events", (version) => { + expect(hasConsistentPrpEventVersion({ + schema: `paperclip.prp.event.v${version}`, schemaVersion: version, + }, version)).toBe(true); + }); + it.each([ + ["paperclip.prp.event.v1", 2, 2], + ["paperclip.prp.event.v2", 1, 1], + ["paperclip.prp.event.v2", 2, 1], + ["paperclip.prp.event.v3", 3, 3], + ["***REDACTED***", 2, 2], + ["paperclip.prp.event.v2", "2", 2], + ])("rejects inconsistent version metadata %s/%s/%s", (schema, version, outer) => { + expect(hasConsistentPrpEventVersion({schema, schemaVersion: version}, outer)).toBe(false); + }); +});