feat(runner): persist ACPX suspension checkpoints (#12425)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Rust ACPX session can now suspend only at a safe idle boundary and its events have a durable projection > - A later runner process cannot safely resume from an unbound or partially written identity record > - The recovery anchor must bind the exact provider identity, run, normalized session, catalog revision, and catalog digest > - The record must be bounded, private, strict about schema drift, and atomically replaceable > - Recovery must re-admit the entire prospective session configuration before releasing the stored identity > - This pull request adds only that package-local checkpoint store without selecting ACPX in runnerd ## Linked Issues or Issue Description Refs #12424 Refs #12422 ## What Changed - Add a versioned ACPX safe-suspension checkpoint contract with unknown fields rejected at every persisted level. - Bind each checkpoint to the run, normalized session, catalog revision, catalog digest, and exact provider identity. - Persist a checkpoint-specific strict identity that requires the pinned permission mode without narrowing the additive live sidecar identity wire shape. - Construct checkpoints only from a session configuration whose model, permission policy, tool catalog, and expected identity validate. - Admit recovery only when reconstructing the checkpoint from the prospective configuration produces an exact match. - Reject run, session, catalog revision, catalog digest, model, permission, expected-identity, profile, and workspace drift fail closed. - Require persisted run/session IDs to satisfy the same stable-ID boundary as fresh session admission. - Store the checkpoint under a dedicated private runner-state subdirectory. - Bound checkpoint files to 1 MiB before reading or decoding. - Refuse symlinked state directories and non-private or non-regular checkpoint files. - Replace checkpoints atomically through a private temporary file and directory sync. - Make repeated saves of the same checkpoint idempotent. - Add integration coverage for private round trips, complete recovery admission, malformed/oversized files, nested schema drift, missing permission binding, invalid stable IDs, and symlink denial. - Document the package-local suspension recovery boundary. - Do not change dependencies, lockfiles, workflows, runnerd selection, server behavior, UI, or migrations. ## Verification - Replay base: `1ee738cf487defe88043b241c4e2dc34a1a8a7bc` (`master` after #12424 merged). - Exact replay head: `89cfea5495428be890810d2b8a27673943234ca3`. - Stable patch ID: `dbaeb0bbe773f1ca5ef1f9bdc0fa61f4a08ca451`. - The exact delta is 4 files and 591 additions, all in `packages/paperclip-runner`; it contains no lockfile, workflow, server, UI, dependency, or migration change. - `git diff --check` and the Cargo formatting check pass on the replayed delta. - Exact-head GitHub Actions run `33374006661` (attempt 2): **PASSED** with 23/23 jobs passed. - Greptile reviewed exact head `89cfea5495428be890810d2b8a27673943234ca3`: **5/5**, with zero unresolved review threads. - Superagent, contributor trust, Socket, and Snyk security checks: **PASSED**. - No local test result is claimed. GitHub Actions is the authoritative verification environment for this replayed revision. ## Risks - A checkpoint is valid only after the sidecar has confirmed safe suspension. The constructor therefore accepts the exact identity returned by that operation and revalidates it against local authority. - Loading proves only that the file is structurally valid; `admit_recovery` is the boundary that proves the file belongs to the prospective run, catalog, model, permission policy, and expected provider identity. - The checkpoint intentionally contains no credentials, bootstrap ticket, provider output, or pending request payload. - Strict checkpoint schema and identity validation rejects incompatible or tampered recovery records rather than attempting partial migration. This strictness is checkpoint-local and does not narrow existing PRP or sidecar wire compatibility. - Atomic replacement uses the platform `rename` primitive; Unix additionally syncs the private parent directory before reporting success. - No production path loads this checkpoint in this pull request. Runnerd execution and durable recovery wiring remain a later slice. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with GPT-5.6, agentic reasoning, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used with version and capability details - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have linked the preceding public PRs or described the issue in-PR - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
1ee738cf48
commit
8478ddbcee
|
|
@ -117,6 +117,11 @@ terminates the local process.
|
|||
Already validated ACPX reducer events project into provider-neutral durable
|
||||
events only with an exact run, session, turn, and item binding. Raw sidecar
|
||||
envelopes and permission requests are not admitted at this boundary.
|
||||
A safely suspended session can be recorded as a bounded private checkpoint.
|
||||
The checkpoint binds the exact provider identity, run, catalog revision, and
|
||||
catalog digest and is replaced atomically before a later recovery attempt.
|
||||
Recovery releases the stored identity only after those bindings match the
|
||||
prospective session configuration exactly.
|
||||
|
||||
Run the complete contract gate with:
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,337 @@
|
|||
use std::fs::{self, DirBuilder};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::fs::File;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::acpx_provider_session::{
|
||||
AcpxPermissionMode, AcpxProviderSessionConfig, AcpxProviderSessionIdentity,
|
||||
};
|
||||
use crate::durable::{
|
||||
create_private_temporary_file, open_private_regular_file, verify_private_directory,
|
||||
};
|
||||
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_DIRECTORY: &str = "acpx-provider";
|
||||
const CHECKPOINT_FILE: &str = "suspension-checkpoint.json";
|
||||
const MAX_CHECKPOINT_BYTES: u64 = 1024 * 1024;
|
||||
const MAX_JSON_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AcpxSuspensionCheckpoint {
|
||||
schema: String,
|
||||
run_id: String,
|
||||
normalized_session_id: String,
|
||||
catalog_revision: u64,
|
||||
catalog_digest: String,
|
||||
identity: PersistedAcpxProviderSessionIdentity,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct PersistedAcpxProviderSessionIdentity {
|
||||
kind: String,
|
||||
normalized_session_id: String,
|
||||
acpx_record_id: String,
|
||||
backend_session_id: String,
|
||||
agent_session_id: String,
|
||||
profile_digest: String,
|
||||
workspace_digest: String,
|
||||
requested_model: String,
|
||||
effective_model: String,
|
||||
permission_mode: AcpxPermissionMode,
|
||||
}
|
||||
|
||||
impl PersistedAcpxProviderSessionIdentity {
|
||||
fn from_runtime(identity: AcpxProviderSessionIdentity) -> Result<Self, LocalRunnerError> {
|
||||
identity.validate()?;
|
||||
let permission_mode = identity.permission_mode.ok_or_else(|| {
|
||||
LocalRunnerError::invalid(
|
||||
"ACPX suspension checkpoint identity omitted its pinned permission mode",
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
kind: identity.kind,
|
||||
normalized_session_id: identity.normalized_session_id,
|
||||
acpx_record_id: identity.acpx_record_id,
|
||||
backend_session_id: identity.backend_session_id,
|
||||
agent_session_id: identity.agent_session_id,
|
||||
profile_digest: identity.profile_digest,
|
||||
workspace_digest: identity.workspace_digest,
|
||||
requested_model: identity.requested_model,
|
||||
effective_model: identity.effective_model,
|
||||
permission_mode,
|
||||
})
|
||||
}
|
||||
|
||||
fn runtime_identity(&self) -> AcpxProviderSessionIdentity {
|
||||
AcpxProviderSessionIdentity {
|
||||
kind: self.kind.clone(),
|
||||
normalized_session_id: self.normalized_session_id.clone(),
|
||||
acpx_record_id: self.acpx_record_id.clone(),
|
||||
backend_session_id: self.backend_session_id.clone(),
|
||||
agent_session_id: self.agent_session_id.clone(),
|
||||
profile_digest: self.profile_digest.clone(),
|
||||
workspace_digest: self.workspace_digest.clone(),
|
||||
requested_model: self.requested_model.clone(),
|
||||
effective_model: self.effective_model.clone(),
|
||||
permission_mode: Some(self.permission_mode),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), LocalRunnerError> {
|
||||
self.runtime_identity().validate()
|
||||
}
|
||||
}
|
||||
|
||||
impl AcpxSuspensionCheckpoint {
|
||||
pub fn from_suspension(
|
||||
config: &AcpxProviderSessionConfig,
|
||||
identity: AcpxProviderSessionIdentity,
|
||||
) -> Result<Self, LocalRunnerError> {
|
||||
config.validate()?;
|
||||
identity.validate()?;
|
||||
if identity.normalized_session_id != config.normalized_session_id
|
||||
|| identity.requested_model != config.model
|
||||
|| identity.effective_model != config.model
|
||||
|| identity.permission_mode != Some(config.permission_mode)
|
||||
|| config
|
||||
.expected_identity
|
||||
.as_ref()
|
||||
.is_some_and(|expected| expected != &identity)
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX suspended identity conflicts with the admitted session configuration",
|
||||
));
|
||||
}
|
||||
let checkpoint = Self {
|
||||
schema: CHECKPOINT_SCHEMA.to_owned(),
|
||||
run_id: config.run_id.clone(),
|
||||
normalized_session_id: config.normalized_session_id.clone(),
|
||||
catalog_revision: config.catalog_revision,
|
||||
catalog_digest: config.tool_set.catalog_digest.clone(),
|
||||
identity: PersistedAcpxProviderSessionIdentity::from_runtime(identity)?,
|
||||
};
|
||||
checkpoint.validate()?;
|
||||
Ok(checkpoint)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), LocalRunnerError> {
|
||||
if self.schema != CHECKPOINT_SCHEMA {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX suspension checkpoint schema is unsupported",
|
||||
));
|
||||
}
|
||||
validate_stable_id(&self.run_id, "run")?;
|
||||
validate_stable_id(&self.normalized_session_id, "normalized session")?;
|
||||
if self.catalog_revision == 0 || self.catalog_revision > MAX_JSON_SAFE_INTEGER {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX suspension checkpoint catalog revision is invalid",
|
||||
));
|
||||
}
|
||||
if !is_sha256_digest(&self.catalog_digest) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX suspension checkpoint catalog digest is invalid",
|
||||
));
|
||||
}
|
||||
self.identity.validate()?;
|
||||
if self.identity.normalized_session_id != self.normalized_session_id {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX suspension checkpoint session identity is inconsistent",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn admit_recovery(
|
||||
&self,
|
||||
config: &AcpxProviderSessionConfig,
|
||||
) -> Result<AcpxProviderSessionIdentity, LocalRunnerError> {
|
||||
self.validate()?;
|
||||
config.validate()?;
|
||||
let identity = self.identity.runtime_identity();
|
||||
let expected = Self::from_suspension(config, identity.clone())?;
|
||||
if &expected != self {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX suspension checkpoint conflicts with the recovery configuration",
|
||||
));
|
||||
}
|
||||
Ok(identity)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AcpxSuspensionCheckpointStore {
|
||||
directory: PathBuf,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl AcpxSuspensionCheckpointStore {
|
||||
pub fn new(runner_state_directory: &Path) -> Result<Self, LocalRunnerError> {
|
||||
secure_directory(runner_state_directory, "runner state")?;
|
||||
let directory = runner_state_directory.join(CHECKPOINT_DIRECTORY);
|
||||
secure_directory(&directory, "ACPX checkpoint")?;
|
||||
Ok(Self {
|
||||
path: directory.join(CHECKPOINT_FILE),
|
||||
directory,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<Option<AcpxSuspensionCheckpoint>, LocalRunnerError> {
|
||||
let file = match open_private_regular_file(&self.path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => {
|
||||
return Err(LocalRunnerError::invalid(format!(
|
||||
"failed to open the private ACPX suspension checkpoint: {error}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let length = file
|
||||
.metadata()
|
||||
.map_err(|error| {
|
||||
LocalRunnerError::invalid(format!(
|
||||
"failed to inspect the ACPX suspension checkpoint: {error}"
|
||||
))
|
||||
})?
|
||||
.len();
|
||||
if length > MAX_CHECKPOINT_BYTES {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX suspension checkpoint exceeds its 1 MiB bound",
|
||||
));
|
||||
}
|
||||
let bytes = read_checkpoint_bytes(file, length)?;
|
||||
let checkpoint: AcpxSuspensionCheckpoint =
|
||||
serde_json::from_slice(&bytes).map_err(|error| {
|
||||
LocalRunnerError::invalid(format!(
|
||||
"ACPX suspension checkpoint is malformed: {error}"
|
||||
))
|
||||
})?;
|
||||
checkpoint.validate()?;
|
||||
Ok(Some(checkpoint))
|
||||
}
|
||||
|
||||
pub fn save(&self, checkpoint: &AcpxSuspensionCheckpoint) -> Result<(), LocalRunnerError> {
|
||||
checkpoint.validate()?;
|
||||
verify_private_directory(&self.directory).map_err(|error| {
|
||||
LocalRunnerError::invalid(format!(
|
||||
"ACPX checkpoint directory is no longer private: {error}"
|
||||
))
|
||||
})?;
|
||||
let bytes = serde_json::to_vec_pretty(checkpoint).map_err(|error| {
|
||||
LocalRunnerError::invalid(format!(
|
||||
"failed to serialize the ACPX suspension checkpoint: {error}"
|
||||
))
|
||||
})?;
|
||||
if bytes.len() as u64 > MAX_CHECKPOINT_BYTES {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX suspension checkpoint exceeds its 1 MiB bound",
|
||||
));
|
||||
}
|
||||
let (temporary, mut file) = create_private_temporary_file(&self.path).map_err(|error| {
|
||||
LocalRunnerError::invalid(format!(
|
||||
"failed to create a private ACPX checkpoint file: {error}"
|
||||
))
|
||||
})?;
|
||||
let result = (|| -> std::io::Result<()> {
|
||||
file.write_all(&bytes)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
fs::rename(&temporary, &self.path)?;
|
||||
#[cfg(unix)]
|
||||
File::open(&self.directory)?.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(LocalRunnerError::invalid(format!(
|
||||
"failed to atomically replace the ACPX suspension checkpoint: {error}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn read_checkpoint_bytes(
|
||||
reader: impl Read,
|
||||
capacity_hint: u64,
|
||||
) -> Result<Vec<u8>, LocalRunnerError> {
|
||||
let mut reader = reader.take(MAX_CHECKPOINT_BYTES + 1);
|
||||
let mut bytes = Vec::with_capacity(capacity_hint.min(MAX_CHECKPOINT_BYTES) as usize);
|
||||
reader.read_to_end(&mut bytes).map_err(|error| {
|
||||
LocalRunnerError::invalid(format!(
|
||||
"failed to read the ACPX suspension checkpoint: {error}"
|
||||
))
|
||||
})?;
|
||||
if bytes.len() as u64 > MAX_CHECKPOINT_BYTES {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX suspension checkpoint exceeds its 1 MiB bound",
|
||||
));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn secure_directory(path: &Path, label: &str) -> Result<(), LocalRunnerError> {
|
||||
let mut builder = DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
builder.mode(0o700);
|
||||
match builder.create(path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(error) => {
|
||||
return Err(LocalRunnerError::invalid(format!(
|
||||
"failed to create {label} directory: {error}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
// The leaf is created with its private mode in the atomic mkdir operation.
|
||||
// Never chmod a path after a metadata check: an attacker could replace the
|
||||
// leaf with a symlink between those calls and redirect the permission write.
|
||||
// Existing paths must already satisfy the same fail-closed contract.
|
||||
verify_private_directory(path).map_err(|error| {
|
||||
LocalRunnerError::invalid(format!("{label} directory is not private: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_stable_id(value: &str, label: &str) -> Result<(), LocalRunnerError> {
|
||||
if !is_stable_id(value, SHORT_STABLE_ID_CHARS) {
|
||||
return Err(LocalRunnerError::invalid(format!(
|
||||
"ACPX suspension checkpoint {label} identity is invalid"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_sha256_digest(value: &str) -> bool {
|
||||
value.len() == 71
|
||||
&& value.starts_with("sha256:")
|
||||
&& value[7..]
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_hexdigit() && !character.is_ascii_uppercase())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::read_checkpoint_bytes;
|
||||
|
||||
#[test]
|
||||
fn bounded_checkpoint_reader_rejects_growth_beyond_the_metadata_hint() {
|
||||
let error = read_checkpoint_bytes(std::io::repeat(b'x'), 0)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(error.contains("1 MiB"), "{error}");
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
pub mod acpx_event_payload;
|
||||
pub mod acpx_event_scope;
|
||||
pub mod acpx_provider_checkpoint;
|
||||
pub mod acpx_provider_session;
|
||||
pub mod acpx_provider_state;
|
||||
pub mod acpx_sidecar_transport;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,248 @@
|
|||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use paperclip_runner_core::acpx_provider_checkpoint::{
|
||||
AcpxSuspensionCheckpoint, AcpxSuspensionCheckpointStore,
|
||||
};
|
||||
use paperclip_runner_core::acpx_provider_session::{
|
||||
AcpxPermissionMode, AcpxProviderSessionConfig, AcpxProviderSessionIdentity,
|
||||
};
|
||||
use paperclip_runner_core::acpx_sidecar_transport::AcpxSidecarTransportConfig;
|
||||
use paperclip_runner_core::provider_bridge::{
|
||||
authorized_tool_catalog_digest, AuthorizedTool, AuthorizedToolSet,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn temporary_directory(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"paperclip-acpx-checkpoint-{label}-{}-{nonce}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&directory).unwrap();
|
||||
#[cfg(unix)]
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
directory
|
||||
}
|
||||
|
||||
fn config(directory: &std::path::Path) -> AcpxProviderSessionConfig {
|
||||
let operations = Vec::new();
|
||||
AcpxProviderSessionConfig {
|
||||
transport: AcpxSidecarTransportConfig {
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-acpx-sidecar")),
|
||||
args: vec!["--mode".to_owned(), "suspend".to_owned()],
|
||||
request_timeout: Duration::from_secs(1),
|
||||
shutdown_grace: Duration::from_millis(100),
|
||||
},
|
||||
agent: "codex".to_owned(),
|
||||
model: "gpt-5.6-sol".to_owned(),
|
||||
run_id: "run-1".to_owned(),
|
||||
catalog_revision: 7,
|
||||
runtime_directory: directory.to_owned(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
working_directory: directory.to_owned(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
permission_mode_pinned: true,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
tool_set: AuthorizedToolSet {
|
||||
schema: "paperclip.runner.authorized-tools.v1".to_owned(),
|
||||
schema_version: 1,
|
||||
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
|
||||
operations,
|
||||
},
|
||||
expected_identity: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn identity() -> AcpxProviderSessionIdentity {
|
||||
AcpxProviderSessionIdentity {
|
||||
kind: "acpx".to_owned(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
acpx_record_id: "acpx-record-1".to_owned(),
|
||||
backend_session_id: "backend-session-1".to_owned(),
|
||||
agent_session_id: "agent-session-1".to_owned(),
|
||||
profile_digest: format!("sha256:{}", "a".repeat(64)),
|
||||
workspace_digest: format!("sha256:{}", "b".repeat(64)),
|
||||
requested_model: "gpt-5.6-sol".to_owned(),
|
||||
effective_model: "gpt-5.6-sol".to_owned(),
|
||||
permission_mode: Some(AcpxPermissionMode::ApproveReads),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_an_exact_private_suspension_checkpoint_idempotently() {
|
||||
let directory = temporary_directory("round-trip");
|
||||
let config = config(&directory);
|
||||
let checkpoint = AcpxSuspensionCheckpoint::from_suspension(&config, identity()).unwrap();
|
||||
let store = AcpxSuspensionCheckpointStore::new(&directory).unwrap();
|
||||
|
||||
assert_eq!(store.load().unwrap(), None);
|
||||
store.save(&checkpoint).unwrap();
|
||||
store.save(&checkpoint).unwrap();
|
||||
let recovered = store.load().unwrap().unwrap();
|
||||
assert_eq!(recovered, checkpoint);
|
||||
assert_eq!(recovered.admit_recovery(&config).unwrap(), identity());
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
assert_eq!(
|
||||
fs::metadata(store.path()).unwrap().permissions().mode() & 0o077,
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(store.path().parent().unwrap())
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o077,
|
||||
0
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admits_recovery_only_for_the_exact_run_catalog_and_provider_identity() {
|
||||
let directory = temporary_directory("recovery-admission");
|
||||
let config = config(&directory);
|
||||
let checkpoint = AcpxSuspensionCheckpoint::from_suspension(&config, identity()).unwrap();
|
||||
|
||||
let mut changed_run = config.clone();
|
||||
changed_run.run_id = "run-2".to_owned();
|
||||
let mut changed_session = config.clone();
|
||||
changed_session.normalized_session_id = "session-2".to_owned();
|
||||
let mut changed_revision = config.clone();
|
||||
changed_revision.catalog_revision += 1;
|
||||
let mut changed_catalog = config.clone();
|
||||
let operations = vec![AuthorizedTool {
|
||||
operation_id: "issues.read".to_owned(),
|
||||
version: 1,
|
||||
description: "Read one issue".to_owned(),
|
||||
input_schema: json!({"type":"object"}),
|
||||
response_schema: json!({"type":"object"}),
|
||||
}];
|
||||
changed_catalog.tool_set = AuthorizedToolSet {
|
||||
schema: "paperclip.runner.authorized-tools.v1".to_owned(),
|
||||
schema_version: 1,
|
||||
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
|
||||
operations,
|
||||
};
|
||||
let mut changed_model = config.clone();
|
||||
changed_model.model = "gpt-5.6-sol-mini".to_owned();
|
||||
let mut changed_permission = config.clone();
|
||||
changed_permission.permission_mode = AcpxPermissionMode::ApproveAll;
|
||||
let mut changed_expected_identity = config.clone();
|
||||
let mut expected = identity();
|
||||
expected.backend_session_id = "backend-session-2".to_owned();
|
||||
changed_expected_identity.expected_identity = Some(expected);
|
||||
|
||||
for (label, changed) in [
|
||||
("run", changed_run),
|
||||
("session", changed_session),
|
||||
("revision", changed_revision),
|
||||
("catalog", changed_catalog),
|
||||
("model", changed_model),
|
||||
("permission", changed_permission),
|
||||
("expected identity", changed_expected_identity),
|
||||
] {
|
||||
let error = checkpoint.admit_recovery(&changed).unwrap_err().to_string();
|
||||
assert!(error.contains("conflict"), "{label}: {error}");
|
||||
}
|
||||
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_identity_drift_before_a_checkpoint_is_created() {
|
||||
let directory = temporary_directory("identity-drift");
|
||||
let config = config(&directory);
|
||||
let mut mismatched = identity();
|
||||
mismatched.effective_model = "other-model".to_owned();
|
||||
let error = AcpxSuspensionCheckpoint::from_suspension(&config, mismatched)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("conflicts"), "{error}");
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_closed_on_unknown_or_oversized_checkpoint_files() {
|
||||
let directory = temporary_directory("malformed");
|
||||
let config = config(&directory);
|
||||
let checkpoint = AcpxSuspensionCheckpoint::from_suspension(&config, identity()).unwrap();
|
||||
let store = AcpxSuspensionCheckpointStore::new(&directory).unwrap();
|
||||
store.save(&checkpoint).unwrap();
|
||||
|
||||
let valid: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(store.path()).unwrap()).unwrap();
|
||||
let mut top_level_unknown = valid.clone();
|
||||
top_level_unknown["unexpected"] = json!(true);
|
||||
let mut nested_unknown = valid.clone();
|
||||
nested_unknown["identity"]["unexpected"] = json!(true);
|
||||
let mut missing_permission = valid.clone();
|
||||
missing_permission["identity"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("permissionMode");
|
||||
let mut invalid_run = valid.clone();
|
||||
invalid_run["runId"] = json!("run 1");
|
||||
let mut invalid_session = valid;
|
||||
invalid_session["normalizedSessionId"] = json!("séssion-1");
|
||||
|
||||
for malformed in [
|
||||
top_level_unknown,
|
||||
nested_unknown,
|
||||
missing_permission,
|
||||
invalid_run,
|
||||
invalid_session,
|
||||
] {
|
||||
fs::write(store.path(), serde_json::to_vec(&malformed).unwrap()).unwrap();
|
||||
assert!(store.load().is_err());
|
||||
}
|
||||
|
||||
fs::write(store.path(), vec![b'x'; 1024 * 1024 + 1]).unwrap();
|
||||
assert!(store.load().unwrap_err().to_string().contains("1 MiB"));
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_a_symlinked_runner_state_directory() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let directory = temporary_directory("symlink");
|
||||
let actual = directory.join("actual");
|
||||
fs::create_dir(&actual).unwrap();
|
||||
let linked = directory.join("linked");
|
||||
symlink(&actual, &linked).unwrap();
|
||||
let error = AcpxSuspensionCheckpointStore::new(&linked)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("must not be a symlink"), "{error}");
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_an_existing_runner_state_directory_that_is_not_private() {
|
||||
let directory = temporary_directory("public-state");
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let error = AcpxSuspensionCheckpointStore::new(&directory)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(error.contains("not private"), "{error}");
|
||||
assert_eq!(
|
||||
fs::metadata(&directory).unwrap().permissions().mode() & 0o077,
|
||||
0o055
|
||||
);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
Loading…
Reference in New Issue