diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index a295eb7207..806d754c26 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::fs::{self, DirBuilder, File}; use std::io::{Read, Write}; +use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -33,7 +34,7 @@ use crate::provider_events::{ use crate::qualified_launch::verify_launch_artifact; pub const ACPX_PROVIDER_STATE_FILE: &str = "acpx-provider-state.json"; -const ACPX_PROVIDER_STATE_SCHEMA: &str = "paperclip.runner.acpx-provider-state.v2"; +const ACPX_PROVIDER_STATE_SCHEMA: &str = "paperclip.runner.acpx-provider-state.v3"; const MAX_PROVIDER_STATE_BYTES: u64 = 16 * 1024 * 1024; const MAX_PENDING_EVENTS: usize = 8_320; const MAX_EVENTS_PER_POLL: usize = 128; @@ -51,6 +52,31 @@ fn event_sequence(value: &str) -> Option { (event_id(sequence) == value).then_some(sequence) } +fn acquire_provider_lifetime_fence( + candidates: [u16; 3], +) -> Result, DurableRunnerError> { + let mut listeners = Vec::with_capacity(2); + for port in candidates { + match TcpListener::bind(("127.0.0.1", port)) { + Ok(listener) => { + listeners.push(listener); + if listeners.len() == 2 { + return Ok(listeners); + } + } + Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => {} + Err(error) => { + return Err(DurableRunnerError::invalid(format!( + "failed to prove ACPX provider lifetime cleanup: {error}" + ))) + } + } + } + Err(DurableRunnerError::invalid( + "ACPX original provider lifetime remains active; cleanup is not yet proven", + )) +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct AcpxProviderDescriptor { @@ -1164,16 +1190,21 @@ impl CommandExecutor for AcpxCommandExecutor { .state .as_ref() .is_some_and(|state| state.provider_exit_unconfirmed); - if self.session.is_none() && provider_exit_unconfirmed { - // The prior provider lifetime owns a kernel-backed quorum until its - // guardian and process group have exited. Reopening the exact - // persistent session can succeed only after that quorum is free; - // the replacement then gives this executor a live handle whose - // shutdown provides positive cleanup acknowledgement. Never clear - // the durable terminal fence based only on the absence of an - // in-memory session in this replacement process. - self.session = Some(self.start_session(true)?); - } + // The prior provider, guardian, and sidecar inherit two listeners from + // this exact three-port set. A replacement can bind any two only after + // the original lifetime has lost quorum. Keep the acquired quorum live + // through the durable state update so no successor can race the proof. + let _provider_lifetime_fence = if self.session.is_none() && provider_exit_unconfirmed { + let candidates = self + .state + .as_ref() + .and_then(|state| state.identity.as_ref()) + .expect("provider cleanup state has a validated identity") + .provider_lifetime_fence_candidates; + Some(acquire_provider_lifetime_fence(candidates)?) + } else { + None + }; if let Some(session) = self.session.as_mut() { session .shutdown("runner process shutdown") @@ -1426,6 +1457,7 @@ mod tests { requested_model: "gpt-5.6-sol".to_owned(), effective_model: "gpt-5.6-sol".to_owned(), permission_mode: Some(AcpxPermissionMode::ApproveReads), + provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], }; let payload = replacement_continuity_payload(&identity, 41, 42, "turn-2"); @@ -1544,6 +1576,8 @@ mod tests { value["runtimeDirectory"] = json!(runtime); value["cwd"] = json!(workspace); let descriptor: AcpxProviderDescriptor = serde_json::from_value(value).unwrap(); + let (provider_lifetime_fence_candidates, original_lifetime_fence) = + reserve_provider_lifetime_fence(); let identity = AcpxProviderSessionIdentity { kind: "acpx".to_owned(), normalized_session_id: "session-1".to_owned(), @@ -1555,6 +1589,7 @@ mod tests { requested_model: descriptor.model.clone(), effective_model: descriptor.model.clone(), permission_mode: Some(descriptor.permission_mode), + provider_lifetime_fence_candidates, }; let operations = Vec::new(); let tool_set = AuthorizedToolSet { @@ -1634,16 +1669,45 @@ mod tests { assert_eq!(events[1].event_type, "run.terminal"); let cleanup_error = recovered .shutdown() - .expect_err("cleanup must not succeed without a verified replacement handle"); + .expect_err("cleanup must not succeed while the original lifetime remains active"); assert!(cleanup_error .to_string() - .contains("failed to start ACPX provider")); + .contains("original provider lifetime remains active")); let persisted: AcpxDurableState = serde_json::from_slice( &fs::read(recovered.state_path()).expect("read retained ACPX state"), ) .expect("parse retained ACPX state"); assert!(persisted.provider_exit_unconfirmed); - assert!(marker.exists()); + assert!(!marker.exists()); + + drop(original_lifetime_fence); + recovered.shutdown().unwrap(); + let persisted: AcpxDurableState = serde_json::from_slice( + &fs::read(recovered.state_path()).expect("read cleared ACPX state"), + ) + .expect("parse cleared ACPX state"); + assert!(!persisted.provider_exit_unconfirmed); + assert!(!marker.exists()); fs::remove_dir_all(directory).unwrap(); } + + fn reserve_provider_lifetime_fence() -> ([u16; 3], Vec) { + let mut listeners = Vec::new(); + for port in 49_152..=u16::MAX { + if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) { + listeners.push(listener); + if listeners.len() == 3 { + break; + } + } + } + assert_eq!(listeners.len(), 3, "reserve provider lifetime ports"); + let candidates = [ + listeners[0].local_addr().unwrap().port(), + listeners[1].local_addr().unwrap().port(), + listeners[2].local_addr().unwrap().port(), + ]; + drop(listeners.pop()); + (candidates, listeners) + } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs index 68e6526388..0e4c332ce3 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_checkpoint.rs @@ -18,7 +18,7 @@ use crate::durable::{ use crate::local_runner::LocalRunnerError; use crate::stable_identity::{is_stable_id, SHORT_STABLE_ID_CHARS}; -const CHECKPOINT_SCHEMA: &str = "paperclip.runner.acpx-suspension-checkpoint.v1"; +const CHECKPOINT_SCHEMA: &str = "paperclip.runner.acpx-suspension-checkpoint.v2"; const CHECKPOINT_DIRECTORY: &str = "acpx-provider"; const CHECKPOINT_FILE: &str = "suspension-checkpoint.json"; const MAX_CHECKPOINT_BYTES: u64 = 1024 * 1024; @@ -48,6 +48,7 @@ struct PersistedAcpxProviderSessionIdentity { requested_model: String, effective_model: String, permission_mode: AcpxPermissionMode, + provider_lifetime_fence_candidates: [u16; 3], } impl PersistedAcpxProviderSessionIdentity { @@ -69,6 +70,7 @@ impl PersistedAcpxProviderSessionIdentity { requested_model: identity.requested_model, effective_model: identity.effective_model, permission_mode, + provider_lifetime_fence_candidates: identity.provider_lifetime_fence_candidates, }) } @@ -84,6 +86,7 @@ impl PersistedAcpxProviderSessionIdentity { requested_model: self.requested_model.clone(), effective_model: self.effective_model.clone(), permission_mode: Some(self.permission_mode), + provider_lifetime_fence_candidates: self.provider_lifetime_fence_candidates, } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs index 01df870ec4..bbaf52c275 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs @@ -47,6 +47,7 @@ pub struct AcpxProviderSessionIdentity { pub effective_model: String, #[serde(default)] pub permission_mode: Option, + pub provider_lifetime_fence_candidates: [u16; 3], } #[derive(Clone, Debug)] @@ -186,6 +187,21 @@ impl AcpxProviderSessionIdentity { ))); } } + if self + .provider_lifetime_fence_candidates + .iter() + .any(|port| *port < 49_152) + || self.provider_lifetime_fence_candidates[0] + == self.provider_lifetime_fence_candidates[1] + || self.provider_lifetime_fence_candidates[0] + == self.provider_lifetime_fence_candidates[2] + || self.provider_lifetime_fence_candidates[1] + == self.provider_lifetime_fence_candidates[2] + { + return Err(LocalRunnerError::invalid( + "ACPX provider lifetime fence candidates are invalid", + )); + } Ok(()) } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs index 70053c7310..f20c2333a0 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs @@ -589,6 +589,7 @@ fn bootstrap_success( "requestedModel": model, "effectiveModel": if mode == "bootstrap-wrong-model" { "wrong-model" } else { model }, "permissionMode": params.get("permissionMode"), + "providerLifetimeFenceCandidates": [60001, 60002, 60003], }, "status": {}, }) @@ -614,6 +615,7 @@ fn bootstrap_success( "requestedModel": "gpt-5.6-sol", "effectiveModel": "gpt-5.6-sol", "permissionMode": "approve-reads", + "providerLifetimeFenceCandidates": [60001, 60002, 60003], })}, }), "tool.resolve" => json!({ diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs index c5897506e0..0cea4bb818 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_checkpoint.rs @@ -74,6 +74,7 @@ fn identity() -> AcpxProviderSessionIdentity { requested_model: "gpt-5.6-sol".to_owned(), effective_model: "gpt-5.6-sol".to_owned(), permission_mode: Some(AcpxPermissionMode::ApproveReads), + provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } @@ -193,6 +194,11 @@ fn fails_closed_on_unknown_or_oversized_checkpoint_files() { .as_object_mut() .unwrap() .remove("permissionMode"); + let mut missing_lifetime_fence = valid.clone(); + missing_lifetime_fence["identity"] + .as_object_mut() + .unwrap() + .remove("providerLifetimeFenceCandidates"); let mut invalid_run = valid.clone(); invalid_run["runId"] = json!("run 1"); let mut invalid_session = valid; @@ -202,6 +208,7 @@ fn fails_closed_on_unknown_or_oversized_checkpoint_files() { top_level_unknown, nested_unknown, missing_permission, + missing_lifetime_fence, invalid_run, invalid_session, ] { diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs index e0ae0f7f17..3fe5e42780 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs @@ -62,6 +62,7 @@ fn expected_identity() -> AcpxProviderSessionIdentity { requested_model: "gpt-5.6-sol".to_owned(), effective_model: "gpt-5.6-sol".to_owned(), permission_mode: Some(AcpxPermissionMode::ApproveReads), + provider_lifetime_fence_candidates: [60_001, 60_002, 60_003], } } @@ -100,6 +101,12 @@ fn validates_qualified_policy_and_tool_catalog_before_spawning() { let mut invalid_tools = config("bootstrap"); invalid_tools.tool_set.catalog_digest = "invalid".to_owned(); assert!(start_error(&invalid_tools).contains("authorized tools")); + + let mut invalid_lifetime_fence = config("bootstrap"); + let mut invalid_identity = expected_identity(); + invalid_identity.provider_lifetime_fence_candidates = [60_001, 60_001, 60_003]; + invalid_lifetime_fence.expected_identity = Some(invalid_identity); + assert!(start_error(&invalid_lifetime_fence).contains("lifetime fence candidates")); } #[test] diff --git a/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts b/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts index 6a066dd4d1..c94f4c3023 100644 --- a/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts +++ b/packages/paperclip-runner/src/backends/harness-driver-backend.test.ts @@ -30,6 +30,7 @@ const providerIdentity = { workspaceDigest: "sha256:workspace", requestedModel: "claude-sonnet-4-20250514", effectiveModel: "claude-sonnet-4-20250514", + providerLifetimeFenceCandidates: [60_001, 60_002, 60_003] as const, }; function prpEvent(sourceSeq: number, eventType: PrpEvent["eventType"], payload: Record): PrpEvent { diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index cecbd4560f..2922d635a5 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -1026,9 +1026,30 @@ function parseExpectedIdentity(value: unknown): AcpxExpectedSessionIdentity { ...(input.permissionMode === undefined ? {} : { permissionMode: requiredPermissionMode(input.permissionMode) }), + providerLifetimeFenceCandidates: requiredFenceCandidates( + input.providerLifetimeFenceCandidates, + ), }; } +function requiredFenceCandidates( + value: unknown, +): readonly [number, number, number] { + if ( + !Array.isArray(value) || + value.length !== 3 || + value.some( + (port) => !Number.isSafeInteger(port) || port < 49_152 || port > 65_535, + ) || + new Set(value).size !== 3 + ) { + throw new Error( + "providerLifetimeFenceCandidates must be three distinct private ports", + ); + } + return Object.freeze([...value]) as readonly [number, number, number]; +} + function requiredPermissionMode( value: unknown, ): AcpxSidecarOpenParams["permissionMode"] { diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts index da3017e853..a3c7b6792c 100644 --- a/packages/paperclip-runner/src/contracts/harness-driver.ts +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -432,6 +432,7 @@ export interface AcpxSessionIdentity { effectiveModel: string; /** Missing on legacy snapshots; those used the historical approve-reads behavior. */ permissionMode?: "approve-all" | "approve-reads" | "deny-all"; + providerLifetimeFenceCandidates: readonly [number, number, number]; } export type PersistedHarnessProviderIdentity = AcpxSessionIdentity; diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts index 028f2b0d94..d2ed9b172f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts @@ -2642,7 +2642,7 @@ function recoveryWorkspaceLease( function fakeHost(createTurn: () => AcpxRuntimeTurn, onClose: () => void) { return { identity: () => ({ - schema: "paperclip.runner.acpx-identity.v1" as const, + schema: "paperclip.runner.acpx-identity.v2" as const, normalizedSessionId: "session-1", acpxRecordId: "record-1", backendSessionId: "backend-1", @@ -2652,6 +2652,7 @@ function fakeHost(createTurn: () => AcpxRuntimeTurn, onClose: () => void) { requestedModel: "gpt-5.6-sol", effectiveModel: "gpt-5.6-sol", permissionMode: "approve-reads" as const, + providerLifetimeFenceCandidates: [60_001, 60_002, 60_003] as const, }), binding: () => ({ normalizedSessionId: "session-1", diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 34e3ab9638..62343cfeb6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -1154,6 +1154,8 @@ class CodexAcpxSession implements HarnessSession { requestedModel: identity.requestedModel, effectiveModel: identity.effectiveModel, permissionMode: identity.permissionMode, + providerLifetimeFenceCandidates: + identity.providerLifetimeFenceCandidates, }, semanticResult: this.#semanticResult && @@ -1871,7 +1873,10 @@ function validateRecoverySnapshot(snapshot: PersistedHarnessSession): void { (identity.permissionMode !== undefined && !["approve-all", "approve-reads", "deny-all"].includes( identity.permissionMode, - )) + )) || + !validProviderLifetimeFenceCandidates( + identity.providerLifetimeFenceCandidates, + ) ) { throw new Error("persisted Codex ACPX session identity is inconsistent"); } @@ -2001,6 +2006,19 @@ function validateRecoverySnapshot(snapshot: PersistedHarnessSession): void { } } +function validProviderLifetimeFenceCandidates( + value: unknown, +): value is readonly [number, number, number] { + return ( + Array.isArray(value) && + value.length === 3 && + value.every( + (port) => Number.isSafeInteger(port) && port >= 49_152 && port <= 65_535, + ) && + new Set(value).size === 3 + ); +} + function isCompletedTerminal(terminalFingerprint: string): boolean { try { const value: unknown = JSON.parse(terminalFingerprint); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts index 6e31392e06..4f8d8616f8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts @@ -70,6 +70,9 @@ describe("managed Codex credentials", () => { }); expect(lease.mode).toBe("inline_json"); + expect(lease.lifetimeFenceCandidates).toEqual( + credentialLeasePorts(await realpath(fixture.home)), + ); expect(lease.lifetimeFenceFds).toHaveLength(2); expect(lease.lifetimeFenceFds.every(Number.isSafeInteger)).toBe(true); expect(lease.lifetimeFenceFds[0]).not.toBe(lease.lifetimeFenceFds[1]); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.ts b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.ts index 012b1e88e9..8205608940 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.ts @@ -48,6 +48,7 @@ try { interface CredentialHomeLock { assertHeld(): void; + candidatePorts(): readonly [number, number, number]; inheritanceFds(): readonly [number, number]; activateLifetimeOwner(pid: number): Promise; release(): Promise; @@ -114,6 +115,8 @@ export type ManagedCodexCredentialMode = "api_key" | "inline_json" | "managed_file"; export interface AcpxProviderLifetimeLease { + /** Exact kernel quorum candidates used to prove this provider has exited. */ + readonly lifetimeFenceCandidates: readonly [number, number, number]; /** Duplicate both quorum listeners into the provider lifetime sentinel. */ readonly lifetimeFenceFds: readonly [number, number]; /** Validate the guardian while the provider-lifetime quorum is still held. */ @@ -139,6 +142,7 @@ export async function acquireAcpxProviderLifetimeLease(input: { let closeAttempt: Promise | null = null; let lifetimeOwnerAttempt: Promise | null = null; return Object.freeze({ + lifetimeFenceCandidates: lock.candidatePorts(), lifetimeFenceFds: lock.inheritanceFds(), async activateLifetimeOwner(pid: number): Promise { if (closed || closeAttempt !== null) { @@ -363,10 +367,11 @@ async function acquireCredentialHomeLock( // contenders cannot both reach quorum; one unrelated occupied listener is // tolerated without probing or trusting the process behind it. const servers: Server[] = []; + const candidatePorts = credentialLeasePorts(home); let invalid: Error | null = null; let released = false; try { - for (const port of credentialLeasePorts(home)) { + for (const port of candidatePorts) { const server = createServer((socket) => socket.destroy()); try { await listenForCredentialLease(server, port); @@ -431,6 +436,9 @@ async function acquireCredentialHomeLock( throw new Error("Managed Codex credential ownership was lost"); } }, + candidatePorts(): readonly [number, number, number] { + return candidatePorts; + }, inheritanceFds(): readonly [number, number] { this.assertHeld(); return inheritanceFds; @@ -466,7 +474,7 @@ async function acquireCredentialHomeLock( }); } -function credentialLeasePorts(home: string): readonly number[] { +function credentialLeasePorts(home: string): readonly [number, number, number] { const userScope = typeof process.getuid === "function" ? String(process.getuid()) : "win32"; const digest = createHash("sha256") @@ -477,11 +485,13 @@ function credentialLeasePorts(home: string): readonly number[] { .digest(); const start = digest.readUInt16BE(0) % CREDENTIAL_LEASE_PORT_COUNT; const step = (digest.readUInt16BE(2) | 1) % CREDENTIAL_LEASE_PORT_COUNT; - return Array.from( - { length: CREDENTIAL_LEASE_CANDIDATES }, - (_, index) => - CREDENTIAL_LEASE_PORT_MIN + - ((start + index * step) % CREDENTIAL_LEASE_PORT_COUNT), + return Object.freeze( + Array.from( + { length: CREDENTIAL_LEASE_CANDIDATES }, + (_, index) => + CREDENTIAL_LEASE_PORT_MIN + + ((start + index * step) % CREDENTIAL_LEASE_PORT_COUNT), + ) as [number, number, number], ); } @@ -653,6 +663,7 @@ function credentialLease( return Object.freeze({ path, mode, + lifetimeFenceCandidates: lock.candidatePorts(), lifetimeFenceFds: lock.inheritanceFds(), async activateLifetimeOwner(pid: number): Promise { if (closed || closeAttempt !== null) { diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts index 12cda7b1d7..4e053e5f55 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts @@ -53,6 +53,7 @@ describe("ACPX recovery identity", () => { requestedModel: fixture.binding.requestedModel, effectiveModel: fixture.binding.effectiveModel, permissionMode: "approve-reads", + providerLifetimeFenceCandidates: [60_001, 60_002, 60_003], }); expect(() => verifyExpectedAcpxIdentity(fixture.expected, fixture.binding, record), @@ -210,7 +211,7 @@ describe("ACPX recovery identity", () => { expect(() => verifyExpectedAcpxIdentity(fixture.expected, fixture.binding, { ...createAcpxIdentityRecord(fixture.expected, fixture.binding), - schema: "paperclip.runner.acpx-identity.v2", + schema: "paperclip.runner.acpx-identity.v1", }), ).toThrow(/Unsupported ACPX identity record schema/); const missingPermissionMode = createAcpxIdentityRecord( @@ -225,6 +226,24 @@ describe("ACPX recovery identity", () => { missingPermissionMode, ), ).toThrow(/permission mode is invalid/); + const missingFenceCandidates = createAcpxIdentityRecord( + fixture.expected, + fixture.binding, + ) as Partial>; + delete missingFenceCandidates.providerLifetimeFenceCandidates; + expect(() => + verifyExpectedAcpxIdentity( + fixture.expected, + fixture.binding, + missingFenceCandidates, + ), + ).toThrow(/lifetime fence candidates are invalid/); + expect(() => + verifyExpectedAcpxIdentity(fixture.expected, fixture.binding, { + ...createAcpxIdentityRecord(fixture.expected, fixture.binding), + providerLifetimeFenceCandidates: [60_001, 60_002, 60_004], + }), + ).toThrow(/does not match the persisted runtime record/); await expect( createAcpxRecoveryBinding({ @@ -270,6 +289,7 @@ async function recoveryFixture() { requestedModel: binding.requestedModel, effectiveModel: binding.effectiveModel, permissionMode: binding.permissionMode, + providerLifetimeFenceCandidates: [60_001, 60_002, 60_003] as const, }; return { root, workspace, input, binding, expected }; } diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts index a0b8f5249b..e256632d95 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.ts @@ -7,7 +7,7 @@ import type { AcpxExpectedSessionIdentity } from "./sidecar-protocol.js"; import type { QualifiedAcpxProfile } from "./qualified-profiles.js"; export const ACPX_IDENTITY_RECORD_SCHEMA = - "paperclip.runner.acpx-identity.v1" as const; + "paperclip.runner.acpx-identity.v2" as const; export interface AcpxRecoveryBinding { normalizedSessionId: string; @@ -33,6 +33,7 @@ export interface AcpxIdentityRecord { requestedModel: string; effectiveModel: string; permissionMode: NativeAcpxPermissionMode; + providerLifetimeFenceCandidates: readonly [number, number, number]; } export async function createAcpxRecoveryBinding(input: { @@ -113,6 +114,9 @@ export function createAcpxIdentityRecord( requestedModel: binding.requestedModel, effectiveModel: binding.effectiveModel, permissionMode: binding.permissionMode, + providerLifetimeFenceCandidates: Object.freeze([ + ...expected.providerLifetimeFenceCandidates, + ]) as readonly [number, number, number], }; } @@ -135,6 +139,7 @@ export function acpxProviderSessionIdentity( requestedModel: record.requestedModel, effectiveModel: record.effectiveModel, permissionMode: record.permissionMode, + providerLifetimeFenceCandidates: record.providerLifetimeFenceCandidates, }; verifyExpectedAcpxIdentity(identity, binding, record); return identity; @@ -142,7 +147,7 @@ export function acpxProviderSessionIdentity( /** * Verify both the controller-provided identity and a persisted runtime record. - * Only the complete v1 record is recoverable. Draft schema-less and + * Only the complete v2 record is recoverable. Draft schema-less and * command-digest records cannot prove every immutable session binding, so * callers must fail closed and start a fresh provider session for them. */ @@ -176,7 +181,11 @@ export function verifyExpectedAcpxIdentity( record.workspaceDigest !== binding.workspaceDigest || record.requestedModel !== binding.requestedModel || record.effectiveModel !== binding.effectiveModel || - record.permissionMode !== binding.permissionMode + record.permissionMode !== binding.permissionMode || + !sameFenceCandidates( + record.providerLifetimeFenceCandidates, + expected.providerLifetimeFenceCandidates, + ) ) { throw new Error( "ACPX recovery identity does not match the persisted runtime record", @@ -197,6 +206,7 @@ function parsePersistedRecord(value: unknown): AcpxIdentityRecord { "requestedModel", "effectiveModel", "permissionMode", + "providerLifetimeFenceCandidates", ]); return validatedRecord(record); } @@ -222,6 +232,7 @@ function validatedRecord(value: Record): AcpxIdentityRecord { if (!isPermissionMode(value.permissionMode)) { throw new Error("ACPX identity permission mode is invalid"); } + validateFenceCandidates(value.providerLifetimeFenceCandidates); return value as unknown as AcpxIdentityRecord; } @@ -249,6 +260,29 @@ function validateExpected(expected: AcpxExpectedSessionIdentity): void { ) { throw new Error("Expected ACPX permission mode is invalid"); } + validateFenceCandidates(expected.providerLifetimeFenceCandidates); +} + +function validateFenceCandidates( + value: unknown, +): asserts value is readonly [number, number, number] { + if ( + !Array.isArray(value) || + value.length !== 3 || + value.some( + (port) => !Number.isSafeInteger(port) || port < 49_152 || port > 65_535, + ) || + new Set(value).size !== 3 + ) { + throw new Error("ACPX provider lifetime fence candidates are invalid"); + } +} + +function sameFenceCandidates( + left: readonly [number, number, number], + right: readonly [number, number, number], +): boolean { + return left.every((port, index) => port === right[index]); } async function resolveWorkspace(value: string): Promise { diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index 26a592ac51..9bc3f1a4ac 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -270,11 +270,20 @@ describe("ACPX runtime host", () => { dependencies, ); expect(host.identity()).toMatchObject({ - schema: "paperclip.runner.acpx-identity.v1", + schema: "paperclip.runner.acpx-identity.v2", acpxRecordId: "record-1", requestedModel: "gpt-5.6-sol", permissionMode: "approve-reads", }); + const lifetimeFenceCandidates = + host.identity().providerLifetimeFenceCandidates; + expect(lifetimeFenceCandidates).toHaveLength(3); + expect(new Set(lifetimeFenceCandidates).size).toBe(3); + expect( + lifetimeFenceCandidates.every( + (port) => port >= 49_152 && port <= 65_535, + ), + ).toBe(true); expect(capturedEnvironment.OPENAI_API_KEY).toBe("launch-secret"); expect(host.persistedEnvironment().OPENAI_API_KEY).toBeUndefined(); expect(host.persistedEnvironment().HTTPS_PROXY).toBeUndefined(); @@ -471,6 +480,7 @@ describe("ACPX runtime host", () => { requestedModel: "claude-sonnet-5", effectiveModel: "claude-sonnet-5", permissionMode: "approve-reads", + providerLifetimeFenceCandidates: [60_001, 60_002, 60_003], }, }, fixture.dependencies({ openRuntime }), @@ -1091,6 +1101,7 @@ describe("ACPX runtime host", () => { path: string; mode: "inline_json"; lifetimeFenceFds: readonly [number, number]; + lifetimeFenceCandidates: readonly [number, number, number]; activateLifetimeOwner(pid: number): Promise; close(): Promise; }>(); @@ -1136,6 +1147,7 @@ describe("ACPX runtime host", () => { path: lateCredentialPath, mode: "inline_json", lifetimeFenceFds: [42, 43], + lifetimeFenceCandidates: [60_001, 60_002, 60_003], activateLifetimeOwner: async () => undefined, close: lateCredentialClose, }); @@ -1280,6 +1292,7 @@ describe("ACPX runtime host", () => { path: join(fixture.root, "auth.json"), mode: "inline_json", lifetimeFenceFds: [42, 43], + lifetimeFenceCandidates: [60_001, 60_002, 60_003], activateLifetimeOwner: async () => undefined, close: credentialClose, }), diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index 3caea9ff9a..0836cbcf40 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -465,6 +465,8 @@ export class AcpxRuntimeHost { requestedModel: binding.requestedModel, effectiveModel: binding.effectiveModel, permissionMode: binding.permissionMode, + providerLifetimeFenceCandidates: + admittedLifetime.lifetimeFenceCandidates, }; const identity = createAcpxIdentityRecord(observedIdentity, binding); if (options.expectedIdentity) { diff --git a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts index 95c1f831de..72cc629690 100644 --- a/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts +++ b/packages/paperclip-runner/src/drivers/acpx/sidecar-protocol.ts @@ -64,6 +64,7 @@ export interface AcpxExpectedSessionIdentity { requestedModel: string; effectiveModel: string; permissionMode?: NativeAcpxPermissionMode; + providerLifetimeFenceCandidates: readonly [number, number, number]; } export function parseAcpxSidecarRequest(value: unknown): AcpxSidecarRequest {