fix(runner): prove original ACPX provider exit

This commit is contained in:
Dotta 2026-09-03 03:38:16 -05:00
parent 848b8296d0
commit 6e9bb95202
18 changed files with 254 additions and 29 deletions

View File

@ -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<u64> {
(event_id(sequence) == value).then_some(sequence)
}
fn acquire_provider_lifetime_fence(
candidates: [u16; 3],
) -> Result<Vec<TcpListener>, 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<TcpListener>) {
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)
}
}

View File

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

View File

@ -47,6 +47,7 @@ pub struct AcpxProviderSessionIdentity {
pub effective_model: String,
#[serde(default)]
pub permission_mode: Option<AcpxPermissionMode>,
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(())
}
}

View File

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

View File

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

View File

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

View File

@ -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<string, unknown>): PrpEvent {

View File

@ -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"] {

View File

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

View File

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

View File

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

View File

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

View File

@ -48,6 +48,7 @@ try {
interface CredentialHomeLock {
assertHeld(): void;
candidatePorts(): readonly [number, number, number];
inheritanceFds(): readonly [number, number];
activateLifetimeOwner(pid: number): Promise<void>;
release(): Promise<void>;
@ -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<void> | null = null;
let lifetimeOwnerAttempt: Promise<void> | null = null;
return Object.freeze({
lifetimeFenceCandidates: lock.candidatePorts(),
lifetimeFenceFds: lock.inheritanceFds(),
async activateLifetimeOwner(pid: number): Promise<void> {
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<void> {
if (closed || closeAttempt !== null) {

View File

@ -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<ReturnType<typeof createAcpxIdentityRecord>>;
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 };
}

View File

@ -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<string, unknown>): 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<string> {

View File

@ -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<void>;
close(): Promise<void>;
}>();
@ -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,
}),

View File

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

View File

@ -64,6 +64,7 @@ export interface AcpxExpectedSessionIdentity {
requestedModel: string;
effectiveModel: string;
permissionMode?: NativeAcpxPermissionMode;
providerLifetimeFenceCandidates: readonly [number, number, number];
}
export function parseAcpxSidecarRequest(value: unknown): AcpxSidecarRequest {