feat(runner): bootstrap ACPX provider sessions (#12418)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Rust runner now has bounded ACPX transport, scope, payload,
normalization, and state-reduction layers
> - A live provider needs a lifecycle owner that starts the sidecar and
proves it opened the exact requested session
> - The initial production slice is Codex-only and must fail closed on
capability, model, identity, policy, or catalog drift
> - Failed bootstraps must not leak a child process, and ordinary
shutdown must preserve resumable provider state
> - This pull request adds that package-local lifecycle without
selecting ACPX in runnerd
> - The benefit is a reviewable bootstrap/recovery boundary before turn
commands and production selection are connected

## Linked Issues or Issue Description

Refs #12417

Refs #12416

## What Changed

- Add a package-local ACPX provider session configuration and lifecycle
owner.
- Reject non-UTF-8 runtime and working directories before spawning so
JSON path serialization cannot panic.
- Validate the sidecar launch contract, Codex-only agent, model, run and
session identifiers, absolute directories, positive JSON-safe catalog
revision, pinned permission mode, bounded instructions, and canonical
authorized tool catalog before spawning.
- Verify the initialization protocol version, child PID,
persistent-session support, exact-model support, runner-owned permission
policy, semantic-tool bridge, and structured-input contract.
- Open an identity-bound session and require the requested and effective
models, permission mode, session identifier, digests, and optional
recovery identity to match exactly.
- Attach the run and require the sidecar to confirm the exact run
identifier and catalog revision.
- Retry failed transport cleanup while retaining lifecycle ownership;
terminate the sidecar after every failed bootstrap and on an unclosed
session drop.
- Close sessions without discarding persistent state and make explicit
shutdown idempotent.
- Extend the package-local fake sidecar with deterministic bootstrap,
wrong-model, and wrong-run responses.
- Add five integration tests covering successful bootstrap/shutdown,
pre-spawn policy validation, model mismatch, run mismatch, and recovery
identity matching.
- Document the session bootstrap boundary.
- Do not change dependencies, lockfiles, workflows, runnerd selection,
server behavior, UI, or migrations.

## Verification

- Replay base: `f038633bf5b04163ff985ef0542876bd9f455379` (`master`
after #12417 merged).
- Exact replay head: `a6d9ad62f20fdb47a1dbc76aa4baa9d8fa6dae53`.
- Stable patch ID: `82b6f2551749598a688c3f44a1a3714516030429`, identical
to the reviewed `e6e550f9..d51a8855` delta.
- The exact delta is 5 files and 609 additions, all in
`packages/paperclip-runner`; it contains no lockfile, workflow, server,
UI, or migration change.
- Focused Rust lifecycle, package, repository, security, and Greptile
checks: **PASSED** on the replayed exact head. Full CI run `33362799786`
is green; its failed-job retry passed one unrelated flaky server shard
without a patch change. Greptile is exact-head 5/5, all security checks
pass, and no review threads remain unresolved.
- No local test result is claimed. GitHub Actions is the authoritative
verification environment for this replayed revision.

## Risks

- This lifecycle owns a child process and session identity.
Configuration is fully validated before spawning, every bootstrap
response is checked against the child PID and requested identity, and
failed bootstrap always terminates the process.
- Recovery identity matching is exact so a persisted native record
cannot silently attach to another session, model, workspace, profile, or
permission policy.
- Explicit shutdown preserves persistent provider state; a dropped
unclosed session still terminates its process group as a safety
fallback.
- The package exports a new Rust module, but no production path
constructs it in this pull request.
- Turn commands, event polling, request resolution, and runnerd
selection remain later slices.

> 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 either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] 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:
Dotta 2026-08-31 01:20:39 -05:00 committed by GitHub
parent f038633bf5
commit 10cf68295d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 609 additions and 0 deletions

View File

@ -89,6 +89,11 @@ correlations. Terminal events flush the final assistant message first and clear
unresolved turn-scoped requests. This reducer still does not select ACPX in
runnerd.
The package-local session bootstrap starts the bounded sidecar transport,
verifies the Codex-only capability handshake and effective model, opens one
identity-bound session, and confirms its run attachment. Any failed bootstrap
terminates the process; session shutdown preserves persistent provider state.
Run the complete contract gate with:
```sh

View File

@ -0,0 +1,398 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::acpx_provider_state::AcpxProviderState;
use crate::acpx_sidecar_transport::{AcpxSidecarTransport, AcpxSidecarTransportConfig};
use crate::generated_acpx_sidecar_contract::{
GeneratedAcpxSidecarCommand, GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
};
use crate::local_runner::LocalRunnerError;
use crate::provider_bridge::{AuthorizedToolSet, ProviderToolBridge};
const MAX_ID_CHARS: usize = 240;
const MAX_MODEL_CHARS: usize = 240;
const MAX_SYSTEM_INSTRUCTIONS_BYTES: usize = 1024 * 1024;
const MAX_JSON_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum AcpxPermissionMode {
ApproveAll,
ApproveReads,
DenyAll,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AcpxProviderSessionIdentity {
pub kind: String,
pub normalized_session_id: String,
pub acpx_record_id: String,
pub backend_session_id: String,
pub agent_session_id: String,
pub profile_digest: String,
pub workspace_digest: String,
pub requested_model: String,
pub effective_model: String,
#[serde(default)]
pub permission_mode: Option<AcpxPermissionMode>,
}
#[derive(Clone, Debug)]
pub struct AcpxProviderSessionConfig {
pub transport: AcpxSidecarTransportConfig,
pub agent: String,
pub model: String,
pub run_id: String,
pub catalog_revision: u64,
pub runtime_directory: PathBuf,
pub normalized_session_id: String,
pub working_directory: PathBuf,
pub permission_mode: AcpxPermissionMode,
pub permission_mode_pinned: bool,
pub system_instructions: String,
pub tool_set: AuthorizedToolSet,
pub expected_identity: Option<AcpxProviderSessionIdentity>,
}
impl AcpxProviderSessionConfig {
pub fn validate(&self) -> Result<(), LocalRunnerError> {
self.transport.validate()?;
if self.agent != "codex" {
return Err(LocalRunnerError::invalid(
"the initial ACPX provider session supports Codex only",
));
}
validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?;
validate_text(&self.run_id, 160, "ACPX run id")?;
validate_text(
&self.normalized_session_id,
160,
"ACPX normalized session id",
)?;
if self.catalog_revision == 0 || self.catalog_revision > MAX_JSON_SAFE_INTEGER {
return Err(LocalRunnerError::invalid(
"ACPX catalog revision must be a positive JSON-safe integer",
));
}
for (path, label) in [
(&self.runtime_directory, "runtime directory"),
(&self.working_directory, "working directory"),
] {
if !path.is_absolute() {
return Err(LocalRunnerError::invalid(format!(
"ACPX {label} must be an existing absolute directory"
)));
}
if path.to_str().is_none() {
return Err(LocalRunnerError::invalid(format!(
"ACPX {label} must be valid UTF-8"
)));
}
if !path.is_dir() {
return Err(LocalRunnerError::invalid(format!(
"ACPX {label} must be an existing absolute directory"
)));
}
}
if !self.permission_mode_pinned {
return Err(LocalRunnerError::invalid(
"ACPX permission mode must be pinned by the runner policy",
));
}
if self.system_instructions.len() > MAX_SYSTEM_INSTRUCTIONS_BYTES
|| self.system_instructions.contains('\0')
{
return Err(LocalRunnerError::invalid(
"ACPX system instructions exceed their bounded contract",
));
}
let mut bridge = ProviderToolBridge::default();
bridge.prepare(self.tool_set.clone()).map_err(|error| {
LocalRunnerError::invalid(format!("ACPX authorized tools are invalid: {error}"))
})?;
if let Some(expected_identity) = self.expected_identity.as_ref() {
expected_identity.validate()?;
if expected_identity.normalized_session_id != self.normalized_session_id
|| expected_identity.requested_model != self.model
|| expected_identity.effective_model != self.model
|| expected_identity.permission_mode != Some(self.permission_mode)
{
return Err(LocalRunnerError::invalid(
"ACPX expected identity conflicts with the requested session",
));
}
}
Ok(())
}
}
impl AcpxProviderSessionIdentity {
pub fn validate(&self) -> Result<(), LocalRunnerError> {
if self.kind != "acpx" {
return Err(LocalRunnerError::invalid(
"ACPX session identity kind is invalid",
));
}
for (value, label) in [
(&self.normalized_session_id, "normalized session"),
(&self.acpx_record_id, "record"),
(&self.backend_session_id, "backend session"),
(&self.agent_session_id, "agent session"),
(&self.requested_model, "requested model"),
(&self.effective_model, "effective model"),
] {
validate_text(value, MAX_ID_CHARS, &format!("ACPX {label} identity"))?;
}
for (value, label) in [
(&self.profile_digest, "profile"),
(&self.workspace_digest, "workspace"),
] {
if !is_sha256_digest(value) {
return Err(LocalRunnerError::invalid(format!(
"ACPX {label} digest is invalid"
)));
}
}
Ok(())
}
}
pub struct AcpxProviderSession {
transport: AcpxSidecarTransport,
state: AcpxProviderState,
identity: AcpxProviderSessionIdentity,
catalog_revision: u64,
closed: bool,
transport_terminated: bool,
}
impl AcpxProviderSession {
pub fn start(config: &AcpxProviderSessionConfig) -> Result<Self, LocalRunnerError> {
config.validate()?;
let mut transport = AcpxSidecarTransport::start(&config.transport)?;
let bootstrap = bootstrap(&mut transport, config);
let (identity, state) = match bootstrap {
Ok(value) => value,
Err(error) => {
let cleanup = transport.shutdown();
return Err(with_cleanup_error(error, cleanup));
}
};
Ok(Self {
transport,
state,
identity,
catalog_revision: config.catalog_revision,
closed: false,
transport_terminated: false,
})
}
pub fn process_id(&self) -> u32 {
self.transport.process_id()
}
pub fn identity(&self) -> &AcpxProviderSessionIdentity {
&self.identity
}
pub fn state(&self) -> &AcpxProviderState {
&self.state
}
pub fn catalog_revision(&self) -> u64 {
self.catalog_revision
}
pub fn shutdown(&mut self, reason: &str) -> Result<(), LocalRunnerError> {
if self.closed {
return self.terminate_transport();
}
self.closed = true;
let close = self.transport.request(
GeneratedAcpxSidecarCommand::SessionClose,
json!({
"reason": bounded_reason(reason),
"discardPersistentState": false,
}),
);
let terminate = self.terminate_transport();
match (close, terminate) {
(Ok(_), Ok(())) => Ok(()),
(Err(error), cleanup) => Err(with_cleanup_error(error, cleanup)),
(Ok(_), Err(error)) => Err(error),
}
}
fn terminate_transport(&mut self) -> Result<(), LocalRunnerError> {
if self.transport_terminated {
return Ok(());
}
self.transport.shutdown()?;
self.transport_terminated = true;
Ok(())
}
}
impl Drop for AcpxProviderSession {
fn drop(&mut self) {
if !self.transport_terminated {
self.closed = true;
let _ = self.terminate_transport();
}
}
}
fn bootstrap(
transport: &mut AcpxSidecarTransport,
config: &AcpxProviderSessionConfig,
) -> Result<(AcpxProviderSessionIdentity, AcpxProviderState), LocalRunnerError> {
let initialized = transport.request(
GeneratedAcpxSidecarCommand::Initialize,
json!({"agent": config.agent, "model": config.model}),
)?;
verify_initialize_response(&initialized, transport.process_id())?;
let opened = transport.request(
GeneratedAcpxSidecarCommand::SessionOpen,
json!({
"runtimeDirectory": config.runtime_directory,
"normalizedSessionId": config.normalized_session_id,
"workingDirectory": config.working_directory,
"agent": config.agent,
"model": config.model,
"permissionMode": config.permission_mode,
"permissionModePinned": config.permission_mode_pinned,
"systemInstructions": config.system_instructions,
"runtimeContext": Value::Null,
"tools": config.tool_set.operations,
"expectedIdentity": config.expected_identity,
}),
)?;
let identity = verify_open_response(&opened, transport.process_id(), config)?;
let attached = transport.request(
GeneratedAcpxSidecarCommand::RunAttach,
json!({
"runId": config.run_id,
"catalogRevision": config.catalog_revision,
"tools": config.tool_set.operations,
}),
)?;
if attached.get("runId").and_then(Value::as_str) != Some(config.run_id.as_str())
|| attached.get("catalogRevision").and_then(Value::as_u64) != Some(config.catalog_revision)
{
return Err(LocalRunnerError::invalid(
"ACPX sidecar did not confirm the requested run attachment",
));
}
Ok((identity, AcpxProviderState::new(&config.run_id)?))
}
fn verify_initialize_response(value: &Value, process_id: u32) -> Result<(), LocalRunnerError> {
if value.get("protocolVersion").and_then(Value::as_u64)
!= Some(GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION)
|| value.get("sidecarPid").and_then(Value::as_u64) != Some(u64::from(process_id))
|| !value.get("profile").is_some_and(Value::is_object)
|| value
.pointer("/capabilities/persistentSessions")
.and_then(Value::as_bool)
!= Some(true)
|| value
.pointer("/capabilities/exactModelVerification")
.and_then(Value::as_bool)
!= Some(true)
|| value
.pointer("/capabilities/permissions")
.and_then(Value::as_str)
!= Some("runner_policy")
|| value
.pointer("/capabilities/semanticTools")
.and_then(Value::as_str)
!= Some("runner_bridge")
|| value
.pointer("/capabilities/structuredInput")
.and_then(Value::as_str)
!= Some("paperclip.question_set.v1")
{
return Err(LocalRunnerError::invalid(
"ACPX sidecar initialization capabilities are invalid",
));
}
Ok(())
}
fn verify_open_response(
value: &Value,
process_id: u32,
config: &AcpxProviderSessionConfig,
) -> Result<AcpxProviderSessionIdentity, LocalRunnerError> {
if value.get("sidecarPid").and_then(Value::as_u64) != Some(u64::from(process_id))
|| !value.get("status").is_some_and(Value::is_object)
{
return Err(LocalRunnerError::invalid(
"ACPX sidecar session-open response is invalid",
));
}
let identity: AcpxProviderSessionIdentity = serde_json::from_value(
value
.get("identity")
.cloned()
.ok_or_else(|| LocalRunnerError::invalid("ACPX sidecar omitted its identity"))?,
)
.map_err(|error| {
LocalRunnerError::invalid(format!("ACPX sidecar identity is invalid: {error}"))
})?;
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 sidecar identity does not match the requested session",
));
}
Ok(identity)
}
fn validate_text(value: &str, max_chars: usize, label: &str) -> Result<(), LocalRunnerError> {
if value.trim().is_empty()
|| value.chars().count() > max_chars
|| value.chars().any(char::is_control)
{
return Err(LocalRunnerError::invalid(format!("{label} 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())
}
fn bounded_reason(value: &str) -> String {
value.chars().take(4_000).collect()
}
fn with_cleanup_error(
error: LocalRunnerError,
cleanup: Result<(), LocalRunnerError>,
) -> LocalRunnerError {
match cleanup {
Ok(()) => error,
Err(cleanup) => LocalRunnerError::invalid(format!(
"{error}; ACPX sidecar cleanup also failed: {cleanup}"
)),
}
}

View File

@ -79,6 +79,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
eprintln!("amber-signal-7305");
std::process::exit(9);
}
"bootstrap" | "bootstrap-wrong-model" | "bootstrap-wrong-run" => {
write_json(&mut stdout, &bootstrap_success(id, command, &request, mode))?;
}
"happy" => {
write_event(&mut stdout, next_sequence)?;
next_sequence += 1;
@ -90,6 +93,58 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn bootstrap_success(id: u64, command: &str, request: &Value, mode: &str) -> Value {
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
let result = match command {
"initialize" => json!({
"protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
"sidecarPid": std::process::id(),
"profile": {"agent":"codex"},
"capabilities": {
"persistentSessions": true,
"exactModelVerification": true,
"permissions": "runner_policy",
"semanticTools": "runner_bridge",
"structuredInput": "paperclip.question_set.v1",
},
}),
"session.open" => {
let model = params
.get("model")
.and_then(Value::as_str)
.unwrap_or("missing");
json!({
"sidecarPid": std::process::id(),
"identity": {
"kind": "acpx",
"normalizedSessionId": params.get("normalizedSessionId"),
"acpxRecordId": "record-1",
"backendSessionId": "backend-1",
"agentSessionId": "agent-1",
"profileDigest": format!("sha256:{}", "1".repeat(64)),
"workspaceDigest": format!("sha256:{}", "2".repeat(64)),
"requestedModel": model,
"effectiveModel": if mode == "bootstrap-wrong-model" { "wrong-model" } else { model },
"permissionMode": params.get("permissionMode"),
},
"status": {},
})
}
"run.attach" => json!({
"runId": if mode == "bootstrap-wrong-run" { "wrong-run" } else { params.get("runId").and_then(Value::as_str).unwrap_or("missing") },
"catalogRevision": params.get("catalogRevision"),
}),
"session.close" => json!({"closed":true}),
_ => json!({"command":command,"params":params}),
};
json!({
"protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
"id": id,
"ok": true,
"result": result,
})
}
fn success(id: u64, command: &str, request: &Value) -> Value {
json!({
"protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,

View File

@ -2,6 +2,7 @@
pub mod acpx_event_payload;
pub mod acpx_event_scope;
pub mod acpx_provider_session;
pub mod acpx_provider_state;
pub mod acpx_sidecar_transport;
pub mod codex_provider;

View File

@ -0,0 +1,150 @@
use std::path::PathBuf;
use std::time::Duration;
use paperclip_runner_core::acpx_provider_session::{
AcpxPermissionMode, AcpxProviderSession, 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 tool_set() -> AuthorizedToolSet {
let operations = vec![AuthorizedTool {
operation_id: "issues.read".to_owned(),
version: 1,
description: "Read an issue.".to_owned(),
input_schema: json!({"type":"object"}),
response_schema: json!({"type":"object"}),
}];
AuthorizedToolSet {
schema: "paperclip.runner.authorized-tools.v1".to_owned(),
schema_version: 1,
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
operations,
}
}
fn config(mode: &str) -> AcpxProviderSessionConfig {
AcpxProviderSessionConfig {
transport: AcpxSidecarTransportConfig {
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-acpx-sidecar")),
args: vec!["--mode".to_owned(), mode.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: 1,
runtime_directory: std::env::temp_dir(),
normalized_session_id: "session-1".to_owned(),
working_directory: std::env::temp_dir(),
permission_mode: AcpxPermissionMode::ApproveReads,
permission_mode_pinned: true,
system_instructions: "Complete the supplied task.".to_owned(),
tool_set: tool_set(),
expected_identity: None,
}
}
fn expected_identity() -> AcpxProviderSessionIdentity {
AcpxProviderSessionIdentity {
kind: "acpx".to_owned(),
normalized_session_id: "session-1".to_owned(),
acpx_record_id: "record-1".to_owned(),
backend_session_id: "backend-1".to_owned(),
agent_session_id: "agent-1".to_owned(),
profile_digest: format!("sha256:{}", "1".repeat(64)),
workspace_digest: format!("sha256:{}", "2".repeat(64)),
requested_model: "gpt-5.6-sol".to_owned(),
effective_model: "gpt-5.6-sol".to_owned(),
permission_mode: Some(AcpxPermissionMode::ApproveReads),
}
}
fn start_error(config: &AcpxProviderSessionConfig) -> String {
match AcpxProviderSession::start(config) {
Ok(mut session) => {
let _ = session.shutdown("unexpected successful bootstrap");
panic!("ACPX provider session unexpectedly started")
}
Err(error) => error.to_string(),
}
}
#[test]
fn bootstraps_a_codex_session_and_confirms_run_identity() {
let mut session = AcpxProviderSession::start(&config("bootstrap")).unwrap();
assert!(session.process_id() > 0);
assert_eq!(session.identity(), &expected_identity());
assert_eq!(session.state().run_id(), "run-1");
assert_eq!(session.state().active_turn_id(), None);
assert_eq!(session.catalog_revision(), 1);
session.shutdown("test complete").unwrap();
session.shutdown("already closed").unwrap();
}
#[test]
fn validates_codex_policy_and_tool_catalog_before_spawning() {
let mut invalid_agent = config("bootstrap");
invalid_agent.agent = "opencode".to_owned();
assert!(start_error(&invalid_agent).contains("Codex only"));
let mut unpinned = config("bootstrap");
unpinned.permission_mode_pinned = false;
assert!(start_error(&unpinned).contains("must be pinned"));
let mut invalid_tools = config("bootstrap");
invalid_tools.tool_set.catalog_digest = "invalid".to_owned();
assert!(start_error(&invalid_tools).contains("authorized tools"));
}
#[test]
fn rejects_a_sidecar_that_reports_another_effective_model() {
let error = start_error(&config("bootstrap-wrong-model"));
assert!(error.contains("identity does not match"), "{error}");
}
#[test]
fn rejects_a_sidecar_that_does_not_confirm_the_run_attachment() {
let error = start_error(&config("bootstrap-wrong-run"));
assert!(error.contains("run attachment"), "{error}");
}
#[test]
fn validates_recovery_identity_against_the_requested_session() {
let mut recovered = config("bootstrap");
recovered.expected_identity = Some(expected_identity());
let mut session = AcpxProviderSession::start(&recovered).unwrap();
assert_eq!(
session.identity(),
recovered.expected_identity.as_ref().unwrap()
);
session.shutdown("test complete").unwrap();
let mut mismatch = config("bootstrap");
let mut expected = expected_identity();
expected.normalized_session_id = "another-session".to_owned();
mismatch.expected_identity = Some(expected);
assert!(start_error(&mismatch).contains("conflicts with the requested session"));
}
#[cfg(unix)]
#[test]
fn rejects_non_utf8_directories_before_spawning() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let mut directory_name =
format!("paperclip-acpx-non-utf8-{}-", std::process::id()).into_bytes();
directory_name.push(0xff);
let directory = std::env::temp_dir().join(OsString::from_vec(directory_name));
let mut invalid = config("bootstrap");
invalid.working_directory = directory;
let error = start_error(&invalid);
assert!(error.contains("must be valid UTF-8"), "{error}");
}