From c2aa1ec0c31fac84bf347e3f0ec24666a09d0e35 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 11 Sep 2026 17:01:54 -0500 Subject: [PATCH 1/4] fix(ci): validate shared Cloud image contexts throughout the stack Co-Authored-By: Paperclip --- .github/workflows/docker-cloud.yml | 14 +++++++++ .github/workflows/docker.yml | 24 ++++++++++++++ .../src/__tests__/docker-build-stamp.test.ts | 31 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/.github/workflows/docker-cloud.yml b/.github/workflows/docker-cloud.yml index 62025d3a85..38fb18e2f7 100644 --- a/.github/workflows/docker-cloud.yml +++ b/.github/workflows/docker-cloud.yml @@ -3,6 +3,13 @@ name: Docker cloud on: workflow_dispatch: workflow_call: + inputs: + staging_artifact_base_url: + type: string + default: "" + staging_lock_sha256: + type: string + default: "" permissions: {} @@ -98,9 +105,16 @@ jobs: node-version: 24 - name: Refresh lockfile for Docker build context + env: + STAGING_ARTIFACT_BASE_URL: ${{ inputs.staging_artifact_base_url }} + EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }} run: | set -euo pipefail pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + if [ -n "$STAGING_ARTIFACT_BASE_URL" ]; then + [[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]] + echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict + fi changed="$(git status --porcelain)" if [ -z "$changed" ]; then diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index bbb830b080..d74f75fd1d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,6 +19,11 @@ on: type: string default: "" + staging_lock_sha256: + description: Reviewed SHA-256 of the resolved pnpm 9 lockfile; required with staging_artifact_base_url + type: string + default: "" + # Least privilege: nothing at the workflow level; each job declares exactly # the token scopes it uses (checkout needs contents:read, GHCR pushes need # packages:write). @@ -59,6 +64,14 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 24 + - name: Resolve and verify staging dependencies before installation + env: + EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }} + run: | + set -euo pipefail + [[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]] + pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict - run: pnpm install --frozen-lockfile - name: Build matching migrator artifacts env: @@ -172,10 +185,18 @@ jobs: node-version: 24 - name: Refresh lockfile for Docker build context + env: + STAGING_ARTIFACT_BASE_URL: ${{ inputs.staging_artifact_base_url }} + EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }} run: | set -euo pipefail pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + if [ -n "$STAGING_ARTIFACT_BASE_URL" ]; then + [[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]] + echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict + fi + changed="$(git status --porcelain)" if [ -z "$changed" ]; then echo "Lockfile already matches package metadata." @@ -406,6 +427,9 @@ jobs: build-and-push-cloud: if: github.event_name != 'push' || github.ref != 'refs/heads/master' uses: ./.github/workflows/docker-cloud.yml + with: + staging_artifact_base_url: ${{ inputs.staging_artifact_base_url || '' }} + staging_lock_sha256: ${{ inputs.staging_lock_sha256 || '' }} permissions: contents: read packages: write diff --git a/server/src/__tests__/docker-build-stamp.test.ts b/server/src/__tests__/docker-build-stamp.test.ts index 4794989feb..26a4737957 100644 --- a/server/src/__tests__/docker-build-stamp.test.ts +++ b/server/src/__tests__/docker-build-stamp.test.ts @@ -76,3 +76,34 @@ describe("docker build-stamp wiring", () => { ).toBeGreaterThanOrEqual(2); }); }); + + +describe("staging dependency integrity", () => { + it("verifies the reviewed resolved lock before installing the migrator", () => { + const migrator = workflow.split(" staging-migrator:")[1].split(" build-and-push:")[0]; + const verification = migrator.indexOf('echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict'); + expect(migrator).toContain("EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }}"); + expect(migrator).toContain('[[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]]'); + expect(verification).toBeGreaterThan(migrator.indexOf("pnpm install --resolution-only --ignore-scripts")); + expect(verification).toBeLessThan(migrator.indexOf("pnpm install --frozen-lockfile")); + }); + + it("checks the same lock in both staging app build contexts", () => { + const caller = workflow.split(" build-and-push-cloud:")[1].split(" # Moves")[0]; + expect(caller).toContain("uses: ./.github/workflows/docker-cloud.yml"); + expect(caller).toContain("staging_artifact_base_url: ${{ inputs.staging_artifact_base_url || '' }}"); + expect(caller).toContain("staging_lock_sha256: ${{ inputs.staging_lock_sha256 || '' }}"); + const contexts = [workflow, cloudWorkflow].flatMap((source) => + source.split(" - name: Refresh lockfile for Docker build context").slice(1)); + expect(contexts).toHaveLength(2); + for (const context of contexts) { + const refresh = context.split(" - name:")[0]; + expect(refresh).toContain("STAGING_ARTIFACT_BASE_URL: ${{ inputs.staging_artifact_base_url }}"); + expect(refresh).toContain("EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }}"); + expect(refresh).toContain('if [ -n "$STAGING_ARTIFACT_BASE_URL" ]; then'); + const verification = refresh.indexOf('echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict'); + expect(verification).toBeGreaterThan(refresh.indexOf("pnpm install --resolution-only --ignore-scripts")); + expect(verification).toBeLessThan(refresh.indexOf('changed="$(git status --porcelain)"')); + } + }); +}); From fe13eadb8afd9cfb314b5d3dc134b19b924508bc Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 11 Sep 2026 17:02:29 -0500 Subject: [PATCH 2/4] test(runner): serve goal reads while holding passive completion notices Co-Authored-By: Paperclip --- .../src/bin/fake-codex-app-server.rs | 111 ++++++++++-------- 1 file changed, 63 insertions(+), 48 deletions(-) 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(); From 201f78c2efdab798b0db8e76c1636650b32518e1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 11 Sep 2026 17:02:30 -0500 Subject: [PATCH 3/4] fix(runner): preserve exact Claude model selectors during ACP admission Co-Authored-By: Paperclip --- .../src/drivers/acpx/codex-runtime-adapter.test.ts | 8 ++++++-- .../src/drivers/acpx/codex-runtime-adapter.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) 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, From 92e3f139fd354d40f658405c1520c0ac3c9c339e Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 11 Sep 2026 17:02:30 -0500 Subject: [PATCH 4/4] fix(runner): preserve v2 event metadata and reject nonretryable campaign failures Co-Authored-By: Paperclip --- server/src/__tests__/redaction.test.ts | 31 ++++++++++++++++++++++++++ server/src/redaction.ts | 23 +++++++++++++++---- tests/runner-e2e/failure-classifier.ts | 2 +- tests/runner-e2e/run-observations.ts | 10 +++++++++ tests/runner-e2e/runner.spec.ts | 9 +++----- tests/runner-e2e/support.test.ts | 27 ++++++++++++++++++++++ 6 files changed, 91 insertions(+), 11 deletions(-) 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); + }); +});