Merge branch 'codex/work-folders-remote-recovery-refresh' into codex/work-folders-session-compat-refresh

* codex/work-folders-remote-recovery-refresh:
  fix(runner): preserve v2 event metadata and reject nonretryable campaign failures
  fix(runner): preserve exact Claude model selectors during ACP admission
  test(runner): serve goal reads while holding passive completion notices
  fix(ci): validate shared Cloud image contexts throughout the stack
This commit is contained in:
Dotta 2026-09-11 17:02:41 -05:00
commit b48689cb00
9 changed files with 168 additions and 62 deletions

View File

@ -1619,56 +1619,71 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
}))?;
}
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();

View File

@ -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({

View File

@ -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,

View File

@ -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(

View File

@ -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)))
);
}

View File

@ -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;

View File

@ -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<string, unknown>,
protocolSchemaVersion: unknown,
): boolean {
return (envelope.schemaVersion === 1 || envelope.schemaVersion === 2)
&& envelope.schema === `paperclip.prp.event.v${envelope.schemaVersion}`
&& protocolSchemaVersion === envelope.schemaVersion;
}

View File

@ -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(

View File

@ -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);
});
});