fix(runner): prove original ACPX provider exit
This commit is contained in:
parent
848b8296d0
commit
6e9bb95202
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
] {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export interface AcpxExpectedSessionIdentity {
|
|||
requestedModel: string;
|
||||
effectiveModel: string;
|
||||
permissionMode?: NativeAcpxPermissionMode;
|
||||
providerLifetimeFenceCandidates: readonly [number, number, number];
|
||||
}
|
||||
|
||||
export function parseAcpxSidecarRequest(value: unknown): AcpxSidecarRequest {
|
||||
|
|
|
|||
Loading…
Reference in New Issue