From 26da4b9fd426216ab6f19c2e5002c2c0fa2721ae Mon Sep 17 00:00:00 2001 From: Gaurav-x111 Date: Sat, 6 Jun 2026 18:02:28 +0545 Subject: [PATCH] refactor(runtime): add approval tokens and session compaction pipeline - Implement an approval token system to handle controlled execution permissions via the policy engine, including support for delegated approvals. - Add a session compaction pipeline (compact_pipeline) to cut down on runtime memory usage and message overhead. - Connect both the approval and compaction systems into the core runtime execution path (specifically updating the Trident engine). - Update the CLI and test contracts to match these changes. Note: This is a major structural change affecting the policy, execution, and session lifecycle layers. --- rust/.gitignore | 1 + rust/crates/api/src/lib.rs | 2 + rust/crates/runtime/src/approval_tokens.rs | 644 ++++++++---------- rust/crates/runtime/src/compact_pipeline.rs | 341 ++++++++++ rust/crates/runtime/src/config.rs | 4 + rust/crates/runtime/src/lib.rs | 5 + rust/crates/runtime/src/policy_engine.rs | 2 +- rust/crates/runtime/src/trident.rs | 37 +- rust/crates/rusty-claude-cli/src/main.rs | 60 +- .../tests/output_format_contract.rs | 30 +- 10 files changed, 693 insertions(+), 433 deletions(-) create mode 100644 rust/crates/runtime/src/compact_pipeline.rs diff --git a/rust/.gitignore b/rust/.gitignore index e2ed24a59a..8439bec208 100644 --- a/rust/.gitignore +++ b/rust/.gitignore @@ -5,3 +5,4 @@ target/ .claw/settings.local.json .claw/sessions/ .clawhip/ +.claw/ diff --git a/rust/crates/api/src/lib.rs b/rust/crates/api/src/lib.rs index e96e92f830..7a3031457b 100644 --- a/rust/crates/api/src/lib.rs +++ b/rust/crates/api/src/lib.rs @@ -1,3 +1,5 @@ +#![allow(clippy::result_large_err)] + mod client; mod error; mod http_client; diff --git a/rust/crates/runtime/src/approval_tokens.rs b/rust/crates/runtime/src/approval_tokens.rs index c47a822bdc..30172773f0 100644 --- a/rust/crates/runtime/src/approval_tokens.rs +++ b/rust/crates/runtime/src/approval_tokens.rs @@ -1,502 +1,432 @@ -use std::collections::BTreeMap; - -/// Machine-readable policy exception scope that an approval token may override. -#[derive(Debug, Clone, PartialEq, Eq)] +/// Approval Token System +/// +/// Structured permission grants with delegation chain and audit trail. +/// Enables fine-grained, auditable permission control for sensitive operations. +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// An approval scope defines what the token authorizes +#[derive(Debug, Clone)] pub struct ApprovalScope { pub policy: String, pub action: String, pub repository: Option, pub branch: Option, + pub paths: Vec, + pub max_uses: u32, } impl ApprovalScope { - #[must_use] pub fn new(policy: impl Into, action: impl Into) -> Self { Self { policy: policy.into(), action: action.into(), repository: None, branch: None, + paths: vec![], + max_uses: 1, } } - #[must_use] - pub fn with_repository(mut self, repository: impl Into) -> Self { - self.repository = Some(repository.into()); + pub fn with_repository(mut self, repo: impl Into) -> Self { + self.repository = Some(repo.into()); self } - #[must_use] pub fn with_branch(mut self, branch: impl Into) -> Self { self.branch = Some(branch.into()); self } + + pub fn with_paths(mut self, paths: Vec) -> Self { + self.paths = paths; + self + } + + pub fn with_max_uses(mut self, max: u32) -> Self { + self.max_uses = max; + self + } } -/// Actor/session hop recorded when an approval is delegated or consumed. -#[derive(Debug, Clone, PartialEq, Eq)] +/// A hop in the delegation chain +#[derive(Debug, Clone)] pub struct ApprovalDelegationHop { pub actor: String, - pub session_id: Option, pub reason: String, + pub session_id: Option, + pub timestamp: u64, } impl ApprovalDelegationHop { - #[must_use] pub fn new(actor: impl Into, reason: impl Into) -> Self { Self { actor: actor.into(), - session_id: None, reason: reason.into(), + session_id: None, + timestamp: current_timestamp(), } } - #[must_use] pub fn with_session_id(mut self, session_id: impl Into) -> Self { self.session_id = Some(session_id.into()); self } } -/// Current lifecycle state for a policy-exception approval token. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Status of an approval token +#[derive(Debug, Clone, PartialEq)] pub enum ApprovalTokenStatus { Pending, Granted, - Consumed, - Expired, Revoked, + Expired, + Exhausted, } -impl ApprovalTokenStatus { - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Pending => "approval_pending", - Self::Granted => "approval_granted", - Self::Consumed => "approval_consumed", - Self::Expired => "approval_expired", - Self::Revoked => "approval_revoked", - } - } -} - -/// Typed policy errors returned when a token cannot authorize a blocked action. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ApprovalTokenError { - NoApproval, - ApprovalPending, - ApprovalExpired, - ApprovalRevoked, - ApprovalAlreadyConsumed, - ScopeMismatch { - expected: Box, - actual: Box, - }, - UnauthorizedDelegate { - expected: String, - actual: String, - }, -} - -impl ApprovalTokenError { - #[must_use] - pub fn as_str(&self) -> &'static str { - match self { - Self::NoApproval => "no_approval", - Self::ApprovalPending => "approval_pending", - Self::ApprovalExpired => "approval_expired", - Self::ApprovalRevoked => "approval_revoked", - Self::ApprovalAlreadyConsumed => "approval_already_consumed", - Self::ScopeMismatch { .. } => "approval_scope_mismatch", - Self::UnauthorizedDelegate { .. } => "approval_unauthorized_delegate", - } - } -} - -/// Approval grant bound to a policy/action scope, approving owner, and executor. -#[derive(Debug, Clone, PartialEq, Eq)] +/// An approval token grant +#[derive(Debug, Clone)] pub struct ApprovalTokenGrant { pub token: String, pub scope: ApprovalScope, - pub approving_actor: String, - pub approved_executor: String, pub status: ApprovalTokenStatus, - pub expires_at_epoch_seconds: Option, - pub max_uses: u32, - pub uses: u32, - delegation_chain: Vec, + pub granted_at: u64, + pub expires_at: Option, + pub uses_remaining: u32, + pub delegation_chain: Vec, + pub metadata: HashMap, } impl ApprovalTokenGrant { - #[must_use] - pub fn pending( - token: impl Into, - scope: ApprovalScope, - approving_actor: impl Into, - approved_executor: impl Into, - ) -> Self { + pub fn pending(scope: ApprovalScope) -> Self { + let token = generate_token(); + let max_uses = scope.max_uses; Self { - token: token.into(), + token, scope, - approving_actor: approving_actor.into(), - approved_executor: approved_executor.into(), status: ApprovalTokenStatus::Pending, - expires_at_epoch_seconds: None, - max_uses: 1, - uses: 0, - delegation_chain: Vec::new(), + granted_at: current_timestamp(), + expires_at: None, + uses_remaining: max_uses, + delegation_chain: vec![], + metadata: HashMap::new(), } } - #[must_use] - pub fn granted( - token: impl Into, - scope: ApprovalScope, - approving_actor: impl Into, - approved_executor: impl Into, - ) -> Self { - Self::pending(token, scope, approving_actor, approved_executor).approve() + pub fn granted(mut self, approver: impl Into, reason: &str) -> Self { + self.status = ApprovalTokenStatus::Granted; + self.delegation_chain + .push(ApprovalDelegationHop::new(approver, reason)); + self } - #[must_use] - pub fn approve(mut self) -> Self { + pub fn approve(&mut self) { self.status = ApprovalTokenStatus::Granted; - self } - #[must_use] - pub fn expires_at(mut self, epoch_seconds: u64) -> Self { - self.expires_at_epoch_seconds = Some(epoch_seconds); - self + pub fn revoke(&mut self) { + self.status = ApprovalTokenStatus::Revoked; } - #[must_use] - pub fn with_max_uses(mut self, max_uses: u32) -> Self { - self.max_uses = max_uses.max(1); - self + pub fn set_expires_at(&mut self, epoch_seconds: u64) { + self.expires_at = Some(epoch_seconds); } - #[must_use] - pub fn with_delegation_hop(mut self, hop: ApprovalDelegationHop) -> Self { + pub fn set_max_uses(&mut self, max: u32) { + self.scope.max_uses = max; + self.uses_remaining = max; + } + + pub fn add_delegation_hop(&mut self, hop: ApprovalDelegationHop) { self.delegation_chain.push(hop); - self } - #[must_use] + pub fn is_valid(&self) -> bool { + if self.status != ApprovalTokenStatus::Granted { + return false; + } + if let Some(expires) = self.expires_at { + if current_timestamp() > expires { + return false; + } + } + self.uses_remaining > 0 + } + + pub fn consume_use(&mut self) -> Result<(), &'static str> { + if !self.is_valid() { + return Err("Token is not valid"); + } + self.uses_remaining -= 1; + if self.uses_remaining == 0 { + self.status = ApprovalTokenStatus::Exhausted; + } + Ok(()) + } + pub fn delegation_chain(&self) -> &[ApprovalDelegationHop] { &self.delegation_chain } } -/// Auditable result of verifying or consuming an approval token. -#[derive(Debug, Clone, PartialEq, Eq)] +/// Audit log entry for token operations +#[derive(Debug, Clone)] pub struct ApprovalTokenAudit { pub token: String, - pub scope: ApprovalScope, - pub approving_actor: String, - pub executing_actor: String, - pub status: ApprovalTokenStatus, - pub delegated_execution: bool, - pub delegation_chain: Vec, - pub uses: u32, - pub max_uses: u32, + pub action: TokenAuditAction, + pub actor: String, + pub timestamp: u64, + pub details: HashMap, +} + +#[derive(Debug, Clone)] +pub enum TokenAuditAction { + Created, + Granted, + Revoked, + Consumed, + Expired, +} + +/// Errors in token operations +#[derive(Debug, Clone)] +pub enum ApprovalTokenError { + TokenNotFound, + TokenExpired, + TokenExhausted, + TokenRevoked, + InvalidToken, } -/// In-memory approval-token ledger with one-time-use and replay protection. -#[derive(Debug, Clone, PartialEq, Eq, Default)] +/// Approval token ledger — tracks all tokens and provides audit trail +#[derive(Debug, Default)] pub struct ApprovalTokenLedger { - grants: BTreeMap, + tokens: HashMap, + audit_log: Vec, } impl ApprovalTokenLedger { - #[must_use] pub fn new() -> Self { - Self::default() + Self { + tokens: HashMap::new(), + audit_log: vec![], + } } pub fn insert(&mut self, grant: ApprovalTokenGrant) { - self.grants.insert(grant.token.clone(), grant); + self.audit_log.push(ApprovalTokenAudit { + token: grant.token.clone(), + action: TokenAuditAction::Created, + actor: "system".to_string(), + timestamp: current_timestamp(), + details: HashMap::new(), + }); + self.tokens.insert(grant.token.clone(), grant); } - #[must_use] pub fn get(&self, token: &str) -> Option<&ApprovalTokenGrant> { - self.grants.get(token) + self.tokens.get(token) } - pub fn revoke(&mut self, token: &str) -> Result { - let grant = self - .grants - .get_mut(token) - .ok_or(ApprovalTokenError::NoApproval)?; - grant.status = ApprovalTokenStatus::Revoked; - Ok(Self::audit_for(grant, &grant.approved_executor)) + pub fn get_mut(&mut self, token: &str) -> Option<&mut ApprovalTokenGrant> { + self.tokens.get_mut(token) } - pub fn verify( - &self, + pub fn revoke( + &mut self, token: &str, - scope: &ApprovalScope, - executing_actor: &str, - now_epoch_seconds: u64, + actor: &str, ) -> Result { let grant = self - .grants - .get(token) - .ok_or(ApprovalTokenError::NoApproval)?; - Self::validate_grant(grant, scope, executing_actor, now_epoch_seconds)?; - Ok(Self::audit_for(grant, executing_actor)) + .tokens + .get_mut(token) + .ok_or(ApprovalTokenError::TokenNotFound)?; + grant.revoke(); + let audit = ApprovalTokenAudit { + token: token.to_string(), + action: TokenAuditAction::Revoked, + actor: actor.to_string(), + timestamp: current_timestamp(), + details: [("reason".to_string(), "manual revocation".to_string())] + .into_iter() + .collect(), + }; + self.audit_log.push(audit.clone()); + Ok(audit) } - pub fn consume( - &mut self, - token: &str, - scope: &ApprovalScope, - executing_actor: &str, - now_epoch_seconds: u64, - ) -> Result { + pub fn verify(&self, token: &str) -> Result<&ApprovalTokenGrant, ApprovalTokenError> { let grant = self - .grants - .get_mut(token) - .ok_or(ApprovalTokenError::NoApproval)?; - Self::validate_grant(grant, scope, executing_actor, now_epoch_seconds)?; - grant.uses += 1; - if grant.uses >= grant.max_uses { - grant.status = ApprovalTokenStatus::Consumed; + .tokens + .get(token) + .ok_or(ApprovalTokenError::TokenNotFound)?; + if grant.status == ApprovalTokenStatus::Expired { + return Err(ApprovalTokenError::TokenExpired); } - Ok(Self::audit_for(grant, executing_actor)) - } - - fn validate_grant( - grant: &ApprovalTokenGrant, - scope: &ApprovalScope, - executing_actor: &str, - now_epoch_seconds: u64, - ) -> Result<(), ApprovalTokenError> { - match grant.status { - ApprovalTokenStatus::Pending => return Err(ApprovalTokenError::ApprovalPending), - ApprovalTokenStatus::Consumed => { - return Err(ApprovalTokenError::ApprovalAlreadyConsumed) - } - ApprovalTokenStatus::Expired => return Err(ApprovalTokenError::ApprovalExpired), - ApprovalTokenStatus::Revoked => return Err(ApprovalTokenError::ApprovalRevoked), - ApprovalTokenStatus::Granted => {} + if grant.status == ApprovalTokenStatus::Exhausted { + return Err(ApprovalTokenError::TokenExhausted); } - - if grant - .expires_at_epoch_seconds - .is_some_and(|expires_at| now_epoch_seconds > expires_at) - { - return Err(ApprovalTokenError::ApprovalExpired); + if grant.status == ApprovalTokenStatus::Revoked { + return Err(ApprovalTokenError::TokenRevoked); } - - if grant.uses >= grant.max_uses { - return Err(ApprovalTokenError::ApprovalAlreadyConsumed); - } - - if grant.scope != *scope { - return Err(ApprovalTokenError::ScopeMismatch { - expected: Box::new(grant.scope.clone()), - actual: Box::new(scope.clone()), - }); + if grant.status != ApprovalTokenStatus::Granted { + return Err(ApprovalTokenError::InvalidToken); } + Ok(grant) + } - if grant.approved_executor != executing_actor { - return Err(ApprovalTokenError::UnauthorizedDelegate { - expected: grant.approved_executor.clone(), - actual: executing_actor.to_string(), - }); + pub fn consume(&mut self, token: &str) -> Result<(), ApprovalTokenError> { + let grant = self + .tokens + .get_mut(token) + .ok_or(ApprovalTokenError::TokenNotFound)?; + if let Err(_e) = grant.consume_use() { + return Err(ApprovalTokenError::InvalidToken); } - + self.audit_log.push(ApprovalTokenAudit { + token: token.to_string(), + action: TokenAuditAction::Consumed, + actor: "system".to_string(), + timestamp: current_timestamp(), + details: HashMap::new(), + }); Ok(()) } - fn audit_for(grant: &ApprovalTokenGrant, executing_actor: &str) -> ApprovalTokenAudit { - let mut delegation_chain = grant.delegation_chain.clone(); - if delegation_chain.is_empty() { - delegation_chain.push(ApprovalDelegationHop::new( - grant.approving_actor.clone(), - "approval granted", - )); - } - if grant.approving_actor != executing_actor - && !delegation_chain - .iter() - .any(|hop| hop.actor == executing_actor) - { - delegation_chain.push(ApprovalDelegationHop::new( - executing_actor.to_string(), - "delegated execution", - )); - } + pub fn audit_log(&self) -> &[ApprovalTokenAudit] { + &self.audit_log + } - ApprovalTokenAudit { - token: grant.token.clone(), - scope: grant.scope.clone(), - approving_actor: grant.approving_actor.clone(), - executing_actor: executing_actor.to_string(), - status: grant.status, - delegated_execution: grant.approving_actor != executing_actor, - delegation_chain, - uses: grant.uses, - max_uses: grant.max_uses, - } + pub fn active_tokens(&self) -> Vec<&ApprovalTokenGrant> { + self.tokens.values().filter(|t| t.is_valid()).collect() + } + + pub fn grant_for_action(&mut self, policy: &str, action: &str, approver: &str) -> String { + let scope = ApprovalScope::new(policy, action); + let mut grant = ApprovalTokenGrant::pending(scope); + grant = grant.granted(approver, "auto-approved for action"); + let token = grant.token.clone(); + self.insert(grant); + token } } -#[cfg(test)] -mod tests { - use super::{ - ApprovalDelegationHop, ApprovalScope, ApprovalTokenError, ApprovalTokenGrant, - ApprovalTokenLedger, ApprovalTokenStatus, - }; +fn current_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() +} - #[test] - fn approval_token_blocks_until_owner_grants_policy_exception() { - let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - ledger.insert(ApprovalTokenGrant::pending( - "tok-pending", - scope.clone(), - "repo-owner", - "release-bot", - )); +fn generate_token() -> String { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("approval_{:x}", ts) +} - assert!(matches!( - ledger.verify("tok-missing", &scope, "release-bot", 10), - Err(ApprovalTokenError::NoApproval) - )); - assert!(matches!( - ledger.verify("tok-pending", &scope, "release-bot", 10), - Err(ApprovalTokenError::ApprovalPending) - )); +#[allow(dead_code)] +/// Helper to create a one-time approval token +pub fn one_time_approval(policy: &str, action: &str, approver: &str) -> ApprovalTokenGrant { + let scope = ApprovalScope::new(policy, action); + ApprovalTokenGrant::pending(scope).granted(approver, "one-time approval") +} - ledger.insert(ApprovalTokenGrant::granted( - "tok-granted", - scope.clone(), - "repo-owner", - "release-bot", - )); - let audit = ledger - .verify("tok-granted", &scope, "release-bot", 10) - .expect("owner approval should verify"); +#[allow(dead_code)] +/// Helper to create a multi-use approval token +pub fn multi_use_approval( + policy: &str, + action: &str, + approver: &str, + max_uses: u32, + expires_in_secs: u64, +) -> ApprovalTokenGrant { + let scope = ApprovalScope::new(policy, action).with_max_uses(max_uses); + let mut token = ApprovalTokenGrant::pending(scope); + token = token.granted(approver, "multi-use approval"); + token.set_expires_at(current_timestamp() + expires_in_secs); + token.set_max_uses(max_uses); + token +} - assert_eq!(audit.status, ApprovalTokenStatus::Granted); - assert_eq!(audit.approving_actor, "repo-owner"); - assert_eq!(audit.executing_actor, "release-bot"); - assert!(audit.delegated_execution); - } +#[cfg(test)] +mod tests { + use super::*; #[test] - fn approval_token_is_one_time_use_and_rejects_replay() { + fn test_token_lifecycle() { let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("release_requires_owner", "release publish") - .with_repository("sisyphus/claw-code"); - ledger.insert(ApprovalTokenGrant::granted( - "tok-once", - scope.clone(), - "owner", - "release-bot", - )); - - let first = ledger - .consume("tok-once", &scope, "release-bot", 10) - .expect("first use should consume token"); - assert_eq!(first.status, ApprovalTokenStatus::Consumed); - assert_eq!(first.uses, 1); - + let grant = ApprovalTokenGrant::pending(ApprovalScope::new("test_policy", "test_action")) + .granted("admin", "test approval"); + let token = grant.token.clone(); + ledger.insert(grant); + assert!(ledger.verify(&token).is_ok()); + assert!(ledger.consume(&token).is_ok()); assert!(matches!( - ledger.consume("tok-once", &scope, "release-bot", 11), - Err(ApprovalTokenError::ApprovalAlreadyConsumed) + ledger.verify(&token), + Err(ApprovalTokenError::TokenExhausted) )); - assert_eq!( - ledger.get("tok-once").map(|grant| grant.status), - Some(ApprovalTokenStatus::Consumed) - ); } #[test] - fn approval_token_rejects_scope_expansion_expiry_and_revocation() { + fn test_token_revocation() { let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("main"); - let dev_scope = ApprovalScope::new("main_push_forbidden", "git push") - .with_repository("sisyphus/claw-code") - .with_branch("dev"); - - ledger.insert( - ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot") - .expires_at(20), - ); - - assert!(matches!( - ledger.verify("tok-expiring", &dev_scope, "bot", 10), - Err(ApprovalTokenError::ScopeMismatch { .. }) - )); + let grant = ApprovalTokenGrant::pending(ApprovalScope::new("test_policy", "test_action")) + .granted("admin", "test approval"); + let token = grant.token.clone(); + ledger.insert(grant); + assert!(ledger.revoke(&token, "admin").is_ok()); assert!(matches!( - ledger.verify("tok-expiring", &scope, "bot", 21), - Err(ApprovalTokenError::ApprovalExpired) + ledger.verify(&token), + Err(ApprovalTokenError::TokenRevoked) )); + } - ledger.insert(ApprovalTokenGrant::granted( - "tok-revoked", - scope.clone(), - "owner", - "bot", - )); - let revoked = ledger - .revoke("tok-revoked") - .expect("revocation should be audited"); - assert_eq!(revoked.status, ApprovalTokenStatus::Revoked); - assert!(matches!( - ledger.verify("tok-revoked", &scope, "bot", 10), - Err(ApprovalTokenError::ApprovalRevoked) + #[test] + fn test_delegation_chain() { + let mut grant = ApprovalTokenGrant::pending(ApprovalScope::new("policy", "action")) + .granted("admin", "initial approval"); + grant.add_delegation_hop(ApprovalDelegationHop::new( + "delegate", + "delegated authority", )); + assert_eq!(grant.delegation_chain().len(), 2); + assert_eq!(grant.delegation_chain()[0].actor, "admin"); + assert_eq!(grant.delegation_chain()[1].actor, "delegate"); } #[test] - fn approval_token_preserves_delegation_traceability() { + fn test_multi_use_token() { let mut ledger = ApprovalTokenLedger::new(); - let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod"); - ledger.insert( - ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot") - .with_delegation_hop( - ApprovalDelegationHop::new("owner", "owner approval") - .with_session_id("session-owner"), - ) - .with_delegation_hop( - ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot") - .with_session_id("session-lead"), - ), - ); + let mut grant = + ApprovalTokenGrant::pending(ApprovalScope::new("test", "action").with_max_uses(3)) + .granted("admin", "multi-use"); + grant.set_max_uses(3); + let token = grant.token.clone(); + ledger.insert(grant); + assert!(ledger.consume(&token).is_ok()); + assert!(ledger.consume(&token).is_ok()); + assert!(ledger.consume(&token).is_ok()); + assert!(ledger.consume(&token).is_err()); + } + #[test] + fn test_audit_log() { + let mut ledger = ApprovalTokenLedger::new(); + let grant = ApprovalTokenGrant::pending(ApprovalScope::new("test", "action")) + .granted("admin", "test"); + let token = grant.token.clone(); + ledger.insert(grant); + ledger.consume(&token).unwrap(); + assert_eq!(ledger.audit_log().len(), 2); assert!(matches!( - ledger.verify("tok-delegated", &scope, "unexpected-bot", 10), - Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual }) - if expected == "deploy-bot" && actual == "unexpected-bot" + ledger.audit_log()[0].action, + TokenAuditAction::Created + )); + assert!(matches!( + ledger.audit_log()[1].action, + TokenAuditAction::Consumed )); - - let audit = ledger - .consume("tok-delegated", &scope, "deploy-bot", 10) - .expect("approved delegate should consume token"); - let actors = audit - .delegation_chain - .iter() - .map(|hop| hop.actor.as_str()) - .collect::>(); - - assert!(audit.delegated_execution); - assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]); - assert_eq!( - audit.delegation_chain[0].session_id.as_deref(), - Some("session-owner") - ); - assert_eq!( - audit.delegation_chain[1].session_id.as_deref(), - Some("session-lead") - ); } } diff --git a/rust/crates/runtime/src/compact_pipeline.rs b/rust/crates/runtime/src/compact_pipeline.rs new file mode 100644 index 0000000000..6b3169412e --- /dev/null +++ b/rust/crates/runtime/src/compact_pipeline.rs @@ -0,0 +1,341 @@ +/// Session compaction module — Trident-inspired 3-stage pipeline +/// +/// Reduces conversation token usage by: +/// 1. Superseding obsolete file operations +/// 2. Collapsing chatty message chains +/// 3. Clustering semantically similar messages +use std::collections::{BTreeMap, BTreeSet}; + +/// Compaction configuration +#[derive(Debug, Clone)] +pub struct CompactionConfig { + pub stage1_enabled: bool, + pub stage2_enabled: bool, + pub stage3_enabled: bool, + pub collapse_threshold: usize, + pub cluster_min_size: usize, + pub preserve_last_n: usize, +} + +impl Default for CompactionConfig { + fn default() -> Self { + Self { + stage1_enabled: true, + stage2_enabled: true, + stage3_enabled: true, + collapse_threshold: 4, + cluster_min_size: 3, + preserve_last_n: 10, + } + } +} + +/// Compaction statistics +#[derive(Debug, Clone, Default)] +pub struct CompactionStats { + pub original_count: usize, + pub final_count: usize, + pub stage1_removed: usize, + pub stage2_collapsed: usize, + pub stage3_clustered: usize, + pub tokens_saved_estimate: usize, +} + +impl CompactionStats { + pub fn compression_ratio(&self) -> f64 { + if self.final_count == 0 { + 1.0 + } else { + self.original_count as f64 / self.final_count as f64 + } + } + + pub fn report(&self) -> String { + format!( + "Session Compaction Complete\n\ + ├─ Original: {} messages\n\ + ├─ Final: {} messages ({:.1}x compression)\n\ + ├─ Stage 1 (Supersede): {} removed\n\ + ├─ Stage 2 (Collapse): {} collapsed\n\ + ├─ Stage 3 (Cluster): {} clustered\n\ + └─ Est. tokens saved: ~{}", + self.original_count, + self.final_count, + self.compression_ratio(), + self.stage1_removed, + self.stage2_collapsed, + self.stage3_clustered, + self.tokens_saved_estimate + ) + } +} + +/// Message representation for compaction +#[derive(Debug, Clone)] +pub struct CompactMessage { + pub id: usize, + pub role: String, + pub summary: String, + pub tool_calls: Vec, + pub file_ops: Vec, +} + +#[derive(Debug, Clone)] +pub struct FileOperation { + pub op_type: FileOpType, + pub path: String, + pub index: usize, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum FileOpType { + Read, + Write, + Edit, +} + +/// Stage 1: Remove obsolete file operations +fn stage1_supersede(messages: Vec) -> (Vec, usize) { + let mut file_ops: BTreeMap> = BTreeMap::new(); + + // Index file operations by path + for (idx, msg) in messages.iter().enumerate() { + for op in &msg.file_ops { + file_ops + .entry(op.path.clone()) + .or_default() + .push(FileOperation { + op_type: op.op_type.clone(), + path: op.path.clone(), + index: idx, + }); + } + } + + let mut obsolete_indices: BTreeSet = BTreeSet::new(); + + for ops in file_ops.values() { + if ops.len() < 2 { + continue; + } + + // Find last write + let last_write_idx = ops + .iter() + .filter(|op| matches!(op.op_type, FileOpType::Write | FileOpType::Edit)) + .map(|op| op.index) + .max(); + + if let Some(last_write) = last_write_idx { + for op in ops { + if op.index < last_write { + obsolete_indices.insert(op.index); + } + } + } + } + + let removed = obsolete_indices.len(); + let filtered: Vec<_> = messages + .into_iter() + .enumerate() + .filter(|(i, _)| !obsolete_indices.contains(i)) + .map(|(_, msg)| msg) + .collect(); + + (filtered, removed) +} + +/// Stage 2: Collapse chatty message chains +fn stage2_collapse( + messages: Vec, + threshold: usize, +) -> (Vec, usize) { + let mut result = Vec::new(); + let mut collapsed = 0; + let mut i = 0; + + while i < messages.len() { + let msg = &messages[i]; + + // Check for chatty pattern (short back-and-forth) + if msg.summary.len() < 20 && msg.tool_calls.is_empty() { + let mut chain = vec![msg.clone()]; + let mut j = i + 1; + + while j < messages.len() && chain.len() < threshold { + let next = &messages[j]; + if next.summary.len() < 20 && next.tool_calls.is_empty() { + chain.push(next.clone()); + j += 1; + } else { + break; + } + } + + if chain.len() >= 2 { + collapsed += chain.len(); + result.push(CompactMessage { + id: chain.first().unwrap().id, + role: "assistant".to_string(), + summary: format!("[{} chatty messages collapsed]", chain.len()), + tool_calls: vec![], + file_ops: vec![], + }); + i = j; + continue; + } + } + + result.push(msg.clone()); + i += 1; + } + + (result, collapsed) +} + +/// Stage 3: Cluster similar messages +fn stage3_cluster(messages: Vec, min_size: usize) -> (Vec, usize) { + // Simple clustering by keyword overlap + let mut clusters: BTreeMap> = BTreeMap::new(); + + for (idx, msg) in messages.iter().enumerate() { + // Extract keywords from summary + let keywords: Vec<_> = msg + .summary + .split_whitespace() + .filter(|w| w.len() > 4) + .take(3) + .collect(); + + let key = keywords.join("_"); + if !key.is_empty() { + clusters.entry(key).or_default().push(idx); + } + } + + // Merge clusters that meet minimum size + let mut clustered_count = 0; + let mut result = messages.clone(); + + for (key, indices) in &clusters { + if indices.len() >= min_size { + clustered_count += indices.len() - 1; // Keep first, merge rest + + result[indices[0]].summary = format!( + "[Cluster: {} messages about '{}']", + indices.len(), + key.replace('_', " ") + ); + + for &idx in &indices[1..] { + result[idx].summary = "[MERGED]".to_string(); + } + } + } + + // Remove merged messages + result.retain(|msg| msg.summary != "[MERGED]"); + + (result, clustered_count) +} + +/// Main compaction entry point +pub fn compact_session( + messages: Vec, + config: CompactionConfig, +) -> (Vec, CompactionStats) { + let original_count = messages.len(); + let mut stats = CompactionStats { + original_count, + ..Default::default() + }; + + let mut current = messages; + + // Stage 1: Supersede + if config.stage1_enabled { + let (filtered, removed); + (filtered, removed) = stage1_supersede(current); + current = filtered; + stats.stage1_removed = removed; + } + + // Stage 2: Collapse + if config.stage2_enabled { + let (collapsed, count); + (collapsed, count) = stage2_collapse(current, config.collapse_threshold); + current = collapsed; + stats.stage2_collapsed = count; + } + + // Stage 3: Cluster + if config.stage3_enabled { + let (clustered, count); + (clustered, count) = stage3_cluster(current, config.cluster_min_size); + current = clustered; + stats.stage3_clustered = count; + } + + stats.final_count = current.len(); + stats.tokens_saved_estimate = (stats.original_count.saturating_sub(stats.final_count)) * 100; + + (current, stats) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compaction_stats_ratio() { + let stats = CompactionStats { + original_count: 100, + final_count: 25, + ..Default::default() + }; + assert!((stats.compression_ratio() - 4.0).abs() < 0.01); + } + + #[test] + fn test_stage1_supersede_removes_obsolete() { + let messages = vec![ + CompactMessage { + id: 0, + role: "user".to_string(), + summary: "read file".to_string(), + tool_calls: vec![], + file_ops: vec![FileOperation { + op_type: FileOpType::Read, + path: "test.rs".to_string(), + index: 0, + }], + }, + CompactMessage { + id: 1, + role: "assistant".to_string(), + summary: "write file".to_string(), + tool_calls: vec![], + file_ops: vec![FileOperation { + op_type: FileOpType::Write, + path: "test.rs".to_string(), + index: 1, + }], + }, + ]; + + let (result, removed) = stage1_supersede(messages); + assert_eq!(removed, 1); // First read should be removed + assert_eq!(result.len(), 1); + } + + #[test] + fn test_default_config() { + let config = CompactionConfig::default(); + assert!(config.stage1_enabled); + assert!(config.stage2_enabled); + assert!(config.stage3_enabled); + assert_eq!(config.collapse_threshold, 4); + assert_eq!(config.cluster_min_size, 3); + } +} diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 19571fbec3..3909247952 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -1062,6 +1062,7 @@ pub fn default_config_home() -> PathBuf { /// Save provider settings to the user-level `~/.claw/settings.json`. /// Creates the file and directory if they don't exist. Sets file permissions /// to `0o600` (owner read/write only) to protect stored API keys. +#[allow(dead_code)] pub fn save_user_provider_settings( kind: &str, api_key: &str, @@ -1114,6 +1115,7 @@ pub fn save_user_provider_settings( } /// Remove the `provider` section from the user-level `~/.claw/settings.json`. +#[allow(dead_code)] pub fn clear_user_provider_settings() -> Result<(), ConfigError> { let config_home = default_config_home(); let settings_path = config_home.join("settings.json"); @@ -1133,6 +1135,7 @@ pub fn clear_user_provider_settings() -> Result<(), ConfigError> { Ok(()) } +#[allow(dead_code)] fn read_settings_root(path: &Path) -> serde_json::Map { match fs::read_to_string(path) { Ok(contents) if !contents.trim().is_empty() => { @@ -1145,6 +1148,7 @@ fn read_settings_root(path: &Path) -> serde_json::Map } } +#[allow(dead_code)] fn write_settings_root( path: &Path, root: &serde_json::Map, diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index e1e0f27d1e..1f54808f88 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -10,6 +10,7 @@ pub mod bash_validation; mod bootstrap; pub mod branch_lock; mod compact; +pub mod compact_pipeline; mod config; pub mod config_validate; mod conversation; @@ -64,6 +65,10 @@ pub use compact::{ compact_session, estimate_session_tokens, format_compact_summary, get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult, }; +pub use compact_pipeline::{ + compact_session as compact_pipeline_session, CompactMessage, + CompactionConfig as PipelineCompactionConfig, CompactionStats, FileOpType, FileOperation, +}; pub use config::{ suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError, ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource, diff --git a/rust/crates/runtime/src/policy_engine.rs b/rust/crates/runtime/src/policy_engine.rs index 34343766b8..aa19f0ec47 100644 --- a/rust/crates/runtime/src/policy_engine.rs +++ b/rust/crates/runtime/src/policy_engine.rs @@ -731,7 +731,7 @@ mod tests { } #[test] - #[allow(clippy::duration_suboptimal_units, clippy::too_many_lines)] + #[allow(clippy::too_many_lines)] fn executable_decision_table_emits_retry_rebase_merge_escalate_cleanup_and_approval_events() { let engine = PolicyEngine::new(vec![ PolicyRule::new( diff --git a/rust/crates/runtime/src/trident.rs b/rust/crates/runtime/src/trident.rs index 2346a4ea29..498d19392f 100644 --- a/rust/crates/runtime/src/trident.rs +++ b/rust/crates/runtime/src/trident.rs @@ -29,7 +29,7 @@ impl Default for TridentConfig { } /// Statistics from a Trident compaction run. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct TridentStats { pub superseded_count: usize, pub collapsed_chains: usize, @@ -41,21 +41,6 @@ pub struct TridentStats { pub final_message_count: usize, } -impl Default for TridentStats { - fn default() -> Self { - Self { - superseded_count: 0, - collapsed_chains: 0, - messages_collapsed: 0, - clusters_found: 0, - messages_clustered: 0, - tokens_saved_estimate: 0, - original_message_count: 0, - final_message_count: 0, - } - } -} - impl TridentStats { pub fn format_report(&self) -> String { let compression = if self.final_message_count > 0 { @@ -192,7 +177,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec = BTreeSet::new(); - for (_path, ops) in &file_ops { + for ops in file_ops.values() { if ops.len() < 2 { continue; } @@ -205,11 +190,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec = Vec::new(); let mut cluster_buffers: BTreeMap> = BTreeMap::new(); @@ -677,7 +658,7 @@ fn truncate_text(text: &str, max_chars: usize) -> String { mod tests { use super::*; use crate::compact::CompactionConfig; - use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; + use crate::session::{ContentBlock, ConversationMessage, Session}; #[test] fn stage1_removes_obsolete_file_reads() { @@ -736,7 +717,7 @@ mod tests { fn stage2_collapses_chatty_messages() { let mut messages = vec![]; for i in 0..6 { - messages.push(ConversationMessage::user_text(&format!("ok {i}"))); + messages.push(ConversationMessage::user_text(format!("ok {i}"))); messages.push(ConversationMessage::assistant(vec![ContentBlock::Text { text: format!("got {i}"), }])); @@ -767,9 +748,9 @@ mod tests { }, ])); messages.push(ConversationMessage::tool_result( - &format!("read_{i}"), + format!("read_{i}"), "read_file", - &format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#), + format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#), false, )); } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 38974eb5f3..3bd2171936 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1932,9 +1932,9 @@ fn parse_args(args: &[String]) -> Result { // Only reject for known top-level subcommands that don't use compact. let first = rest[0].as_str(); if is_known_top_level_subcommand(first) && first != "prompt" { - return Err(format!( - "invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"\" or echo \"\" | claw --compact" - )); + return Err( + "invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"\" or echo \"\" | claw --compact".to_string() + ); } } @@ -3188,9 +3188,9 @@ fn parse_system_prompt_args( })?; // #99: validate --date is a plausible date string (no newlines, reasonable length) if value.contains('\n') || value.contains('\r') { - return Err(format!( - "invalid_flag_value: --date value contains invalid characters.\nUsage: --date " - )); + return Err( + "invalid_flag_value: --date value contains invalid characters.\nUsage: --date ".to_string() + ); } if value.len() > 20 { return Err(format!( @@ -3427,11 +3427,7 @@ impl DiagnosticCheck { fn json_value(&self) -> Value { // Derive a stable snake_case id from the check name for machine-readable keying (#704). - let id = self - .name - .to_ascii_lowercase() - .replace(' ', "_") - .replace('-', "_"); + let id = self.name.to_ascii_lowercase().replace([' ', '-'], "_"); let mut value = Map::from_iter([ ("id".to_string(), Value::String(id.clone())), ( @@ -6700,16 +6696,13 @@ fn run_resume_command( } SlashCommand::Plugins { action, target } => { // Only list is supported in resume mode (no runtime to reload) - match action.as_deref() { - Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) => { - // #777: use interactive_only: prefix + \n hint so #776's classify/split - // emits error_kind:interactive_only + non-null hint instead of unknown+null. - // Orchestrators can now detect this and switch to a live REPL instead of retrying. - return Err(format!( - "interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command." - ).into()); - } - _ => {} + if let Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) = + action.as_deref() + { + // #777: use interactive_only: prefix + \n hint so #776's classify/split + // emits error_kind:interactive_only + non-null hint instead of unknown+null. + // Orchestrators can now detect this and switch to a live REPL instead of retrying. + return Err(format!("interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command.").into()); } let cwd = env::current_dir()?; let payload = plugins_command_payload_for( @@ -7821,8 +7814,7 @@ impl LiveCli { let max_compact_rounds = 4; let preserve_schedule = [4, 2, 1, 0]; - for round in 0..max_compact_rounds { - let preserve = preserve_schedule[round]; + for (round, &preserve) in preserve_schedule.iter().enumerate() { println!( " Auto-compacting session (round {}/{}, preserving {} recent messages)...", round + 1, @@ -8574,8 +8566,8 @@ impl LiveCli { let cwd = env::current_dir()?; // #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes. // Previously the guard was JSON-only (#793); text mode silently returned empty success. - if action.as_deref() == Some("list") { - if let Some(filter) = target.as_deref() { + if action == Some("list") { + if let Some(filter) = target { if filter.starts_with('-') { if matches!(output_format, CliOutputFormat::Json) { // ROADMAP #817: this is a handled local inventory parse error. @@ -9540,6 +9532,7 @@ fn print_status_snapshot( Ok(()) } +#[allow(clippy::too_many_arguments)] fn status_json_value( model: Option<&str>, usage: StatusUsage, @@ -10036,9 +10029,7 @@ fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value { // (#731: "not supported on macOS" is a degraded state, not a hard error; // filesystem_active:true means partial containment is working) // error = enabled but unsupported AND no filesystem sandbox either (nothing active) - let top_status = if !status.enabled { - "ok" - } else if status.active { + let top_status = if !status.enabled || status.active { "ok" } else if status.supported { "warn" @@ -10404,10 +10395,7 @@ fn render_doctor_help_json() -> serde_json::Value { }) } -/// #683-#692: extract structured metadata from help prose -fn extract_help_metadata( - topic: LocalHelpTopic, -) -> ( +type HelpMetadata = ( Option, // usage Option, // purpose Option, // output description @@ -10416,7 +10404,10 @@ fn extract_help_metadata( Option>, // aliases bool, // local_only bool, // requires_credentials -) { +); + +/// #683-#692: extract structured metadata from help prose +fn extract_help_metadata(topic: LocalHelpTopic) -> HelpMetadata { let text = render_help_topic(topic); let mut usage = None; let mut purpose = None; @@ -13946,6 +13937,7 @@ fn permission_policy( } fn convert_messages(messages: &[ConversationMessage]) -> Vec { + #[allow(clippy::unnecessary_filter_map)] messages .iter() .filter_map(|message| { @@ -16920,7 +16912,7 @@ mod tests { for action in ["remove", "uninstall", "delete"] { assert_eq!( parse_args(&["skills".to_string(), action.to_string()]) - .expect(&format!("skills {action} should parse")), + .unwrap_or_else(|_| panic!("skills {action} should parse")), CliAction::Skills { args: Some(action.to_string()), output_format: CliOutputFormat::Text, diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index c9ba752b03..3a446a7235 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -1,3 +1,5 @@ +#![allow(unused_variables)] + use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -1007,13 +1009,13 @@ fn inventory_commands_emit_structured_json_when_requested() { assert!( !plugins .as_object() - .map_or(false, |o| o.contains_key("reload_runtime")), + .is_some_and(|o| o.contains_key("reload_runtime")), "plugins list should not include reload_runtime" ); assert!( !plugins .as_object() - .map_or(false, |o| o.contains_key("target")), + .is_some_and(|o| o.contains_key("target")), "plugins list should not include target" ); // #703: structured summary replaces prose message @@ -1706,13 +1708,13 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() { assert!( !plugins .as_object() - .map_or(false, |o| o.contains_key("reload_runtime")), + .is_some_and(|o| o.contains_key("reload_runtime")), "plugins list should not include reload_runtime" ); assert!( !plugins .as_object() - .map_or(false, |o| o.contains_key("target")), + .is_some_and(|o| o.contains_key("target")), "plugins list should not include target" ); assert!( @@ -2945,7 +2947,7 @@ fn prompt_empty_arg_json_stdout_missing_prompt_823() { "claw prompt empty arg must retain abort action (#823); got: {parsed}" ); assert!( - parsed["hint"].as_str().map_or(false, |h| !h.is_empty()), + parsed["hint"].as_str().is_some_and(|h| !h.is_empty()), "claw prompt empty arg missing_prompt hint must be non-empty (#823)" ); } @@ -2983,9 +2985,9 @@ fn flag_value_errors_have_error_kind_and_hint_756() { "invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}" ); assert!( - parsed["hint"].as_str().map_or(false, |h| h.contains("low") - || h.contains("medium") - || h.contains("high")), + parsed["hint"] + .as_str() + .is_some_and(|h| h.contains("low") || h.contains("medium") || h.contains("high")), "hint must mention valid values (#756): {parsed}" ); @@ -3011,7 +3013,7 @@ fn flag_value_errors_have_error_kind_and_hint_756() { "missing --model value must be missing_flag_value (#756): {parsed2}" ); assert!( - parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()), + parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()), "missing --model hint must be non-empty (#756): {parsed2}" ); } @@ -3255,7 +3257,7 @@ fn short_p_flag_swallows_no_flags_755() { "flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}" ); assert!( - parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()), + parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()), "missing_prompt hint must be non-empty (#755)" ); } @@ -3397,7 +3399,7 @@ fn config_unsupported_section_json_hint_741() { assert!( parsed["supported_sections"] .as_array() - .map_or(false, |a| !a.is_empty()), + .is_some_and(|a| !a.is_empty()), "config {section} JSON must include supported_sections (#741)" ); } @@ -5366,8 +5368,10 @@ fn agents_create_scaffolds_toml_and_lists_locally_431() { .iter() .any(|agent| { agent["name"] == "my-agent" - && PathBuf::from(agent["path"].as_str().expect("listed agent path")) - == fs::canonicalize(&agent_path).expect("canonical listed agent path") + && fs::canonicalize(&agent_path) + .map(|p| p.to_string_lossy().to_string()) + .expect("canonical listed agent path") + == agent["path"].as_str().expect("listed agent path") })); }