From a412b4d9b76475f6a0f682c53e969bc0bca97a29 Mon Sep 17 00:00:00 2001 From: Philip Date: Sun, 31 May 2026 10:54:22 +0200 Subject: [PATCH 1/7] chore: update .gitignore to include AI-related directories --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 93b3eaf..187aed5 100644 --- a/.gitignore +++ b/.gitignore @@ -50,8 +50,10 @@ coverage/ .codex/hooks.json # AI +.ai/ .claude/ .codex/ .cursor/ CLAUDE.md AGENTS.md +agentmesh.lock \ No newline at end of file From f989aa62e28b0db4f2d0a01c989083f427d55e6e Mon Sep 17 00:00:00 2001 From: Philip Date: Sun, 31 May 2026 10:54:36 +0200 Subject: [PATCH 2/7] feat: add lockfile privacy warnings to DoctorHealth and enhance privacy checks in the pipeline --- crates/agentmesh-core/src/lib.rs | 2 + crates/agentmesh-core/src/pipeline.rs | 285 +++++++++++++++++++++++++- crates/agentmesh/src/main.rs | 2 + 3 files changed, 287 insertions(+), 2 deletions(-) diff --git a/crates/agentmesh-core/src/lib.rs b/crates/agentmesh-core/src/lib.rs index 7cbe511..59a6ac4 100644 --- a/crates/agentmesh-core/src/lib.rs +++ b/crates/agentmesh-core/src/lib.rs @@ -120,6 +120,8 @@ pub struct DoctorHealth { pub failed_pending_syncs: usize, /// Number of entities skipped because no runtime can represent them. pub capability_skips: usize, + /// Number of sensitive-looking lockfile metadata entries. + pub lockfile_privacy_warnings: usize, } /// Options for restoring a preserved entity version. diff --git a/crates/agentmesh-core/src/pipeline.rs b/crates/agentmesh-core/src/pipeline.rs index cd1e54c..c063c55 100644 --- a/crates/agentmesh-core/src/pipeline.rs +++ b/crates/agentmesh-core/src/pipeline.rs @@ -19,6 +19,7 @@ use agentmesh_protocol::{ }; use serde::Serialize; use serde::de::DeserializeOwned; +use serde_json::Value; use thiserror::Error; use crate::config::{ @@ -51,6 +52,8 @@ use crate::{ /// Pipeline result type. pub type Result = std::result::Result; +const DOCTOR_PRIVACY_WARNING_DETAIL_LIMIT: usize = 20; + /// Runtime adapter operations required by the sync pipeline. pub trait AdapterRegistry { /// Detects whether a runtime is present in a repository. @@ -443,6 +446,7 @@ pub fn doctor_with_adapter_registry( let config = load_config(repo_root)?.config; let capability_skipped = capability_skip_count_for_lockfile(&lockfile, &config)?; let sync_state = entity_sync_state(repo_root, &lockfile)?; + let privacy_findings = doctor_lockfile_privacy_findings(&lockfile); let mut findings = Vec::new(); findings.push(format!("entities: {}", lockfile.entities.len())); @@ -458,6 +462,7 @@ pub fn doctor_with_adapter_registry( findings.extend(doctor_adapter_findings(repo_root, &lockfile, adapters)?); findings.extend(doctor_hook_findings(repo_root, &cache)?); findings.extend(doctor_conflict_findings(&cache, &lockfile)?); + findings.extend(privacy_findings.findings); findings.push(format!("watcher_pid: {}", cache.watcher_pid.display())); findings.push(format!("watcher_log: {}", cache.watcher_log.display())); findings.push("network: disabled".to_string()); @@ -470,10 +475,228 @@ pub fn doctor_with_adapter_registry( pending_syncs: pending_count, failed_pending_syncs: failed_pending_count, capability_skips: capability_skipped, + lockfile_privacy_warnings: privacy_findings.warning_count, }, }) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct LockfilePrivacyFindings { + warning_count: usize, + findings: Vec, +} + +fn doctor_lockfile_privacy_findings(lockfile: &Lockfile) -> LockfilePrivacyFindings { + let mut warnings = Vec::new(); + let mut warning_count = 0; + + for (entity_id, entity) in &lockfile.entities { + if contains_sensitive_term(entity_id.as_str()) { + push_privacy_warning( + &mut warnings, + &mut warning_count, + format!( + "entity id `{}` contains sensitive-looking text", + entity_id.as_str() + ), + ); + } + for (location, path) in &entity.locations { + if path_contains_sensitive_term(path) { + push_privacy_warning( + &mut warnings, + &mut warning_count, + format!( + "location path for `{}` at `{}` contains sensitive-looking text: {}", + entity_id.as_str(), + location.as_str(), + path.display() + ), + ); + } + } + for entry in &entity.lineage { + if path_contains_sensitive_term(&entry.imported_from) { + push_privacy_warning( + &mut warnings, + &mut warning_count, + format!( + "lineage path for `{}` contains sensitive-looking text: {}", + entity_id.as_str(), + entry.imported_from.display() + ), + ); + } + } + for record in &entity.rename_history { + if path_contains_sensitive_term(&record.from) { + push_privacy_warning( + &mut warnings, + &mut warning_count, + format!( + "rename source for `{}` contains sensitive-looking text: {}", + entity_id.as_str(), + record.from.display() + ), + ); + } + if path_contains_sensitive_term(&record.to) { + push_privacy_warning( + &mut warnings, + &mut warning_count, + format!( + "rename target for `{}` contains sensitive-looking text: {}", + entity_id.as_str(), + record.to.display() + ), + ); + } + } + } + + for (entity_id, overrides) in &lockfile.overrides { + for (runtime, override_entry) in overrides { + collect_sensitive_override_keys( + entity_id, + runtime, + &override_entry.0, + &mut warnings, + &mut warning_count, + ); + } + } + + let mut findings = Vec::new(); + if warning_count > 0 { + findings.push(format!("lockfile_privacy_warnings: {warning_count}")); + } + findings.extend(warnings); + if warning_count > DOCTOR_PRIVACY_WARNING_DETAIL_LIMIT { + findings.push(format!( + "lockfile_privacy_warnings_truncated: {} additional warning(s)", + warning_count - DOCTOR_PRIVACY_WARNING_DETAIL_LIMIT + )); + } + + LockfilePrivacyFindings { + warning_count, + findings, + } +} + +fn push_privacy_warning(warnings: &mut Vec, warning_count: &mut usize, detail: String) { + *warning_count += 1; + if warnings.len() < DOCTOR_PRIVACY_WARNING_DETAIL_LIMIT { + warnings.push(format!( + "lockfile_privacy_warning_{warning_count}: {detail}" + )); + } +} + +fn collect_sensitive_override_keys( + entity_id: &EntityId, + runtime: &RuntimeName, + values: &BTreeMap, + warnings: &mut Vec, + warning_count: &mut usize, +) { + for (key, value) in values { + collect_sensitive_json_keys( + entity_id, + runtime, + Some(key), + value, + warnings, + warning_count, + ); + } +} + +fn collect_sensitive_json_keys( + entity_id: &EntityId, + runtime: &RuntimeName, + key: Option<&str>, + value: &Value, + warnings: &mut Vec, + warning_count: &mut usize, +) { + if let Some(key) = key + && contains_sensitive_term(key) + { + push_privacy_warning( + warnings, + warning_count, + format!( + "override key `{key}` for `{}` at `{}` looks sensitive; keep secrets in machine-local config or environment variables", + entity_id.as_str(), + runtime.as_str() + ), + ); + } + + match value { + Value::Object(map) => { + for (child_key, child_value) in map { + collect_sensitive_json_keys( + entity_id, + runtime, + Some(child_key), + child_value, + warnings, + warning_count, + ); + } + } + Value::Array(values) => { + for child_value in values { + collect_sensitive_json_keys( + entity_id, + runtime, + None, + child_value, + warnings, + warning_count, + ); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } +} + +fn path_contains_sensitive_term(path: &Path) -> bool { + path.components() + .any(|component| contains_sensitive_term(&component.as_os_str().to_string_lossy())) +} + +fn contains_sensitive_term(value: &str) -> bool { + let normalized = value.to_ascii_lowercase(); + [ + "access-key", + "access_key", + "apikey", + "api-key", + "api_key", + "auth-token", + "auth_token", + "bearer", + "client-secret", + "client_secret", + "cookie", + "credential", + "jwt", + "oauth", + "passwd", + "password", + "private-key", + "private_key", + "secret", + "session", + "token", + ] + .iter() + .any(|term| normalized.contains(term)) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] struct EntitySyncState { in_sync: usize, @@ -3491,8 +3714,8 @@ mod tests { use super::{PlanOptions, capability_skip_count_for_lockfile}; use crate::lockfile::{ - AdapterDeclaration, AdapterMode, HookKind, Lockfile, LockfileEntity, read_lockfile, - write_lockfile, + AdapterDeclaration, AdapterMode, HookKind, Lockfile, LockfileEntity, OverrideEntry, + read_lockfile, write_lockfile, }; use crate::merge::preserve_losing_version; use crate::pending_queue::PendingQueue; @@ -4613,6 +4836,64 @@ schema: 1 assert_eq!(report.health.pending_syncs, 0); assert_eq!(report.health.failed_pending_syncs, 0); assert_eq!(report.health.capability_skips, 0); + assert_eq!(report.health.lockfile_privacy_warnings, 0); + assert!( + report + .findings + .iter() + .all(|finding| !finding.starts_with("lockfile_privacy")) + ); + } + + #[test] + fn doctor_warns_about_sensitive_lockfile_metadata() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(&repo) { + panic!("repo should be created: {error}"); + } + let mut lockfile = Lockfile::empty(); + let mut entity = entity_entry( + EntityType::Subagent, + hash("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + ); + entity.locations.insert( + location_key(".ai"), + std::path::PathBuf::from("subagents/service-token.md"), + ); + let entity_id = entity_id("subagent:service-token"); + lockfile.entities.insert(entity_id.clone(), entity); + lockfile.overrides.insert( + entity_id, + std::collections::BTreeMap::from([( + runtime_name("codex"), + OverrideEntry(std::collections::BTreeMap::from([( + "api_token".to_string(), + serde_json::json!("redacted"), + )])), + )]), + ); + if let Err(error) = write_lockfile(&repo, &lockfile) { + panic!("lockfile should write: {error}"); + } + + let report = match doctor(&repo) { + Ok(report) => report, + Err(error) => panic!("doctor should succeed: {error}"), + }; + + assert!(report.health.lockfile_privacy_warnings >= 2); + assert!(report.findings.iter().any(|finding| { + finding.starts_with("lockfile_privacy_warning_") + && finding.contains("entity id `subagent:service-token`") + })); + assert!(report.findings.iter().any(|finding| { + finding.starts_with("lockfile_privacy_warning_") + && finding.contains("override key `api_token`") + })); } #[test] diff --git a/crates/agentmesh/src/main.rs b/crates/agentmesh/src/main.rs index 3d58704..52fbf09 100644 --- a/crates/agentmesh/src/main.rs +++ b/crates/agentmesh/src/main.rs @@ -1921,6 +1921,7 @@ fn snapshot_exit_code(snapshot: &RepoSnapshot) -> AgentmeshExitCode { || health.capability_skips > 0 || health.pending_conflicts > 0 || health.pending_syncs > 0 + || health.lockfile_privacy_warnings > 0 }) { AgentmeshExitCode::Drift @@ -2274,6 +2275,7 @@ fn core_health_json(health: Option<&agentmesh_core::DoctorHealth>) -> serde_json "pending_syncs": health.pending_syncs, "failed_pending_syncs": health.failed_pending_syncs, "capability_skips": health.capability_skips, + "lockfile_privacy_warnings": health.lockfile_privacy_warnings, }), None => serde_json::Value::Null, } From 99596153868a092a37a7c8b5b3cdf6b2e61027db Mon Sep 17 00:00:00 2001 From: Philip Date: Sun, 31 May 2026 12:07:23 +0200 Subject: [PATCH 3/7] chore: update dependencies and versioning across the project to 0.1.2, enhance file handling in adapters, and improve privacy checks in the pipeline --- Cargo.lock | 791 +----------------- Cargo.toml | 14 +- adapters/claude/src/lib.rs | 119 +-- adapters/codex/src/lib.rs | 116 +-- crates/agentmesh-adapter-sdk-rust/src/lib.rs | 241 +++++- crates/agentmesh-core/Cargo.toml | 2 +- crates/agentmesh-core/src/pipeline.rs | 158 +++- crates/agentmesh-protocol/src/lib.rs | 39 +- crates/agentmesh/src/main.rs | 66 +- crates/agentmesh/tests/cli_flows.rs | 42 +- deny.toml | 5 - fuzz/Cargo.lock | 820 +------------------ fuzz/Cargo.toml | 6 +- 13 files changed, 682 insertions(+), 1737 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 04d6b06..4a91b5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,7 +19,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agentmesh" -version = "0.1.1" +version = "0.1.2" dependencies = [ "agentmesh-adapter-claude", "agentmesh-adapter-codex", @@ -37,7 +37,7 @@ dependencies = [ [[package]] name = "agentmesh-adapter-claude" -version = "0.1.1" +version = "0.1.2" dependencies = [ "agentmesh-adapter-sdk-rust", "agentmesh-protocol", @@ -49,7 +49,7 @@ dependencies = [ [[package]] name = "agentmesh-adapter-codex" -version = "0.1.1" +version = "0.1.2" dependencies = [ "agentmesh-adapter-sdk-rust", "agentmesh-protocol", @@ -62,7 +62,7 @@ dependencies = [ [[package]] name = "agentmesh-adapter-sdk-rust" -version = "0.1.1" +version = "0.1.2" dependencies = [ "agentmesh-protocol", "serde", @@ -75,7 +75,7 @@ dependencies = [ [[package]] name = "agentmesh-core" -version = "0.1.1" +version = "0.1.2" dependencies = [ "agentmesh-adapter-claude", "agentmesh-adapter-codex", @@ -100,7 +100,7 @@ dependencies = [ [[package]] name = "agentmesh-protocol" -version = "0.1.1" +version = "0.1.2" dependencies = [ "base64", "serde", @@ -110,7 +110,7 @@ dependencies = [ [[package]] name = "agentmesh-watcher" -version = "0.1.1" +version = "0.1.2" dependencies = [ "agentmesh-core", "blake3", @@ -226,40 +226,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "aws-lc-rs" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "backtrace" version = "0.3.76" @@ -362,12 +334,6 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - [[package]] name = "cast" version = "0.3.0" @@ -381,8 +347,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -459,31 +423,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - [[package]] name = "console" version = "0.16.3" @@ -507,22 +452,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.3.0" @@ -633,12 +562,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "either" version = "1.16.0" @@ -728,15 +651,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - [[package]] name = "fraction" version = "0.15.4" @@ -757,12 +671,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "fsevent-sys" version = "4.1.0" @@ -772,34 +680,12 @@ dependencies = [ "libc", ] -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - [[package]] name = "futures-task" version = "0.3.32" @@ -813,25 +699,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", - "futures-io", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -865,25 +737,6 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "half" version = "2.7.1" @@ -927,45 +780,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - [[package]] name = "hybrid-array" version = "0.4.12" @@ -975,65 +789,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - [[package]] name = "icu_collections" version = "2.1.1" @@ -1186,12 +941,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - [[package]] name = "is_ci" version = "1.2.0" @@ -1219,65 +968,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.99" @@ -1311,8 +1001,6 @@ dependencies = [ "referencing", "regex", "regex-syntax", - "reqwest", - "rustls", "serde", "serde_json", "unicode-general-category", @@ -1580,12 +1268,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "outref" version = "0.5.2" @@ -1877,74 +1559,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" -[[package]] -name = "reqwest" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "rustc-demangle" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "1.1.4" @@ -1958,80 +1578,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.23.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" -dependencies = [ - "aws-lc-rs", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -2065,44 +1611,12 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -2191,22 +1705,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "similar" version = "2.7.0" @@ -2234,16 +1732,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2256,12 +1744,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "supports-color" version = "3.0.2" @@ -2294,15 +1776,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - [[package]] name = "synstructure" version = "0.13.2" @@ -2387,43 +1860,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" version = "0.8.23" @@ -2465,76 +1901,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typenum" version = "1.20.0" @@ -2599,24 +1965,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2670,15 +2018,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2716,16 +2055,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.72" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.122" @@ -2812,15 +2141,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi" version = "0.3.9" @@ -2858,22 +2178,13 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2885,22 +2196,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - [[package]] name = "windows-targets" version = "0.53.5" @@ -2908,106 +2203,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -3187,12 +2434,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index e82fcc6..8e31375 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.1.1" +version = "0.1.2" edition = "2024" rust-version = "1.85" license = "MIT" @@ -20,12 +20,12 @@ repository = "https://github.com/aranticlabs/agentmesh" homepage = "https://agentmesh.sh" [workspace.dependencies] -agentmesh-adapter-claude = { path = "adapters/claude", version = "0.1.1" } -agentmesh-adapter-codex = { path = "adapters/codex", version = "0.1.1" } -agentmesh-adapter-sdk-rust = { path = "crates/agentmesh-adapter-sdk-rust", version = "0.1.1" } -agentmesh-core = { path = "crates/agentmesh-core", version = "0.1.1" } -agentmesh-protocol = { path = "crates/agentmesh-protocol", version = "0.1.1" } -agentmesh-watcher = { path = "crates/agentmesh-watcher", version = "0.1.1" } +agentmesh-adapter-claude = { path = "adapters/claude", version = "0.1.2" } +agentmesh-adapter-codex = { path = "adapters/codex", version = "0.1.2" } +agentmesh-adapter-sdk-rust = { path = "crates/agentmesh-adapter-sdk-rust", version = "0.1.2" } +agentmesh-core = { path = "crates/agentmesh-core", version = "0.1.2" } +agentmesh-protocol = { path = "crates/agentmesh-protocol", version = "0.1.2" } +agentmesh-watcher = { path = "crates/agentmesh-watcher", version = "0.1.2" } base64 = "0.22.1" clap = { version = "4", features = ["derive"] } miette = { version = "7", features = ["fancy"] } diff --git a/adapters/claude/src/lib.rs b/adapters/claude/src/lib.rs index 4c30dcc..16c0a8f 100644 --- a/adapters/claude/src/lib.rs +++ b/adapters/claude/src/lib.rs @@ -1,12 +1,12 @@ //! Bundled Claude adapter entry points. use std::collections::BTreeMap; -use std::fs; use std::path::{Path, PathBuf}; use agentmesh_adapter_sdk_rust::{ - Adapter, AdapterError, AdapterMetadata, FormatTranslation, compose_frontmatter, - ensure_hook_array, find_hook_array_mut, find_hook_group, hash_files, is_safe_relative, + Adapter, AdapterError, AdapterMetadata, FormatTranslation, collect_entity_files, + compose_frontmatter, dir_entry_file_type, ensure_hook_array, find_hook_array_mut, + find_hook_group, hash_files, is_regular_dir, is_regular_file, is_safe_relative, max_mtime_string, mtime_string, parse_frontmatter, read_dir_sorted, read_json_object, read_to_string, remove_matching_entries, remove_recorded_entries, selected, sha256_bytes, skipped_entity, slug_for_entity, slugify, workspace_relative, workspace_root_for, write_atomic, @@ -71,7 +71,9 @@ impl Adapter for ClaudeAdapter { let mut skipped = Vec::new(); let instructions_path = workspace_root.join("CLAUDE.md"); - if selected(filter, &[PathBuf::from("CLAUDE.md")]) && instructions_path.is_file() { + if selected(filter, &[PathBuf::from("CLAUDE.md")]) + && is_regular_file(&workspace_root, &instructions_path)? + { entities.push(import_markdown_entity( &workspace_root, &instructions_path, @@ -321,13 +323,29 @@ fn import_skills( entities: &mut Vec, skipped: &mut Vec, ) -> agentmesh_adapter_sdk_rust::Result<()> { - if !skills_root.is_dir() { - return Ok(()); + match is_regular_dir(workspace_root, skills_root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, skills_root), + reason: error.to_string(), + }); + return Ok(()); + } } for entry in read_dir_sorted(skills_root)? { let path = entry.path(); - if !path.is_dir() { + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked skill path is not supported".to_string(), + }); + continue; + } + if !file_type.is_dir() { continue; } let Some(name) = path.file_name().and_then(|name| name.to_str()) else { @@ -348,13 +366,32 @@ fn import_skills( let source_path = path.join("SKILL.md"); let source_relative = workspace_relative(workspace_root, &source_path)?; let skill_relative = workspace_relative(workspace_root, &path)?; - if !selected(filter, &[source_relative.clone(), skill_relative]) || !source_path.is_file() { + if !selected(filter, &[source_relative.clone(), skill_relative]) { + continue; + } + let source_is_file = match is_regular_file(workspace_root, &source_path) { + Ok(source_is_file) => source_is_file, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + if !source_is_file { continue; } let slug = slugify(name); let mut files = BTreeMap::new(); - collect_entity_files(&path, &path, &mut files)?; + if let Err(error) = collect_entity_files(&path, &path, &mut files) { + skipped.push(SkippedPath { + path: workspace_relative(workspace_root, &path)?, + reason: error.to_string(), + }); + continue; + } let content = read_to_string(&source_path)?; let frontmatter = match frontmatter_json_for_path(&source_relative, &content) { Ok(frontmatter) => frontmatter, @@ -383,6 +420,10 @@ fn import_skills( Ok(()) } +fn relative_or_path(workspace_root: &Path, path: &Path) -> PathBuf { + workspace_relative(workspace_root, path).unwrap_or_else(|_| path.to_path_buf()) +} + fn import_subagents( workspace_root: &Path, agents_root: &Path, @@ -390,13 +431,31 @@ fn import_subagents( entities: &mut Vec, skipped: &mut Vec, ) -> agentmesh_adapter_sdk_rust::Result<()> { - if !agents_root.is_dir() { - return Ok(()); + match is_regular_dir(workspace_root, agents_root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, agents_root), + reason: error.to_string(), + }); + return Ok(()); + } } for entry in read_dir_sorted(agents_root)? { let path = entry.path(); - if path.extension().and_then(|extension| extension.to_str()) != Some("md") { + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked subagent path is not supported".to_string(), + }); + continue; + } + if !file_type.is_file() + || path.extension().and_then(|extension| extension.to_str()) != Some("md") + { continue; } let source_relative = workspace_relative(workspace_root, &path)?; @@ -471,32 +530,6 @@ fn import_markdown_entity( }) } -fn collect_entity_files( - root: &Path, - dir: &Path, - files: &mut BTreeMap, -) -> agentmesh_adapter_sdk_rust::Result<()> { - for entry in read_dir_sorted(dir)? { - let path = entry.path(); - if path.is_dir() { - collect_entity_files(root, &path, files)?; - continue; - } - if !path.is_file() { - continue; - } - let relative = path.strip_prefix(root).map_err(|_| { - AdapterError::rpc( - AdapterErrorCode::WorkspaceOutsideBound, - format!("{} is outside {}", path.display(), root.display()), - ) - })?; - files.insert(relative.to_path_buf(), read_entity_file(&path)?); - } - - Ok(()) -} - fn first_file_content(files: &BTreeMap) -> Option { for key in [ Path::new("SKILL.md"), @@ -594,16 +627,6 @@ fn skill_runtime_file(path: &Path, slug: &str) -> Option { Some(path.to_path_buf()) } -fn read_entity_file(path: &Path) -> agentmesh_adapter_sdk_rust::Result { - fs::read(path) - .map(EntityFile::from_bytes) - .map_err(|source| AdapterError::Io { - action: "read file", - path: path.to_path_buf(), - source, - }) -} - fn file_text(file: &EntityFile) -> Option { match file.encoding { EntityFileEncoding::Utf8 => Some(file.content.clone()), diff --git a/adapters/codex/src/lib.rs b/adapters/codex/src/lib.rs index 9137fc9..ee2d606 100644 --- a/adapters/codex/src/lib.rs +++ b/adapters/codex/src/lib.rs @@ -6,7 +6,8 @@ use std::path::{Path, PathBuf}; use agentmesh_adapter_sdk_rust::{ Adapter, AdapterError, AdapterMetadata, FormatTranslation, FrontmatterDocument, - compose_frontmatter, ensure_hook_array, find_hook_array_mut, find_hook_group, hash_files, + collect_entity_files, compose_frontmatter, dir_entry_file_type, ensure_hook_array, + find_hook_array_mut, find_hook_group, hash_files, is_regular_dir, is_regular_file, is_safe_relative, max_mtime_string, mtime_string, parse_frontmatter, read_dir_sorted, read_json_object, read_to_string, remove_matching_entries, remove_recorded_entries, selected, sha256_bytes, skipped_entity, slug_for_entity, slugify, workspace_relative, workspace_root_for, @@ -71,7 +72,9 @@ impl Adapter for CodexAdapter { let mut skipped = Vec::new(); let instructions_path = workspace_root.join("AGENTS.md"); - if selected(filter, &[PathBuf::from("AGENTS.md")]) && instructions_path.is_file() { + if selected(filter, &[PathBuf::from("AGENTS.md")]) + && is_regular_file(&workspace_root, &instructions_path)? + { entities.push(import_markdown_entity( &instructions_path, EntityType::Instructions, @@ -331,13 +334,29 @@ fn import_skills( entities: &mut Vec, skipped: &mut Vec, ) -> agentmesh_adapter_sdk_rust::Result<()> { - if !skills_root.is_dir() { - return Ok(()); + match is_regular_dir(workspace_root, skills_root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, skills_root), + reason: error.to_string(), + }); + return Ok(()); + } } for entry in read_dir_sorted(skills_root)? { let path = entry.path(); - if !path.is_dir() { + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked skill path is not supported".to_string(), + }); + continue; + } + if !file_type.is_dir() { continue; } let Some(name) = path.file_name().and_then(|name| name.to_str()) else { @@ -358,13 +377,32 @@ fn import_skills( let source_path = path.join("SKILL.md"); let source_relative = workspace_relative(workspace_root, &source_path)?; let skill_relative = workspace_relative(workspace_root, &path)?; - if !selected(filter, &[source_relative.clone(), skill_relative]) || !source_path.is_file() { + if !selected(filter, &[source_relative.clone(), skill_relative]) { + continue; + } + let source_is_file = match is_regular_file(workspace_root, &source_path) { + Ok(source_is_file) => source_is_file, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + if !source_is_file { continue; } let slug = slugify(name); let mut files = BTreeMap::new(); - collect_entity_files(&path, &path, &mut files)?; + if let Err(error) = collect_entity_files(&path, &path, &mut files) { + skipped.push(SkippedPath { + path: workspace_relative(workspace_root, &path)?, + reason: error.to_string(), + }); + continue; + } let content = read_to_string(&source_path)?; let frontmatter = frontmatter_json(&content)?; @@ -384,6 +422,10 @@ fn import_skills( Ok(()) } +fn relative_or_path(workspace_root: &Path, path: &Path) -> PathBuf { + workspace_relative(workspace_root, path).unwrap_or_else(|_| path.to_path_buf()) +} + fn import_subagents( workspace_root: &Path, agents_root: &Path, @@ -391,13 +433,31 @@ fn import_subagents( entities: &mut Vec, skipped: &mut Vec, ) -> agentmesh_adapter_sdk_rust::Result<()> { - if !agents_root.is_dir() { - return Ok(()); + match is_regular_dir(workspace_root, agents_root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, agents_root), + reason: error.to_string(), + }); + return Ok(()); + } } for entry in read_dir_sorted(agents_root)? { let path = entry.path(); - if path.extension().and_then(|extension| extension.to_str()) != Some("toml") { + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked subagent path is not supported".to_string(), + }); + continue; + } + if !file_type.is_file() + || path.extension().and_then(|extension| extension.to_str()) != Some("toml") + { continue; } let source_relative = workspace_relative(workspace_root, &path)?; @@ -511,32 +571,6 @@ fn import_toml_subagent( }) } -fn collect_entity_files( - root: &Path, - dir: &Path, - files: &mut BTreeMap, -) -> agentmesh_adapter_sdk_rust::Result<()> { - for entry in read_dir_sorted(dir)? { - let path = entry.path(); - if path.is_dir() { - collect_entity_files(root, &path, files)?; - continue; - } - if !path.is_file() { - continue; - } - let relative = path.strip_prefix(root).map_err(|_| { - AdapterError::rpc( - AdapterErrorCode::WorkspaceOutsideBound, - format!("{} is outside {}", path.display(), root.display()), - ) - })?; - files.insert(relative.to_path_buf(), read_entity_file(&path)?); - } - - Ok(()) -} - fn first_file_content(files: &BTreeMap) -> Option { for key in [Path::new("SKILL.md"), Path::new("AGENTS.md")] { if let Some(content) = files.get(key).and_then(file_text) { @@ -803,16 +837,6 @@ fn skill_runtime_file(path: &Path, slug: &str) -> Option { Some(path.to_path_buf()) } -fn read_entity_file(path: &Path) -> agentmesh_adapter_sdk_rust::Result { - fs::read(path) - .map(EntityFile::from_bytes) - .map_err(|source| AdapterError::Io { - action: "read file", - path: path.to_path_buf(), - source, - }) -} - fn file_text(file: &EntityFile) -> Option { match file.encoding { EntityFileEncoding::Utf8 => Some(file.content.clone()), diff --git a/crates/agentmesh-adapter-sdk-rust/src/lib.rs b/crates/agentmesh-adapter-sdk-rust/src/lib.rs index b4069eb..9988725 100644 --- a/crates/agentmesh-adapter-sdk-rust/src/lib.rs +++ b/crates/agentmesh-adapter-sdk-rust/src/lib.rs @@ -23,6 +23,9 @@ use tempfile::NamedTempFile; use thiserror::Error; const COMMON_FRONTMATTER_KEYS: &[&str] = &["name", "description", "allowed-tools", "model"]; +const MAX_ENTITY_TREE_DEPTH: usize = 32; +const MAX_ENTITY_FILE_COUNT: usize = 1024; +const MAX_ENTITY_TOTAL_BYTES: u64 = 64 * 1024 * 1024; /// Static format-translation metadata for one entity type. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -831,6 +834,171 @@ pub fn read_dir_sorted(path: &Path) -> Result> { Ok(entries) } +/// Returns a directory entry's type without following symlinks. +pub fn dir_entry_file_type(entry: &fs::DirEntry) -> Result { + entry.file_type().map_err(|source| AdapterError::Io { + action: "read file type", + path: entry.path(), + source, + }) +} + +/// Returns true when an existing path is a regular file inside the workspace. +pub fn is_regular_file(workspace_root: &Path, path: &Path) -> Result { + Ok(safe_metadata(workspace_root, path)?.is_some_and(|metadata| metadata.is_file())) +} + +/// Returns true when an existing path is a directory inside the workspace. +pub fn is_regular_dir(workspace_root: &Path, path: &Path) -> Result { + Ok(safe_metadata(workspace_root, path)?.is_some_and(|metadata| metadata.is_dir())) +} + +/// Collects entity files from a directory tree while rejecting symlink traversal. +pub fn collect_entity_files( + root: &Path, + dir: &Path, + files: &mut BTreeMap, +) -> Result<()> { + let mut total_bytes = 0; + collect_entity_files_inner(root, dir, files, 0, &mut total_bytes) +} + +fn collect_entity_files_inner( + root: &Path, + dir: &Path, + files: &mut BTreeMap, + depth: usize, + total_bytes: &mut u64, +) -> Result<()> { + if depth > MAX_ENTITY_TREE_DEPTH { + return Err(entity_limit_error( + dir, + format!("entity directory depth exceeds {MAX_ENTITY_TREE_DEPTH}"), + )); + } + + for entry in read_dir_sorted(dir)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + return Err(symlink_error(&path)); + } + if file_type.is_dir() { + collect_entity_files_inner(root, &path, files, depth + 1, total_bytes)?; + continue; + } + if !file_type.is_file() { + continue; + } + if files.len() >= MAX_ENTITY_FILE_COUNT { + return Err(entity_limit_error( + &path, + format!("entity file count exceeds {MAX_ENTITY_FILE_COUNT}"), + )); + } + + let metadata = fs::symlink_metadata(&path).map_err(|source| AdapterError::Io { + action: "read metadata", + path: path.clone(), + source, + })?; + let projected_bytes = total_bytes + .checked_add(metadata.len()) + .ok_or_else(|| entity_limit_error(&path, "entity byte count overflowed"))?; + if projected_bytes > MAX_ENTITY_TOTAL_BYTES { + return Err(entity_limit_error( + &path, + format!("entity byte size exceeds {MAX_ENTITY_TOTAL_BYTES}"), + )); + } + + let relative = path.strip_prefix(root).map_err(|_| { + AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("{} is outside {}", path.display(), root.display()), + ) + })?; + let bytes = fs::read(&path).map_err(|source| AdapterError::Io { + action: "read file", + path: path.clone(), + source, + })?; + *total_bytes = total_bytes + .checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)) + .ok_or_else(|| entity_limit_error(&path, "entity byte count overflowed"))?; + if *total_bytes > MAX_ENTITY_TOTAL_BYTES { + return Err(entity_limit_error( + &path, + format!("entity byte size exceeds {MAX_ENTITY_TOTAL_BYTES}"), + )); + } + files.insert(relative.to_path_buf(), EntityFile::from_bytes(bytes)); + } + + Ok(()) +} + +fn safe_metadata(workspace_root: &Path, path: &Path) -> Result> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(AdapterError::Io { + action: "read metadata", + path: path.to_path_buf(), + source, + }); + } + }; + ensure_no_symlink_components(workspace_root, path)?; + if metadata.file_type().is_symlink() { + return Err(symlink_error(path)); + } + Ok(Some(metadata)) +} + +fn ensure_no_symlink_components(workspace_root: &Path, path: &Path) -> Result<()> { + let relative = path.strip_prefix(workspace_root).map_err(|_| { + AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("{} is outside {}", path.display(), workspace_root.display()), + ) + })?; + let mut current = workspace_root.to_path_buf(); + for component in relative.components() { + let Component::Normal(part) = component else { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("unsafe path component in {}", path.display()), + )); + }; + current.push(part); + let metadata = fs::symlink_metadata(¤t).map_err(|source| AdapterError::Io { + action: "read metadata", + path: current.clone(), + source, + })?; + if metadata.file_type().is_symlink() { + return Err(symlink_error(¤t)); + } + } + Ok(()) +} + +fn symlink_error(path: &Path) -> AdapterError { + AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("symlinked path {} is not supported", path.display()), + ) +} + +fn entity_limit_error(path: &Path, message: impl Into) -> AdapterError { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("{}: {}", path.display(), message.into()), + ) +} + /// Reads a file to a UTF-8 string. pub fn read_to_string(path: &Path) -> Result { fs::read_to_string(path).map_err(|source| AdapterError::Io { @@ -873,16 +1041,28 @@ pub fn is_safe_relative(path: &Path) -> bool { /// Returns the maximum modification time of a file or directory tree. pub fn max_mtime_string(path: &Path) -> Result { - if path.is_file() { + let metadata = fs::symlink_metadata(path).map_err(|source| AdapterError::Io { + action: "read metadata", + path: path.to_path_buf(), + source, + })?; + if metadata.file_type().is_symlink() { + return Err(symlink_error(path)); + } + if metadata.is_file() { return mtime_string(path); } let mut newest = UNIX_EPOCH; for entry in read_dir_sorted(path)? { let entry_path = entry.path(); - let modified = if entry_path.is_dir() { + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + return Err(symlink_error(&entry_path)); + } + let modified = if file_type.is_dir() { system_time_from_string(&max_mtime_string(&entry_path)?) } else { - fs::metadata(&entry_path) + fs::symlink_metadata(&entry_path) .and_then(|metadata| metadata.modified()) .unwrap_or(UNIX_EPOCH) }; @@ -895,13 +1075,19 @@ pub fn max_mtime_string(path: &Path) -> Result { /// Returns the modification time of a file as a formatted string. pub fn mtime_string(path: &Path) -> Result { - let modified = fs::metadata(path) - .and_then(|metadata| metadata.modified()) - .map_err(|source| AdapterError::Io { - action: "read metadata", - path: path.to_path_buf(), - source, - })?; + let metadata = fs::symlink_metadata(path).map_err(|source| AdapterError::Io { + action: "read metadata", + path: path.to_path_buf(), + source, + })?; + if metadata.file_type().is_symlink() { + return Err(symlink_error(path)); + } + let modified = metadata.modified().map_err(|source| AdapterError::Io { + action: "read metadata", + path: path.to_path_buf(), + source, + })?; Ok(format_system_time(modified)) } @@ -1115,7 +1301,8 @@ mod tests { use super::{ Adapter, AdapterError, AdapterMetadata, FormatTranslation, canonicalize_frontmatter, - log_notification, parse_frontmatter, run_adapter_with_io, sha256_bytes, write_atomic, + collect_entity_files, log_notification, parse_frontmatter, run_adapter_with_io, + sha256_bytes, write_atomic, }; use agentmesh_protocol::EntityType; use serde_norway::Value as YamlValue; @@ -1350,6 +1537,38 @@ mod tests { assert_eq!(contents, "content"); } + #[cfg(unix)] + #[test] + fn collect_entity_files_rejects_symlinked_paths() { + use std::os::unix::fs::symlink; + + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path().join("skill"); + if let Err(error) = std::fs::create_dir_all(&root) { + panic!("skill directory should be created: {error}"); + } + if let Err(error) = std::fs::write(root.join("SKILL.md"), "content") { + panic!("skill file should be written: {error}"); + } + if let Err(error) = std::fs::write(temp.path().join("outside.txt"), "outside") { + panic!("outside file should be written: {error}"); + } + if let Err(error) = symlink(temp.path().join("outside.txt"), root.join("outside.txt")) { + panic!("symlink should be created: {error}"); + } + + let mut files = BTreeMap::new(); + let error = match collect_entity_files(&root, &root, &mut files) { + Ok(()) => panic!("symlinked entity path should fail"), + Err(error) => error, + }; + + assert!(error.to_string().contains("symlinked path")); + } + #[test] fn maps_custom_adapter_errors() { let error = AdapterError::rpc(agentmesh_protocol::AdapterErrorCode::WriteFailed, "nope"); diff --git a/crates/agentmesh-core/Cargo.toml b/crates/agentmesh-core/Cargo.toml index 347602f..8f222ef 100644 --- a/crates/agentmesh-core/Cargo.toml +++ b/crates/agentmesh-core/Cargo.toml @@ -12,7 +12,7 @@ homepage.workspace = true agentmesh-protocol.workspace = true blake3 = "1.8.5" fs2 = "0.4.3" -jsonschema = "0.46.5" +jsonschema = { version = "0.46.5", default-features = false } serde.workspace = true serde_json.workspace = true serde_norway = "0.9.42" diff --git a/crates/agentmesh-core/src/pipeline.rs b/crates/agentmesh-core/src/pipeline.rs index c063c55..65f8579 100644 --- a/crates/agentmesh-core/src/pipeline.rs +++ b/crates/agentmesh-core/src/pipeline.rs @@ -53,6 +53,9 @@ use crate::{ pub type Result = std::result::Result; const DOCTOR_PRIVACY_WARNING_DETAIL_LIMIT: usize = 20; +const MAX_ENTITY_TREE_DEPTH: usize = 32; +const MAX_ENTITY_FILE_COUNT: usize = 1024; +const MAX_ENTITY_TOTAL_BYTES: u64 = 64 * 1024 * 1024; /// Runtime adapter operations required by the sync pipeline. pub trait AdapterRegistry { @@ -816,7 +819,7 @@ fn entity_location_hash( lockfile_path: &Path, ) -> Result> { let absolute_path = path_from_lockfile(repo_root, location, lockfile_path); - if !absolute_path.exists() { + if !is_regular_file_path(&absolute_path)? { return Ok(None); } if entity_type != EntityType::Skill { @@ -825,7 +828,7 @@ fn entity_location_hash( let Some(root) = absolute_path.parent() else { return Ok(None); }; - if !root.is_dir() { + if !is_regular_dir_path(root)? { return Ok(None); } let files = collect_entity_text_files(root, root)?; @@ -2271,7 +2274,7 @@ struct EntityCandidate { fn entity_candidates(repo_root: &Path) -> Result> { let mut candidates = Vec::new(); - if repo_root.join("AGENTS.md").is_file() { + if is_regular_file_path(&repo_root.join("AGENTS.md"))? { candidates.push(EntityCandidate { entity_type: EntityType::Instructions, location_key: location_key(".ai")?, @@ -2301,16 +2304,16 @@ fn scan_skill_dir( candidates: &mut Vec, ) -> Result<()> { let dir = repo_root.join(relative_dir); - if !dir.is_dir() { + if !is_regular_dir_path(&dir)? { return Ok(()); } for entry in read_dir_sorted(&dir)? { - if !entry.is_dir() { + if !is_regular_dir_path(&entry)? { continue; } let skill_md = entry.join("SKILL.md"); - if !skill_md.is_file() { + if !is_regular_file_path(&skill_md)? { continue; } let Some(name) = entry.file_name().and_then(|name| name.to_str()) else { @@ -2336,12 +2339,12 @@ fn scan_subagent_dir( candidates: &mut Vec, ) -> Result<()> { let dir = repo_root.join(relative_dir); - if !dir.is_dir() { + if !is_regular_dir_path(&dir)? { return Ok(()); } for entry in read_dir_sorted(&dir)? { - if !entry.is_file() { + if !is_regular_file_path(&entry)? { continue; } if entry.extension().and_then(|value| value.to_str()) != Some(extension) { @@ -2379,6 +2382,32 @@ fn read_dir_sorted(dir: &Path) -> Result> { Ok(entries) } +fn is_regular_file_path(path: &Path) -> Result { + Ok(safe_path_metadata(path)?.is_some_and(|metadata| metadata.is_file())) +} + +fn is_regular_dir_path(path: &Path) -> Result { + Ok(safe_path_metadata(path)?.is_some_and(|metadata| metadata.is_dir())) +} + +fn safe_path_metadata(path: &Path) -> Result> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(PipelineError::Io { + action: "read metadata", + path: path.to_path_buf(), + source, + }); + } + }; + if metadata.file_type().is_symlink() { + return Err(symlink_entity_error(path)); + } + Ok(Some(metadata)) +} + fn entity_files_for_candidate( repo_root: &Path, candidate: &EntityCandidate, @@ -2411,14 +2440,65 @@ fn entity_files_for_candidate( fn collect_entity_text_files(root: &Path, dir: &Path) -> Result>> { let mut files = BTreeMap::new(); + let mut total_bytes = 0; + collect_entity_text_files_inner(root, dir, 0, &mut files, &mut total_bytes)?; + Ok(files) +} + +fn collect_entity_text_files_inner( + root: &Path, + dir: &Path, + depth: usize, + files: &mut BTreeMap>, + total_bytes: &mut u64, +) -> Result<()> { + if depth > MAX_ENTITY_TREE_DEPTH { + return Err(entity_limit_error( + dir, + format!("entity directory depth exceeds {MAX_ENTITY_TREE_DEPTH}"), + )); + } + + let dir_metadata = fs::symlink_metadata(dir).map_err(|source| PipelineError::Io { + action: "read metadata", + path: dir.to_path_buf(), + source, + })?; + if dir_metadata.file_type().is_symlink() { + return Err(symlink_entity_error(dir)); + } + for path in read_dir_sorted(dir)? { - if path.is_dir() { - files.extend(collect_entity_text_files(root, &path)?); + let metadata = fs::symlink_metadata(&path).map_err(|source| PipelineError::Io { + action: "read metadata", + path: path.clone(), + source, + })?; + if metadata.file_type().is_symlink() { + return Err(symlink_entity_error(&path)); + } + if metadata.is_dir() { + collect_entity_text_files_inner(root, &path, depth + 1, files, total_bytes)?; continue; } - if !path.is_file() { + if !metadata.is_file() { continue; } + if files.len() >= MAX_ENTITY_FILE_COUNT { + return Err(entity_limit_error( + &path, + format!("entity file count exceeds {MAX_ENTITY_FILE_COUNT}"), + )); + } + let projected_bytes = total_bytes + .checked_add(metadata.len()) + .ok_or_else(|| entity_limit_error(&path, "entity byte count overflowed"))?; + if projected_bytes > MAX_ENTITY_TOTAL_BYTES { + return Err(entity_limit_error( + &path, + format!("entity byte size exceeds {MAX_ENTITY_TOTAL_BYTES}"), + )); + } let relative = path .strip_prefix(root) .map(Path::to_path_buf) @@ -2428,9 +2508,32 @@ fn collect_entity_text_files(root: &Path, dir: &Path) -> Result MAX_ENTITY_TOTAL_BYTES { + return Err(entity_limit_error( + &path, + format!("entity byte size exceeds {MAX_ENTITY_TOTAL_BYTES}"), + )); + } files.insert(relative, contents); } - Ok(files) + Ok(()) +} + +fn symlink_entity_error(path: &Path) -> PipelineError { + PipelineError::EntityFormat { + path: path.to_path_buf(), + message: "symlinked entity path is not supported".to_string(), + } +} + +fn entity_limit_error(path: &Path, message: impl Into) -> PipelineError { + PipelineError::EntityFormat { + path: path.to_path_buf(), + message: message.into(), + } } fn canonicalize_for_candidate( @@ -3937,6 +4040,37 @@ schema: 1 assert_eq!(emitted, &actual); } + #[cfg(unix)] + #[test] + fn entity_file_collection_rejects_symlinked_paths() { + use std::os::unix::fs::symlink; + + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path().join("skill"); + if let Err(error) = fs::create_dir_all(&root) { + panic!("skill directory should be created: {error}"); + } + if let Err(error) = fs::write(root.join("SKILL.md"), "content") { + panic!("skill file should be written: {error}"); + } + if let Err(error) = fs::write(temp.path().join("outside.txt"), "outside") { + panic!("outside file should be written: {error}"); + } + if let Err(error) = symlink(temp.path().join("outside.txt"), root.join("outside.txt")) { + panic!("symlink should be created: {error}"); + } + + let error = match super::collect_entity_text_files(&root, &root) { + Ok(_) => panic!("symlinked entity path should fail"), + Err(error) => error, + }; + + assert!(error.to_string().contains("symlinked entity path")); + } + #[test] fn pin_marker_controls_imported_identity() { let temp = match tempfile::tempdir() { diff --git a/crates/agentmesh-protocol/src/lib.rs b/crates/agentmesh-protocol/src/lib.rs index 4619acf..5695227 100644 --- a/crates/agentmesh-protocol/src/lib.rs +++ b/crates/agentmesh-protocol/src/lib.rs @@ -17,6 +17,9 @@ pub const PROTOCOL_VERSION: u32 = 1; /// JSON-RPC protocol marker. pub const JSONRPC_VERSION: &str = "2.0"; +/// Maximum JSON-RPC frame payload accepted over adapter stdio. +pub const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024; + /// Canonical entity categories exchanged across adapter boundaries. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -700,6 +703,14 @@ pub enum ProtocolError { /// Header value. value: String, }, + /// Content length exceeds the accepted frame limit. + #[error("Content-Length {length} exceeds maximum frame size {max}")] + FrameTooLarge { + /// Requested payload length. + length: usize, + /// Maximum accepted payload length. + max: usize, + }, /// JSON serialization failed. #[error("failed to serialize JSON-RPC message")] SerializeJson { @@ -764,6 +775,12 @@ pub fn read_frame(reader: &mut impl BufRead) -> Result> { } let length = content_length.ok_or(ProtocolError::MissingContentLength)?; + if length > MAX_FRAME_BYTES { + return Err(ProtocolError::FrameTooLarge { + length, + max: MAX_FRAME_BYTES, + }); + } let mut payload = vec![0; length]; reader .read_exact(&mut payload) @@ -779,6 +796,12 @@ pub fn read_frame(reader: &mut impl BufRead) -> Result> { /// Writes one framed payload. pub fn write_frame(writer: &mut impl Write, payload: &[u8]) -> Result<()> { + if payload.len() > MAX_FRAME_BYTES { + return Err(ProtocolError::FrameTooLarge { + length: payload.len(), + max: MAX_FRAME_BYTES, + }); + } write!(writer, "Content-Length: {}\r\n\r\n", payload.len()).map_err(|source| { ProtocolError::Io { action: "write header", @@ -827,8 +850,8 @@ mod tests { use super::{ AdapterErrorCode, EntityFile, EntityType, InitializeRequest, InitializeResponse, - JsonRpcRequest, PROTOCOL_VERSION, ProtocolError, RequestId, read_frame, read_json_frame, - write_frame, write_json_frame, + JsonRpcRequest, MAX_FRAME_BYTES, PROTOCOL_VERSION, ProtocolError, RequestId, read_frame, + read_json_frame, write_frame, write_json_frame, }; #[test] @@ -961,4 +984,16 @@ mod tests { assert!(matches!(error, ProtocolError::InvalidHeader { .. })); } + + #[test] + fn rejects_oversized_frames_before_body_allocation() { + let mut reader = + Cursor::new(format!("Content-Length: {}\r\n\r\n", MAX_FRAME_BYTES + 1).into_bytes()); + let error = match read_frame(&mut reader) { + Ok(_) => panic!("oversized frame should fail"), + Err(error) => error, + }; + + assert!(matches!(error, ProtocolError::FrameTooLarge { .. })); + } } diff --git a/crates/agentmesh/src/main.rs b/crates/agentmesh/src/main.rs index 52fbf09..c00eed4 100644 --- a/crates/agentmesh/src/main.rs +++ b/crates/agentmesh/src/main.rs @@ -2646,6 +2646,11 @@ fn install_git_pre_commit_hook(context: &CliContext, force: bool) -> Result<()> Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(error) => return Err(CliError::from_io(error)), }; + let existing_mode = if existing.is_some() { + file_mode(&hook)? + } else { + None + }; let existing_is_agentmesh = existing .as_deref() .is_some_and(|content| content.contains(GIT_PRE_COMMIT_MARKER)); @@ -2672,16 +2677,18 @@ fn install_git_pre_commit_hook(context: &CliContext, force: bool) -> Result<()> AgentmeshExitCode::Usage, )); } - write_text_atomic(&saved, content)?; - make_executable(&saved)?; + write_text_atomic_with_mode(&saved, content, existing_mode)?; true } } else { false }; - write_text_atomic(&hook, &git_pre_commit_body(&binary_path, chain_original))?; - make_executable(&hook)?; + write_text_atomic_with_mode( + &hook, + &git_pre_commit_body(&binary_path, chain_original), + hook_wrapper_mode(existing_mode), + )?; record_git_pre_commit_ownership(context, chain_original)?; if !context.silent { @@ -2820,8 +2827,12 @@ fn rewrite_git_pre_commit_hook(context: &CliContext) -> Result<()> { } let binary_path = std::env::current_exe().map_err(CliError::from_io)?; let saved = context.repo_root.join(GIT_PRE_COMMIT_SAVED); - write_text_atomic(&hook, &git_pre_commit_body(&binary_path, saved.exists()))?; - make_executable(&hook) + let existing_mode = file_mode(&hook)?; + write_text_atomic_with_mode( + &hook, + &git_pre_commit_body(&binary_path, saved.exists()), + hook_wrapper_mode(existing_mode), + ) } fn print_codex_trust_prompt(context: &CliContext, hooks: &[agentmesh_protocol::InstalledHook]) { @@ -2996,7 +3007,6 @@ fn uninstall_git_pre_commit_hook( if saved.exists() { fs::rename(&saved, &hook).map_err(CliError::from_io)?; - make_executable(&hook)?; if !context.silent { println!( " {} Restored original git pre-commit hook", @@ -3236,7 +3246,7 @@ fn shell_quote_path(path: &Path) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } -fn write_text_atomic(path: &Path, content: &str) -> Result<()> { +fn write_text_atomic_with_mode(path: &Path, content: &str, mode: Option) -> Result<()> { let Some(parent) = path.parent() else { return Err(CliError::new( format!("cannot resolve parent directory for {}", path.display()), @@ -3246,23 +3256,55 @@ fn write_text_atomic(path: &Path, content: &str) -> Result<()> { fs::create_dir_all(parent).map_err(CliError::from_io)?; let temp = parent.join(format!(".agentmesh-{}.tmp", std::process::id())); fs::write(&temp, content).map_err(CliError::from_io)?; + set_file_mode(&temp, mode)?; fs::rename(&temp, path).map_err(CliError::from_io) } -fn make_executable(path: &Path) -> Result<()> { +fn file_mode(path: &Path) -> Result> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let metadata = fs::metadata(path).map_err(CliError::from_io)?; - let mut permissions = metadata.permissions(); - permissions.set_mode(permissions.mode() | 0o755); - fs::set_permissions(path, permissions).map_err(CliError::from_io)?; + Ok(Some(metadata.permissions().mode() & 0o777)) + } + + #[cfg(not(unix))] + { + let _ = path; + Ok(None) + } +} + +fn hook_wrapper_mode(existing_mode: Option) -> Option { + #[cfg(unix)] + { + Some(existing_mode.unwrap_or(0o600) | 0o100) + } + + #[cfg(not(unix))] + { + let _ = existing_mode; + None + } +} + +fn set_file_mode(path: &Path, mode: Option) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + if let Some(mode) = mode { + let mut permissions = fs::metadata(path).map_err(CliError::from_io)?.permissions(); + permissions.set_mode(mode); + fs::set_permissions(path, permissions).map_err(CliError::from_io)?; + } } #[cfg(not(unix))] { let _ = path; + let _ = mode; } Ok(()) diff --git a/crates/agentmesh/tests/cli_flows.rs b/crates/agentmesh/tests/cli_flows.rs index 3268f51..734d382 100644 --- a/crates/agentmesh/tests/cli_flows.rs +++ b/crates/agentmesh/tests/cli_flows.rs @@ -1277,7 +1277,19 @@ fn git_pre_commit_install_chains_and_uninstall_restores_existing_hook() { let repo = temp.path().join("repo"); let cache = temp.path().join("cache"); let original = "#!/bin/sh\necho user-hook\n"; - write(repo.join(".git/hooks/pre-commit"), original); + let original_hook = repo.join(".git/hooks/pre-commit"); + write(&original_hook, original); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(&original_hook) + .unwrap_or_else(|error| panic!("original hook metadata should be readable: {error}")) + .permissions(); + permissions.set_mode(0o600); + fs::set_permissions(&original_hook, permissions) + .unwrap_or_else(|error| panic!("original hook permissions should be set: {error}")); + } let install = run_agentmesh( &repo, @@ -1292,6 +1304,23 @@ fn git_pre_commit_install_chains_and_uninstall_restores_existing_hook() { assert!(contents.contains("pre-commit.agentmesh-saved")); assert!(contents.contains("git-pre-commit")); assert_eq!(read(&saved), original); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let saved_mode = fs::metadata(&saved) + .unwrap_or_else(|error| panic!("saved hook metadata should be readable: {error}")) + .permissions() + .mode() + & 0o777; + let wrapper_mode = fs::metadata(&hook) + .unwrap_or_else(|error| panic!("wrapper hook metadata should be readable: {error}")) + .permissions() + .mode() + & 0o777; + assert_eq!(saved_mode, 0o600); + assert_eq!(wrapper_mode, 0o700); + } let ownership = match find_named_file(&cache, "hook-ownership.json") { Some(path) => read(path), @@ -1304,6 +1333,17 @@ fn git_pre_commit_install_chains_and_uninstall_restores_existing_hook() { assert_success(&uninstall); assert_eq!(read(&hook), original); assert!(!saved.exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let restored_mode = fs::metadata(&hook) + .unwrap_or_else(|error| panic!("restored hook metadata should be readable: {error}")) + .permissions() + .mode() + & 0o777; + assert_eq!(restored_mode, 0o600); + } } #[test] diff --git a/deny.toml b/deny.toml index ff6d099..56c9178 100644 --- a/deny.toml +++ b/deny.toml @@ -10,9 +10,7 @@ allow = [ "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", - "BSD-3-Clause", "BSL-1.0", - "CDLA-Permissive-2.0", "CC0-1.0", "ISC", "MIT", @@ -21,9 +19,6 @@ allow = [ "Unlicense", "Zlib", ] -exceptions = [ - { allow = ["NCSA"], crate = "libfuzzer-sys" }, -] [bans] multiple-versions = "warn" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index a482986..89d297a 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "agentmesh-adapter-sdk-rust" -version = "0.1.0" +version = "0.1.2" dependencies = [ "agentmesh-protocol", "serde", @@ -17,7 +17,7 @@ dependencies = [ [[package]] name = "agentmesh-core" -version = "0.1.0" +version = "0.1.2" dependencies = [ "agentmesh-protocol", "blake3", @@ -47,7 +47,7 @@ dependencies = [ [[package]] name = "agentmesh-protocol" -version = "0.1.0" +version = "0.1.2" dependencies = [ "base64", "serde", @@ -108,40 +108,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "aws-lc-rs" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "base64" version = "0.22.1" @@ -220,12 +192,6 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - [[package]] name = "cc" version = "1.2.62" @@ -244,25 +210,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - [[package]] name = "const-oid" version = "0.10.2" @@ -275,22 +222,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.3.0" @@ -337,12 +268,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "email_address" version = "0.2.9" @@ -365,7 +290,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -402,12 +327,6 @@ dependencies = [ "serde", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -420,15 +339,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - [[package]] name = "fraction" version = "0.15.4" @@ -449,40 +359,12 @@ dependencies = [ "winapi", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - [[package]] name = "futures-task" version = "0.3.32" @@ -496,25 +378,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", - "futures-io", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -542,25 +410,6 @@ dependencies = [ "wasip3", ] -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -593,45 +442,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - [[package]] name = "hybrid-array" version = "0.4.12" @@ -641,65 +451,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - [[package]] name = "icu_collections" version = "2.1.1" @@ -820,67 +571,12 @@ dependencies = [ "serde_core", ] -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - [[package]] name = "jobserver" version = "0.1.34" @@ -924,8 +620,6 @@ dependencies = [ "referencing", "regex", "regex-syntax", - "reqwest", - "rustls", "serde", "serde_json", "unicode-general-category", @@ -999,17 +693,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - [[package]] name = "num" version = "0.4.3" @@ -1095,12 +778,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "outref" version = "0.5.2" @@ -1304,68 +981,6 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" -[[package]] -name = "reqwest" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "1.1.4" @@ -1376,81 +991,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" -dependencies = [ - "aws-lc-rs", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", + "windows-sys", ] [[package]] @@ -1465,53 +1006,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -1600,22 +1100,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "similar" version = "3.1.1" @@ -1637,28 +1121,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.117" @@ -1670,15 +1138,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - [[package]] name = "synstructure" version = "0.13.2" @@ -1700,7 +1159,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1733,43 +1192,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "toml" version = "0.8.23" @@ -1811,76 +1233,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typenum" version = "1.20.0" @@ -1921,24 +1273,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - [[package]] name = "utf8_iter" version = "1.0.4" @@ -1967,31 +1301,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -2023,16 +1332,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.72" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.122" @@ -2099,16 +1398,6 @@ dependencies = [ "semver", ] -[[package]] -name = "web-sys" -version = "0.3.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "web-time" version = "1.1.0" @@ -2119,15 +1408,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi" version = "0.3.9" @@ -2144,15 +1424,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2165,15 +1436,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2183,70 +1445,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "winnow" version = "0.7.15" @@ -2420,12 +1618,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - [[package]] name = "zerotrie" version = "0.2.4" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 906a411..a39a2a3 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -13,9 +13,9 @@ cargo-fuzz = true resolver = "3" [dependencies] -agentmesh-adapter-sdk-rust = { path = "../crates/agentmesh-adapter-sdk-rust", version = "0.1.0" } -agentmesh-core = { path = "../crates/agentmesh-core", version = "0.1.0" } -agentmesh-protocol = { path = "../crates/agentmesh-protocol", version = "0.1.0" } +agentmesh-adapter-sdk-rust = { path = "../crates/agentmesh-adapter-sdk-rust", version = "0.1.2" } +agentmesh-core = { path = "../crates/agentmesh-core", version = "0.1.2" } +agentmesh-protocol = { path = "../crates/agentmesh-protocol", version = "0.1.2" } libfuzzer-sys = "0.4" serde_norway = "0.9.42" From 93a02cc6d6486f6df6a23bb1ff8818cafd2723b9 Mon Sep 17 00:00:00 2001 From: Philip Date: Sun, 31 May 2026 12:08:48 +0200 Subject: [PATCH 4/7] refactor: split large orchestration modules Extract CLI inspection and hook management into focused modules, move core doctor reporting behind pipeline/doctor.rs, and isolate watcher service registration. Split adapter hook handling and SDK frontmatter helpers while preserving the existing public APIs and behavior. --- adapters/claude/src/hooks.rs | 110 + adapters/claude/src/lib.rs | 103 +- adapters/codex/src/hooks.rs | 134 ++ adapters/codex/src/lib.rs | 127 +- .../src/frontmatter.rs | 138 ++ crates/agentmesh-adapter-sdk-rust/src/lib.rs | 140 +- crates/agentmesh-core/src/pipeline.rs | 644 +----- crates/agentmesh-core/src/pipeline/doctor.rs | 644 ++++++ crates/agentmesh-watcher/src/lib.rs | 228 +-- crates/agentmesh-watcher/src/service.rs | 229 +++ crates/agentmesh/src/hooks.rs | 608 ++++++ crates/agentmesh/src/inspect.rs | 925 +++++++++ crates/agentmesh/src/main.rs | 1811 ++--------------- 13 files changed, 2969 insertions(+), 2872 deletions(-) create mode 100644 adapters/claude/src/hooks.rs create mode 100644 adapters/codex/src/hooks.rs create mode 100644 crates/agentmesh-adapter-sdk-rust/src/frontmatter.rs create mode 100644 crates/agentmesh-core/src/pipeline/doctor.rs create mode 100644 crates/agentmesh-watcher/src/service.rs create mode 100644 crates/agentmesh/src/hooks.rs create mode 100644 crates/agentmesh/src/inspect.rs diff --git a/adapters/claude/src/hooks.rs b/adapters/claude/src/hooks.rs new file mode 100644 index 0000000..01fdb36 --- /dev/null +++ b/adapters/claude/src/hooks.rs @@ -0,0 +1,110 @@ +use super::*; + +pub(crate) fn install_hooks( + request: InstallHooksRequest, +) -> agentmesh_adapter_sdk_rust::Result { + if !request.agentmesh_binary_path.is_absolute() { + return Err(AdapterError::rpc( + AdapterErrorCode::HookInstallFailed, + "agentmesh_binary_path must be absolute", + )); + } + + let workspace_root = workspace_root_for(&request.runtime_dir)?; + let overlay = request.runtime_dir.join("settings.local.json"); + let matcher = append_matcher("Edit|Write|MultiEdit", request.matcher_extra.as_deref()); + let command = format!( + "{} sync --trigger=claude-hook --silent", + request.agentmesh_binary_path.display() + ); + let mut value = read_json_object(&overlay)?; + let post_tool_use = ensure_hook_array(&mut value, &["hooks", "PostToolUse"])?; + + if let Some(index) = find_hook_group(post_tool_use, &command) { + return Ok(InstallHooksResponse { + hooks_installed: vec![InstalledHook { + overlay_file: workspace_relative(&workspace_root, &overlay)?, + entry_path: format!("$.hooks.PostToolUse[{index}]"), + command, + matcher, + }], + fallback_needed: false, + fallback_reason: None, + }); + } + + post_tool_use.push(json!({ + "matcher": matcher, + "hooks": [{ + "type": "command", + "command": command, + }], + })); + let index = post_tool_use.len() - 1; + write_json_pretty(&overlay, &value)?; + + Ok(InstallHooksResponse { + hooks_installed: vec![InstalledHook { + overlay_file: workspace_relative(&workspace_root, &overlay)?, + entry_path: format!("$.hooks.PostToolUse[{index}]"), + command, + matcher, + }], + fallback_needed: false, + fallback_reason: None, + }) +} + +pub(crate) fn remove_hooks( + request: RemoveHooksRequest, +) -> agentmesh_adapter_sdk_rust::Result { + let overlay = request.runtime_dir.join("settings.local.json"); + if !overlay.exists() { + return Ok(RemoveHooksResponse { + ok: false, + removed_count: 0, + error: Some("Claude hook overlay does not exist".to_string()), + }); + } + + let mut value = read_json_object(&overlay)?; + let Some(post_tool_use) = find_hook_array_mut(&mut value, &["hooks", "PostToolUse"]) else { + return Ok(RemoveHooksResponse { + ok: false, + removed_count: 0, + error: Some("Claude PostToolUse hook array not found".to_string()), + }); + }; + + let mut removed = remove_recorded_entries( + post_tool_use, + &request.entry_paths, + "$.hooks.PostToolUse", + "claude-hook", + ); + if removed == 0 { + removed = remove_matching_entries(post_tool_use, "claude-hook"); + } + + if removed == 0 { + return Ok(RemoveHooksResponse { + ok: false, + removed_count: 0, + error: Some("AgentMesh Claude hook entry not found".to_string()), + }); + } + + write_json_pretty(&overlay, &value)?; + Ok(RemoveHooksResponse { + ok: true, + removed_count: removed, + error: None, + }) +} + +fn append_matcher(default: &str, extra: Option<&str>) -> String { + match extra.map(str::trim).filter(|extra| !extra.is_empty()) { + Some(extra) => format!("{default}|{extra}"), + None => default.to_string(), + } +} diff --git a/adapters/claude/src/lib.rs b/adapters/claude/src/lib.rs index 16c0a8f..f2569b5 100644 --- a/adapters/claude/src/lib.rs +++ b/adapters/claude/src/lib.rs @@ -3,6 +3,8 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +mod hooks; + use agentmesh_adapter_sdk_rust::{ Adapter, AdapterError, AdapterMetadata, FormatTranslation, collect_entity_files, compose_frontmatter, dir_entry_file_type, ensure_hook_array, find_hook_array_mut, @@ -202,104 +204,14 @@ impl Adapter for ClaudeAdapter { &self, request: InstallHooksRequest, ) -> agentmesh_adapter_sdk_rust::Result { - if !request.agentmesh_binary_path.is_absolute() { - return Err(AdapterError::rpc( - AdapterErrorCode::HookInstallFailed, - "agentmesh_binary_path must be absolute", - )); - } - - let workspace_root = workspace_root_for(&request.runtime_dir)?; - let overlay = request.runtime_dir.join("settings.local.json"); - let matcher = append_matcher("Edit|Write|MultiEdit", request.matcher_extra.as_deref()); - let command = format!( - "{} sync --trigger=claude-hook --silent", - request.agentmesh_binary_path.display() - ); - let mut value = read_json_object(&overlay)?; - let post_tool_use = ensure_hook_array(&mut value, &["hooks", "PostToolUse"])?; - - if let Some(index) = find_hook_group(post_tool_use, &command) { - return Ok(InstallHooksResponse { - hooks_installed: vec![InstalledHook { - overlay_file: workspace_relative(&workspace_root, &overlay)?, - entry_path: format!("$.hooks.PostToolUse[{index}]"), - command, - matcher, - }], - fallback_needed: false, - fallback_reason: None, - }); - } - - post_tool_use.push(json!({ - "matcher": matcher, - "hooks": [{ - "type": "command", - "command": command, - }], - })); - let index = post_tool_use.len() - 1; - write_json_pretty(&overlay, &value)?; - - Ok(InstallHooksResponse { - hooks_installed: vec![InstalledHook { - overlay_file: workspace_relative(&workspace_root, &overlay)?, - entry_path: format!("$.hooks.PostToolUse[{index}]"), - command, - matcher, - }], - fallback_needed: false, - fallback_reason: None, - }) + hooks::install_hooks(request) } fn remove_hooks( &self, request: RemoveHooksRequest, ) -> agentmesh_adapter_sdk_rust::Result { - let overlay = request.runtime_dir.join("settings.local.json"); - if !overlay.exists() { - return Ok(RemoveHooksResponse { - ok: false, - removed_count: 0, - error: Some("Claude hook overlay does not exist".to_string()), - }); - } - - let mut value = read_json_object(&overlay)?; - let Some(post_tool_use) = find_hook_array_mut(&mut value, &["hooks", "PostToolUse"]) else { - return Ok(RemoveHooksResponse { - ok: false, - removed_count: 0, - error: Some("Claude PostToolUse hook array not found".to_string()), - }); - }; - - let mut removed = remove_recorded_entries( - post_tool_use, - &request.entry_paths, - "$.hooks.PostToolUse", - "claude-hook", - ); - if removed == 0 { - removed = remove_matching_entries(post_tool_use, "claude-hook"); - } - - if removed == 0 { - return Ok(RemoveHooksResponse { - ok: false, - removed_count: 0, - error: Some("AgentMesh Claude hook entry not found".to_string()), - }); - } - - write_json_pretty(&overlay, &value)?; - Ok(RemoveHooksResponse { - ok: true, - removed_count: removed, - error: None, - }) + hooks::remove_hooks(request) } } @@ -655,13 +567,6 @@ fn entity_file_bytes( }) } -fn append_matcher(default: &str, extra: Option<&str>) -> String { - match extra.map(str::trim).filter(|extra| !extra.is_empty()) { - Some(extra) => format!("{default}|{extra}"), - None => default.to_string(), - } -} - #[cfg(test)] mod tests { use std::collections::BTreeMap; diff --git a/adapters/codex/src/hooks.rs b/adapters/codex/src/hooks.rs new file mode 100644 index 0000000..e7912a2 --- /dev/null +++ b/adapters/codex/src/hooks.rs @@ -0,0 +1,134 @@ +use super::*; + +pub(crate) fn install_hooks( + request: InstallHooksRequest, +) -> agentmesh_adapter_sdk_rust::Result { + if !request.agentmesh_binary_path.is_absolute() { + return Err(AdapterError::rpc( + AdapterErrorCode::HookInstallFailed, + "agentmesh_binary_path must be absolute", + )); + } + + let workspace_root = workspace_root_for(&request.runtime_dir)?; + let overlay = request.runtime_dir.join("hooks.json"); + let matcher = codex_matcher(request.matcher_extra.as_deref()); + let command = format!( + "{} sync --trigger=codex-hook --silent", + request.agentmesh_binary_path.display() + ); + let mut value = read_json_object(&overlay)?; + let post_tool_use = ensure_hook_array(&mut value, &["PostToolUse"])?; + + if let Some(index) = find_hook_group(post_tool_use, &command) { + return Ok(InstallHooksResponse { + hooks_installed: vec![InstalledHook { + overlay_file: workspace_relative(&workspace_root, &overlay)?, + entry_path: format!("$.PostToolUse[{index}]"), + command, + matcher, + }], + fallback_needed: false, + fallback_reason: None, + }); + } + + post_tool_use.push(json!({ + "matcher": matcher, + "hooks": [{ + "type": "command", + "command": command, + "timeout": 5, + "statusMessage": "AgentMesh sync", + }], + })); + let index = post_tool_use.len() - 1; + write_json_pretty(&overlay, &value)?; + + Ok(InstallHooksResponse { + hooks_installed: vec![InstalledHook { + overlay_file: workspace_relative(&workspace_root, &overlay)?, + entry_path: format!("$.PostToolUse[{index}]"), + command, + matcher, + }], + fallback_needed: false, + fallback_reason: None, + }) +} + +pub(crate) fn remove_hooks( + request: RemoveHooksRequest, +) -> agentmesh_adapter_sdk_rust::Result { + let overlay = request.runtime_dir.join("hooks.json"); + if !overlay.exists() { + return Ok(RemoveHooksResponse { + ok: false, + removed_count: 0, + error: Some("Codex hook overlay does not exist".to_string()), + }); + } + + let mut value = read_json_object(&overlay)?; + let removed = { + let Some(post_tool_use) = find_hook_array_mut(&mut value, &["PostToolUse"]) else { + return Ok(RemoveHooksResponse { + ok: false, + removed_count: 0, + error: Some("Codex PostToolUse hook array not found".to_string()), + }); + }; + + let mut removed = remove_recorded_entries( + post_tool_use, + &request.entry_paths, + "$.PostToolUse", + "codex-hook", + ); + if removed == 0 { + removed = remove_matching_entries(post_tool_use, "codex-hook"); + } + removed + }; + + if removed == 0 { + return Ok(RemoveHooksResponse { + ok: false, + removed_count: 0, + error: Some("AgentMesh Codex hook entry not found".to_string()), + }); + } + + if codex_hooks_are_empty(&value) { + fs::remove_file(&overlay).map_err(|source| AdapterError::Io { + action: "remove file", + path: overlay.clone(), + source, + })?; + } else { + write_json_pretty(&overlay, &value)?; + } + + Ok(RemoveHooksResponse { + ok: true, + removed_count: removed, + error: None, + }) +} + +fn codex_matcher(extra: Option<&str>) -> String { + let mut tools = vec!["Edit", "Write", "MultiEdit"]; + if let Some(extra) = extra.map(str::trim).filter(|extra| !extra.is_empty()) { + tools.push(extra); + } + format!("^({})$", tools.join("|")) +} + +fn codex_hooks_are_empty(value: &JsonValue) -> bool { + let Some(object) = value.as_object() else { + return false; + }; + object + .iter() + .all(|(key, value)| key == "PostToolUse" && value.as_array().is_some_and(Vec::is_empty)) +} diff --git a/adapters/codex/src/lib.rs b/adapters/codex/src/lib.rs index ee2d606..6df8e05 100644 --- a/adapters/codex/src/lib.rs +++ b/adapters/codex/src/lib.rs @@ -4,6 +4,8 @@ use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; +mod hooks; + use agentmesh_adapter_sdk_rust::{ Adapter, AdapterError, AdapterMetadata, FormatTranslation, FrontmatterDocument, collect_entity_files, compose_frontmatter, dir_entry_file_type, ensure_hook_array, @@ -199,118 +201,14 @@ impl Adapter for CodexAdapter { &self, request: InstallHooksRequest, ) -> agentmesh_adapter_sdk_rust::Result { - if !request.agentmesh_binary_path.is_absolute() { - return Err(AdapterError::rpc( - AdapterErrorCode::HookInstallFailed, - "agentmesh_binary_path must be absolute", - )); - } - - let workspace_root = workspace_root_for(&request.runtime_dir)?; - let overlay = request.runtime_dir.join("hooks.json"); - let matcher = codex_matcher(request.matcher_extra.as_deref()); - let command = format!( - "{} sync --trigger=codex-hook --silent", - request.agentmesh_binary_path.display() - ); - let mut value = read_json_object(&overlay)?; - let post_tool_use = ensure_hook_array(&mut value, &["PostToolUse"])?; - - if let Some(index) = find_hook_group(post_tool_use, &command) { - return Ok(InstallHooksResponse { - hooks_installed: vec![InstalledHook { - overlay_file: workspace_relative(&workspace_root, &overlay)?, - entry_path: format!("$.PostToolUse[{index}]"), - command, - matcher, - }], - fallback_needed: false, - fallback_reason: None, - }); - } - - post_tool_use.push(json!({ - "matcher": matcher, - "hooks": [{ - "type": "command", - "command": command, - "timeout": 5, - "statusMessage": "AgentMesh sync", - }], - })); - let index = post_tool_use.len() - 1; - write_json_pretty(&overlay, &value)?; - - Ok(InstallHooksResponse { - hooks_installed: vec![InstalledHook { - overlay_file: workspace_relative(&workspace_root, &overlay)?, - entry_path: format!("$.PostToolUse[{index}]"), - command, - matcher, - }], - fallback_needed: false, - fallback_reason: None, - }) + hooks::install_hooks(request) } fn remove_hooks( &self, request: RemoveHooksRequest, ) -> agentmesh_adapter_sdk_rust::Result { - let overlay = request.runtime_dir.join("hooks.json"); - if !overlay.exists() { - return Ok(RemoveHooksResponse { - ok: false, - removed_count: 0, - error: Some("Codex hook overlay does not exist".to_string()), - }); - } - - let mut value = read_json_object(&overlay)?; - let removed = { - let Some(post_tool_use) = find_hook_array_mut(&mut value, &["PostToolUse"]) else { - return Ok(RemoveHooksResponse { - ok: false, - removed_count: 0, - error: Some("Codex PostToolUse hook array not found".to_string()), - }); - }; - - let mut removed = remove_recorded_entries( - post_tool_use, - &request.entry_paths, - "$.PostToolUse", - "codex-hook", - ); - if removed == 0 { - removed = remove_matching_entries(post_tool_use, "codex-hook"); - } - removed - }; - - if removed == 0 { - return Ok(RemoveHooksResponse { - ok: false, - removed_count: 0, - error: Some("AgentMesh Codex hook entry not found".to_string()), - }); - } - - if codex_hooks_are_empty(&value) { - fs::remove_file(&overlay).map_err(|source| AdapterError::Io { - action: "remove file", - path: overlay.clone(), - source, - })?; - } else { - write_json_pretty(&overlay, &value)?; - } - - Ok(RemoveHooksResponse { - ok: true, - removed_count: removed, - error: None, - }) + hooks::remove_hooks(request) } } @@ -865,23 +763,6 @@ fn entity_file_bytes( }) } -fn codex_matcher(extra: Option<&str>) -> String { - let mut tools = vec!["Edit", "Write", "MultiEdit"]; - if let Some(extra) = extra.map(str::trim).filter(|extra| !extra.is_empty()) { - tools.push(extra); - } - format!("^({})$", tools.join("|")) -} - -fn codex_hooks_are_empty(value: &JsonValue) -> bool { - let Some(object) = value.as_object() else { - return false; - }; - object - .iter() - .all(|(key, value)| key == "PostToolUse" && value.as_array().is_some_and(Vec::is_empty)) -} - #[cfg(test)] mod tests { use std::collections::BTreeMap; diff --git a/crates/agentmesh-adapter-sdk-rust/src/frontmatter.rs b/crates/agentmesh-adapter-sdk-rust/src/frontmatter.rs new file mode 100644 index 0000000..fa278a6 --- /dev/null +++ b/crates/agentmesh-adapter-sdk-rust/src/frontmatter.rs @@ -0,0 +1,138 @@ +use std::collections::HashSet; + +use serde_norway::{Mapping, Value as YamlValue}; + +use crate::{AdapterError, Result}; + +const COMMON_FRONTMATTER_KEYS: &[&str] = &["name", "description", "allowed-tools", "model"]; + +/// Parsed Markdown frontmatter and body. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FrontmatterDocument { + /// Parsed YAML frontmatter. + pub frontmatter: Mapping, + /// Body content after frontmatter. + pub body: String, +} + +/// Splits Markdown into YAML frontmatter and body content. +pub fn parse_frontmatter(markdown: &str) -> Result { + let Some(rest) = markdown.strip_prefix("---\n") else { + return Ok(FrontmatterDocument { + frontmatter: Mapping::new(), + body: markdown.to_string(), + }); + }; + let Some(end) = rest.find("\n---\n") else { + return Ok(FrontmatterDocument { + frontmatter: Mapping::new(), + body: markdown.to_string(), + }); + }; + + let frontmatter = &rest[..end]; + let body = &rest[end + "\n---\n".len()..]; + Ok(FrontmatterDocument { + frontmatter: parse_frontmatter_mapping(frontmatter)?, + body: body.to_string(), + }) +} + +/// Serializes Markdown with stable frontmatter key ordering. +pub fn compose_frontmatter(document: &FrontmatterDocument) -> Result { + let ordered = ordered_frontmatter(&document.frontmatter); + let frontmatter = yaml_fragment(&YamlValue::Mapping(ordered))?; + Ok(format!("---\n{frontmatter}---\n{}", document.body)) +} + +/// Canonicalizes Markdown frontmatter key ordering. +pub fn canonicalize_frontmatter(markdown: &str) -> Result { + compose_frontmatter(&parse_frontmatter(markdown)?) +} + +fn parse_frontmatter_mapping(frontmatter: &str) -> Result { + if frontmatter.trim().is_empty() { + return Ok(Mapping::new()); + } + + match serde_norway::from_str::(frontmatter) { + Ok(YamlValue::Mapping(mapping)) => Ok(mapping), + Ok(YamlValue::Null) => Ok(Mapping::new()), + Ok(_) => Err(AdapterError::FrontmatterNotMapping), + Err(source) => parse_flat_frontmatter_mapping(frontmatter) + .ok_or(AdapterError::ParseFrontmatter { source }), + } +} + +fn parse_flat_frontmatter_mapping(frontmatter: &str) -> Option { + let mut mapping = Mapping::new(); + + for line in frontmatter.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if line.chars().next().is_some_and(char::is_whitespace) { + return None; + } + + let (key, value) = line.split_once(':')?; + let key = key.trim(); + if key.is_empty() || !key.chars().all(is_plain_frontmatter_key_char) { + return None; + } + + let value = value.trim(); + if value + .chars() + .next() + .is_some_and(|character| matches!(character, '"' | '\'' | '[' | '{' | '|' | '>')) + { + return None; + } + + mapping.insert( + YamlValue::String(key.to_string()), + YamlValue::String(value.to_string()), + ); + } + + Some(mapping) +} + +fn is_plain_frontmatter_key_char(character: char) -> bool { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') +} + +fn ordered_frontmatter(frontmatter: &Mapping) -> Mapping { + let mut output = Mapping::new(); + let mut emitted = HashSet::new(); + + for key in COMMON_FRONTMATTER_KEYS { + if let Some(value) = frontmatter.get(*key) { + output.insert(YamlValue::String((*key).to_string()), value.clone()); + emitted.insert((*key).to_string()); + } + } + + let mut remaining = frontmatter + .iter() + .filter_map(|(key, value)| key.as_str().map(|key| (key.to_string(), value.clone()))) + .filter(|(key, _)| !emitted.contains(key)) + .collect::>(); + remaining.sort_by(|left, right| left.0.cmp(&right.0)); + + for (key, value) in remaining { + output.insert(YamlValue::String(key), value); + } + + output +} + +fn yaml_fragment(value: &YamlValue) -> Result { + let serialized = serde_norway::to_string(value) + .map_err(|source| AdapterError::SerializeFrontmatter { source })?; + let without_start = serialized.strip_prefix("---\n").unwrap_or(&serialized); + let without_end = without_start.strip_suffix("...\n").unwrap_or(without_start); + Ok(without_end.to_string()) +} diff --git a/crates/agentmesh-adapter-sdk-rust/src/lib.rs b/crates/agentmesh-adapter-sdk-rust/src/lib.rs index 9988725..7a5a8a1 100644 --- a/crates/agentmesh-adapter-sdk-rust/src/lib.rs +++ b/crates/agentmesh-adapter-sdk-rust/src/lib.rs @@ -1,6 +1,6 @@ //! Shared Rust adapter interfaces and stdio serving helpers. -use std::collections::{BTreeMap, HashSet}; +use std::collections::BTreeMap; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::path::{Component, Path, PathBuf}; @@ -17,12 +17,15 @@ use agentmesh_protocol::{ use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::{Map as JsonMap, Value as JsonValue}; -use serde_norway::{Mapping, Value as YamlValue}; use sha2::{Digest, Sha256}; use tempfile::NamedTempFile; use thiserror::Error; -const COMMON_FRONTMATTER_KEYS: &[&str] = &["name", "description", "allowed-tools", "model"]; +mod frontmatter; +pub use frontmatter::{ + FrontmatterDocument, canonicalize_frontmatter, compose_frontmatter, parse_frontmatter, +}; + const MAX_ENTITY_TREE_DEPTH: usize = 32; const MAX_ENTITY_FILE_COUNT: usize = 1024; const MAX_ENTITY_TOTAL_BYTES: u64 = 64 * 1024 * 1024; @@ -520,137 +523,6 @@ pub fn write_progress_notification( Ok(()) } -/// Parsed Markdown frontmatter and body. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FrontmatterDocument { - /// Parsed YAML frontmatter. - pub frontmatter: Mapping, - /// Body content after frontmatter. - pub body: String, -} - -/// Splits Markdown into YAML frontmatter and body content. -pub fn parse_frontmatter(markdown: &str) -> Result { - let Some(rest) = markdown.strip_prefix("---\n") else { - return Ok(FrontmatterDocument { - frontmatter: Mapping::new(), - body: markdown.to_string(), - }); - }; - let Some(end) = rest.find("\n---\n") else { - return Ok(FrontmatterDocument { - frontmatter: Mapping::new(), - body: markdown.to_string(), - }); - }; - - let frontmatter = &rest[..end]; - let body = &rest[end + "\n---\n".len()..]; - Ok(FrontmatterDocument { - frontmatter: parse_frontmatter_mapping(frontmatter)?, - body: body.to_string(), - }) -} - -/// Serializes Markdown with stable frontmatter key ordering. -pub fn compose_frontmatter(document: &FrontmatterDocument) -> Result { - let ordered = ordered_frontmatter(&document.frontmatter); - let frontmatter = yaml_fragment(&YamlValue::Mapping(ordered))?; - Ok(format!("---\n{frontmatter}---\n{}", document.body)) -} - -/// Canonicalizes Markdown frontmatter key ordering. -pub fn canonicalize_frontmatter(markdown: &str) -> Result { - compose_frontmatter(&parse_frontmatter(markdown)?) -} - -fn parse_frontmatter_mapping(frontmatter: &str) -> Result { - if frontmatter.trim().is_empty() { - return Ok(Mapping::new()); - } - - match serde_norway::from_str::(frontmatter) { - Ok(YamlValue::Mapping(mapping)) => Ok(mapping), - Ok(YamlValue::Null) => Ok(Mapping::new()), - Ok(_) => Err(AdapterError::FrontmatterNotMapping), - Err(source) => parse_flat_frontmatter_mapping(frontmatter) - .ok_or(AdapterError::ParseFrontmatter { source }), - } -} - -fn parse_flat_frontmatter_mapping(frontmatter: &str) -> Option { - let mut mapping = Mapping::new(); - - for line in frontmatter.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - if line.chars().next().is_some_and(char::is_whitespace) { - return None; - } - - let (key, value) = line.split_once(':')?; - let key = key.trim(); - if key.is_empty() || !key.chars().all(is_plain_frontmatter_key_char) { - return None; - } - - let value = value.trim(); - if value - .chars() - .next() - .is_some_and(|character| matches!(character, '"' | '\'' | '[' | '{' | '|' | '>')) - { - return None; - } - - mapping.insert( - YamlValue::String(key.to_string()), - YamlValue::String(value.to_string()), - ); - } - - Some(mapping) -} - -fn is_plain_frontmatter_key_char(character: char) -> bool { - character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') -} - -fn ordered_frontmatter(frontmatter: &Mapping) -> Mapping { - let mut output = Mapping::new(); - let mut emitted = HashSet::new(); - - for key in COMMON_FRONTMATTER_KEYS { - if let Some(value) = frontmatter.get(*key) { - output.insert(YamlValue::String((*key).to_string()), value.clone()); - emitted.insert((*key).to_string()); - } - } - - let mut remaining = frontmatter - .iter() - .filter_map(|(key, value)| key.as_str().map(|key| (key.to_string(), value.clone()))) - .filter(|(key, _)| !emitted.contains(key)) - .collect::>(); - remaining.sort_by(|left, right| left.0.cmp(&right.0)); - - for (key, value) in remaining { - output.insert(YamlValue::String(key), value); - } - - output -} - -fn yaml_fragment(value: &YamlValue) -> Result { - let serialized = serde_norway::to_string(value) - .map_err(|source| AdapterError::SerializeFrontmatter { source })?; - let without_start = serialized.strip_prefix("---\n").unwrap_or(&serialized); - let without_end = without_start.strip_suffix("...\n").unwrap_or(without_start); - Ok(without_end.to_string()) -} - /// Computes a SHA-256 hash over in-memory bytes. #[must_use] pub fn sha256_bytes(bytes: &[u8]) -> String { diff --git a/crates/agentmesh-core/src/pipeline.rs b/crates/agentmesh-core/src/pipeline.rs index 65f8579..c544f19 100644 --- a/crates/agentmesh-core/src/pipeline.rs +++ b/crates/agentmesh-core/src/pipeline.rs @@ -49,10 +49,12 @@ use crate::{ UninstallSummary, UpgradeSummary, VERSION, }; +mod doctor; +pub use doctor::{doctor, doctor_with_adapter_registry}; + /// Pipeline result type. pub type Result = std::result::Result; -const DOCTOR_PRIVACY_WARNING_DETAIL_LIMIT: usize = 20; const MAX_ENTITY_TREE_DEPTH: usize = 32; const MAX_ENTITY_FILE_COUNT: usize = 1024; const MAX_ENTITY_TOTAL_BYTES: u64 = 64 * 1024 * 1024; @@ -427,391 +429,6 @@ pub fn uninstall(repo_root: &Path, opts: UninstallOptions) -> Result Result { - doctor_with_adapter_registry(repo_root, &SubprocessAdapterRegistry) -} - -/// Builds a health report with an explicit adapter registry. -pub fn doctor_with_adapter_registry( - repo_root: &Path, - adapters: &dyn AdapterRegistry, -) -> Result { - let cache = CacheLayout::new(&default_cache_root()?, repo_root)?; - let lockfile = read_lockfile_or_empty(repo_root)?; - let pending_queue = PendingQueue::new(&cache.pending_syncs_dir); - let pending_count = pending_queue.read_ready()?.len(); - let failed_pending_count = failed_pending_records(&cache.pending_syncs_dir)?; - let pending_conflicts = lockfile - .entities - .values() - .filter(|entry| entry.pending_conflict_resolution == Some(true)) - .count(); - let config = load_config(repo_root)?.config; - let capability_skipped = capability_skip_count_for_lockfile(&lockfile, &config)?; - let sync_state = entity_sync_state(repo_root, &lockfile)?; - let privacy_findings = doctor_lockfile_privacy_findings(&lockfile); - - let mut findings = Vec::new(); - findings.push(format!("entities: {}", lockfile.entities.len())); - findings.push(format!("entities_in_sync: {}", sync_state.in_sync)); - findings.push(format!("entities_out_of_sync: {}", sync_state.out_of_sync)); - findings.push(format!("pending_conflicts: {pending_conflicts}")); - findings.push(format!("pending_syncs: {pending_count}")); - findings.push(format!("failed_pending_syncs: {failed_pending_count}")); - findings.extend(doctor_pending_failure_findings(&cache.pending_syncs_dir)?); - findings.push(format!("capability_skips: {capability_skipped}")); - findings.push(format!("cache_root: {}", cache.root.display())); - findings.extend(doctor_integrity_findings(repo_root, &cache)?); - findings.extend(doctor_adapter_findings(repo_root, &lockfile, adapters)?); - findings.extend(doctor_hook_findings(repo_root, &cache)?); - findings.extend(doctor_conflict_findings(&cache, &lockfile)?); - findings.extend(privacy_findings.findings); - findings.push(format!("watcher_pid: {}", cache.watcher_pid.display())); - findings.push(format!("watcher_log: {}", cache.watcher_log.display())); - findings.push("network: disabled".to_string()); - - Ok(DoctorReport { - findings, - health: DoctorHealth { - entities_out_of_sync: sync_state.out_of_sync, - pending_conflicts, - pending_syncs: pending_count, - failed_pending_syncs: failed_pending_count, - capability_skips: capability_skipped, - lockfile_privacy_warnings: privacy_findings.warning_count, - }, - }) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct LockfilePrivacyFindings { - warning_count: usize, - findings: Vec, -} - -fn doctor_lockfile_privacy_findings(lockfile: &Lockfile) -> LockfilePrivacyFindings { - let mut warnings = Vec::new(); - let mut warning_count = 0; - - for (entity_id, entity) in &lockfile.entities { - if contains_sensitive_term(entity_id.as_str()) { - push_privacy_warning( - &mut warnings, - &mut warning_count, - format!( - "entity id `{}` contains sensitive-looking text", - entity_id.as_str() - ), - ); - } - for (location, path) in &entity.locations { - if path_contains_sensitive_term(path) { - push_privacy_warning( - &mut warnings, - &mut warning_count, - format!( - "location path for `{}` at `{}` contains sensitive-looking text: {}", - entity_id.as_str(), - location.as_str(), - path.display() - ), - ); - } - } - for entry in &entity.lineage { - if path_contains_sensitive_term(&entry.imported_from) { - push_privacy_warning( - &mut warnings, - &mut warning_count, - format!( - "lineage path for `{}` contains sensitive-looking text: {}", - entity_id.as_str(), - entry.imported_from.display() - ), - ); - } - } - for record in &entity.rename_history { - if path_contains_sensitive_term(&record.from) { - push_privacy_warning( - &mut warnings, - &mut warning_count, - format!( - "rename source for `{}` contains sensitive-looking text: {}", - entity_id.as_str(), - record.from.display() - ), - ); - } - if path_contains_sensitive_term(&record.to) { - push_privacy_warning( - &mut warnings, - &mut warning_count, - format!( - "rename target for `{}` contains sensitive-looking text: {}", - entity_id.as_str(), - record.to.display() - ), - ); - } - } - } - - for (entity_id, overrides) in &lockfile.overrides { - for (runtime, override_entry) in overrides { - collect_sensitive_override_keys( - entity_id, - runtime, - &override_entry.0, - &mut warnings, - &mut warning_count, - ); - } - } - - let mut findings = Vec::new(); - if warning_count > 0 { - findings.push(format!("lockfile_privacy_warnings: {warning_count}")); - } - findings.extend(warnings); - if warning_count > DOCTOR_PRIVACY_WARNING_DETAIL_LIMIT { - findings.push(format!( - "lockfile_privacy_warnings_truncated: {} additional warning(s)", - warning_count - DOCTOR_PRIVACY_WARNING_DETAIL_LIMIT - )); - } - - LockfilePrivacyFindings { - warning_count, - findings, - } -} - -fn push_privacy_warning(warnings: &mut Vec, warning_count: &mut usize, detail: String) { - *warning_count += 1; - if warnings.len() < DOCTOR_PRIVACY_WARNING_DETAIL_LIMIT { - warnings.push(format!( - "lockfile_privacy_warning_{warning_count}: {detail}" - )); - } -} - -fn collect_sensitive_override_keys( - entity_id: &EntityId, - runtime: &RuntimeName, - values: &BTreeMap, - warnings: &mut Vec, - warning_count: &mut usize, -) { - for (key, value) in values { - collect_sensitive_json_keys( - entity_id, - runtime, - Some(key), - value, - warnings, - warning_count, - ); - } -} - -fn collect_sensitive_json_keys( - entity_id: &EntityId, - runtime: &RuntimeName, - key: Option<&str>, - value: &Value, - warnings: &mut Vec, - warning_count: &mut usize, -) { - if let Some(key) = key - && contains_sensitive_term(key) - { - push_privacy_warning( - warnings, - warning_count, - format!( - "override key `{key}` for `{}` at `{}` looks sensitive; keep secrets in machine-local config or environment variables", - entity_id.as_str(), - runtime.as_str() - ), - ); - } - - match value { - Value::Object(map) => { - for (child_key, child_value) in map { - collect_sensitive_json_keys( - entity_id, - runtime, - Some(child_key), - child_value, - warnings, - warning_count, - ); - } - } - Value::Array(values) => { - for child_value in values { - collect_sensitive_json_keys( - entity_id, - runtime, - None, - child_value, - warnings, - warning_count, - ); - } - } - Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} - } -} - -fn path_contains_sensitive_term(path: &Path) -> bool { - path.components() - .any(|component| contains_sensitive_term(&component.as_os_str().to_string_lossy())) -} - -fn contains_sensitive_term(value: &str) -> bool { - let normalized = value.to_ascii_lowercase(); - [ - "access-key", - "access_key", - "apikey", - "api-key", - "api_key", - "auth-token", - "auth_token", - "bearer", - "client-secret", - "client_secret", - "cookie", - "credential", - "jwt", - "oauth", - "passwd", - "password", - "private-key", - "private_key", - "secret", - "session", - "token", - ] - .iter() - .any(|term| normalized.contains(term)) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -struct EntitySyncState { - in_sync: usize, - out_of_sync: usize, -} - -fn failed_pending_records(dir: &Path) -> Result { - let mut count = 0; - match fs::read_dir(dir) { - Ok(entries) => { - for entry in entries { - let entry = entry.map_err(|source| PipelineError::Io { - action: "read directory entry", - path: dir.to_path_buf(), - source, - })?; - if entry - .file_name() - .to_str() - .is_some_and(|name| name.starts_with("failed-")) - { - count += 1; - } - } - } - Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} - Err(source) => { - return Err(PipelineError::Io { - action: "read directory", - path: dir.to_path_buf(), - source, - }); - } - } - Ok(count) -} - -fn doctor_pending_failure_findings(dir: &Path) -> Result> { - let mut findings = Vec::new(); - match fs::read_dir(dir) { - Ok(entries) => { - for entry in entries { - let entry = entry.map_err(|source| PipelineError::Io { - action: "read directory entry", - path: dir.to_path_buf(), - source, - })?; - let path = entry.path(); - if !entry - .file_name() - .to_str() - .is_some_and(|name| name.starts_with("failed-")) - { - continue; - } - let record = read_json::(&path)?; - findings.push(format!( - "pending_failure_{}: path={} attempts={} error={}", - record.pending_id, - path.display(), - record.attempts, - record.last_error.as_deref().unwrap_or("unknown") - )); - } - } - Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} - Err(source) => { - return Err(PipelineError::Io { - action: "read directory", - path: dir.to_path_buf(), - source, - }); - } - } - findings.sort(); - Ok(findings) -} - -fn entity_sync_state(repo_root: &Path, lockfile: &Lockfile) -> Result { - let mut state = EntitySyncState::default(); - for entity in lockfile.entities.values() { - let mut out_of_sync = entity.locations.is_empty(); - for (location, path) in &entity.locations { - let Some(expected_hash) = entity.emitted_native_sha256.get(location).or_else(|| { - if location.as_str() == ".ai" { - Some(&entity.canonical_sha256) - } else { - None - } - }) else { - out_of_sync = true; - continue; - }; - let Some(actual_hash) = - entity_location_hash(repo_root, entity.entity_type, location, path)? - else { - out_of_sync = true; - continue; - }; - if &actual_hash != expected_hash { - out_of_sync = true; - } - } - if out_of_sync { - state.out_of_sync += 1; - } else { - state.in_sync += 1; - } - } - Ok(state) -} - fn entity_location_hash( repo_root: &Path, entity_type: EntityType, @@ -835,259 +452,6 @@ fn entity_location_hash( hash_entity_files(&files).map(Some) } -fn doctor_integrity_findings(repo_root: &Path, cache: &CacheLayout) -> Result> { - let current = current_integrity_pin(repo_root)?; - match read_integrity_pin(&cache.integrity_json) { - Ok(pin) => { - let mode = if pin.binary_path.is_absolute() { - "pinned-absolute" - } else { - "path-resolved" - }; - let status = if pin.binary_path == current.binary_path - && pin.binary_sha256 == current.binary_sha256 - { - "match" - } else { - "mismatch" - }; - Ok(vec![ - format!("integrity: {status}"), - format!("integrity_mode: {mode}"), - format!("integrity_pinned_binary: {}", pin.binary_path.display()), - format!("integrity_pinned_sha256: {}", pin.binary_sha256.as_str()), - format!( - "integrity_current_binary: {}", - current.binary_path.display() - ), - format!( - "integrity_current_sha256: {}", - current.binary_sha256.as_str() - ), - format!("integrity_version: {}", pin.binary_version), - ]) - } - Err(StateError::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => { - Ok(vec![ - "integrity: unpinned".to_string(), - format!( - "integrity_current_binary: {}", - current.binary_path.display() - ), - format!( - "integrity_current_sha256: {}", - current.binary_sha256.as_str() - ), - ]) - } - Err(error) => Err(error.into()), - } -} - -fn doctor_adapter_findings( - repo_root: &Path, - lockfile: &Lockfile, - adapters: &dyn AdapterRegistry, -) -> Result> { - let markers = detect_runtime_markers(repo_root, adapters)?; - let known = [ - (runtime_name("claude")?, markers.claude), - (runtime_name("codex")?, markers.codex), - ]; - let mut findings = Vec::new(); - let mut known_runtimes = BTreeSet::new(); - for (runtime, detected) in &known { - known_runtimes.insert(runtime.clone()); - if let Some(adapter) = lockfile.adapters.get(runtime) { - findings.push(format!( - "adapter_{}: detected={} declared=true mode={} protocol={} entities={} hooks={}", - runtime.as_str(), - detected, - adapter_mode_name(adapter.mode), - adapter.protocol_version, - adapter.entities.len(), - adapter.hooks.len() - )); - } else { - findings.push(format!( - "adapter_{}: detected={} declared=false", - runtime.as_str(), - detected - )); - } - } - findings.extend( - lockfile - .adapters - .iter() - .filter(|(runtime, _)| !known_runtimes.contains(*runtime)) - .map(|(runtime, adapter)| { - format!( - "adapter_{}: detected=false declared=true mode={} protocol={} entities={} hooks={}", - runtime.as_str(), - adapter_mode_name(adapter.mode), - adapter.protocol_version, - adapter.entities.len(), - adapter.hooks.len() - ) - }), - ); - for entity_type in [ - EntityType::Instructions, - EntityType::Skill, - EntityType::Subagent, - ] { - let runtimes = lockfile - .adapters - .iter() - .filter(|(_, adapter)| adapter.entities.contains(&entity_type)) - .map(|(runtime, _)| runtime.as_str()) - .collect::>(); - let coverage = if runtimes.is_empty() { - "none".to_string() - } else { - runtimes.join(",") - }; - findings.push(format!( - "adapter_coverage_{}: {coverage}", - entity_type.as_str() - )); - } - if lockfile.adapters.is_empty() { - findings.push("adapters: none".to_string()); - } - Ok(findings) -} - -fn adapter_mode_name(mode: AdapterMode) -> &'static str { - match mode { - AdapterMode::Bundled => "bundled", - } -} - -fn doctor_hook_findings(repo_root: &Path, cache: &CacheLayout) -> Result> { - match read_hook_ownership(&cache.hook_ownership_json) { - Ok(ownership) if ownership.0.is_empty() => Ok(vec!["hooks: none".to_string()]), - Ok(ownership) => Ok(ownership - .0 - .iter() - .map(|(runtime, entry)| { - let overlay = repo_root.join(&entry.overlay_file); - let overlay_exists = overlay.is_file(); - let command_present = if overlay_exists { - fs::read_to_string(&overlay) - .map(|contents| { - contents.contains("agentmesh") - && contents.contains(&format!("{}-hook", runtime.as_str())) - }) - .unwrap_or(false) - } else { - false - }; - let drift = !overlay_exists || entry.entry_paths.is_empty() || !command_present; - format!( - "hook_{}: overlay={} entries={} exists={} command_present={} drift={}", - runtime.as_str(), - entry.overlay_file.display(), - entry.entry_paths.len(), - overlay_exists, - command_present, - drift - ) - }) - .collect()), - Err(StateError::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => { - Ok(vec!["hooks: none".to_string()]) - } - Err(error) => Err(error.into()), - } -} - -fn doctor_conflict_findings(cache: &CacheLayout, lockfile: &Lockfile) -> Result> { - let mut preserved = 0; - match fs::read_dir(&cache.conflicts_dir) { - Ok(entries) => { - for entry in entries { - let entry = entry.map_err(|source| PipelineError::Io { - action: "read directory entry", - path: cache.conflicts_dir.clone(), - source, - })?; - if entry.path().is_dir() { - preserved += 1; - } - } - } - Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} - Err(source) => { - return Err(PipelineError::Io { - action: "read directory", - path: cache.conflicts_dir.clone(), - source, - }); - } - } - let pending = lockfile - .entities - .values() - .filter(|entry| entry.pending_conflict_resolution == Some(true)) - .count(); - let mut findings = vec![ - format!("preserved_conflict_entities: {preserved}"), - format!("pending_conflict_entities: {pending}"), - ]; - for (entity_id, entity) in &lockfile.entities { - if entity.pending_conflict_resolution != Some(true) { - continue; - } - let preserved_paths = preserved_conflict_paths(cache, entity_id)?; - let preserved = if preserved_paths.is_empty() { - "none".to_string() - } else { - preserved_paths - .iter() - .map(|path| path.display().to_string()) - .collect::>() - .join(",") - }; - findings.push(format!( - "conflict_{}: pending=true preserved={preserved}", - entity_id.as_str() - )); - } - Ok(findings) -} - -fn preserved_conflict_paths(cache: &CacheLayout, entity_id: &EntityId) -> Result> { - let dir = conflict_entity_dir(&cache.conflicts_dir, entity_id); - let mut paths = Vec::new(); - match fs::read_dir(&dir) { - Ok(entries) => { - for entry in entries { - let entry = entry.map_err(|source| PipelineError::Io { - action: "read directory entry", - path: dir.clone(), - source, - })?; - let path = entry.path(); - if path.is_file() { - paths.push(path); - } - } - } - Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} - Err(source) => { - return Err(PipelineError::Io { - action: "read directory", - path: dir, - source, - }); - } - } - paths.sort(); - Ok(paths) -} - /// Restores the latest preserved losing version for an entity/runtime pair. pub fn restore(repo_root: &Path, entity_id: &EntityId, from: RuntimeName) -> Result<()> { restore_with_options(repo_root, entity_id, from, RestoreOptions::default()).map(|_| ()) @@ -5059,7 +4423,7 @@ schema: 1 panic!("preserved version should write: {error}"); } - let findings = match super::doctor_conflict_findings(&cache, &lockfile) { + let findings = match super::doctor::doctor_conflict_findings(&cache, &lockfile) { Ok(findings) => findings, Err(error) => panic!("conflict findings should build: {error}"), }; diff --git a/crates/agentmesh-core/src/pipeline/doctor.rs b/crates/agentmesh-core/src/pipeline/doctor.rs new file mode 100644 index 0000000..c686777 --- /dev/null +++ b/crates/agentmesh-core/src/pipeline/doctor.rs @@ -0,0 +1,644 @@ +use super::*; + +const DOCTOR_PRIVACY_WARNING_DETAIL_LIMIT: usize = 20; + +pub fn doctor(repo_root: &Path) -> Result { + doctor_with_adapter_registry(repo_root, &SubprocessAdapterRegistry) +} + +/// Builds a health report with an explicit adapter registry. +pub fn doctor_with_adapter_registry( + repo_root: &Path, + adapters: &dyn AdapterRegistry, +) -> Result { + let cache = CacheLayout::new(&default_cache_root()?, repo_root)?; + let lockfile = read_lockfile_or_empty(repo_root)?; + let pending_queue = PendingQueue::new(&cache.pending_syncs_dir); + let pending_count = pending_queue.read_ready()?.len(); + let failed_pending_count = failed_pending_records(&cache.pending_syncs_dir)?; + let pending_conflicts = lockfile + .entities + .values() + .filter(|entry| entry.pending_conflict_resolution == Some(true)) + .count(); + let config = load_config(repo_root)?.config; + let capability_skipped = capability_skip_count_for_lockfile(&lockfile, &config)?; + let sync_state = entity_sync_state(repo_root, &lockfile)?; + let privacy_findings = doctor_lockfile_privacy_findings(&lockfile); + + let mut findings = Vec::new(); + findings.push(format!("entities: {}", lockfile.entities.len())); + findings.push(format!("entities_in_sync: {}", sync_state.in_sync)); + findings.push(format!("entities_out_of_sync: {}", sync_state.out_of_sync)); + findings.push(format!("pending_conflicts: {pending_conflicts}")); + findings.push(format!("pending_syncs: {pending_count}")); + findings.push(format!("failed_pending_syncs: {failed_pending_count}")); + findings.extend(doctor_pending_failure_findings(&cache.pending_syncs_dir)?); + findings.push(format!("capability_skips: {capability_skipped}")); + findings.push(format!("cache_root: {}", cache.root.display())); + findings.extend(doctor_integrity_findings(repo_root, &cache)?); + findings.extend(doctor_adapter_findings(repo_root, &lockfile, adapters)?); + findings.extend(doctor_hook_findings(repo_root, &cache)?); + findings.extend(doctor_conflict_findings(&cache, &lockfile)?); + findings.extend(privacy_findings.findings); + findings.push(format!("watcher_pid: {}", cache.watcher_pid.display())); + findings.push(format!("watcher_log: {}", cache.watcher_log.display())); + findings.push("network: disabled".to_string()); + + Ok(DoctorReport { + findings, + health: DoctorHealth { + entities_out_of_sync: sync_state.out_of_sync, + pending_conflicts, + pending_syncs: pending_count, + failed_pending_syncs: failed_pending_count, + capability_skips: capability_skipped, + lockfile_privacy_warnings: privacy_findings.warning_count, + }, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LockfilePrivacyFindings { + warning_count: usize, + findings: Vec, +} + +fn doctor_lockfile_privacy_findings(lockfile: &Lockfile) -> LockfilePrivacyFindings { + let mut warnings = Vec::new(); + let mut warning_count = 0; + + for (entity_id, entity) in &lockfile.entities { + if contains_sensitive_term(entity_id.as_str()) { + push_privacy_warning( + &mut warnings, + &mut warning_count, + format!( + "entity id `{}` contains sensitive-looking text", + entity_id.as_str() + ), + ); + } + for (location, path) in &entity.locations { + if path_contains_sensitive_term(path) { + push_privacy_warning( + &mut warnings, + &mut warning_count, + format!( + "location path for `{}` at `{}` contains sensitive-looking text: {}", + entity_id.as_str(), + location.as_str(), + path.display() + ), + ); + } + } + for entry in &entity.lineage { + if path_contains_sensitive_term(&entry.imported_from) { + push_privacy_warning( + &mut warnings, + &mut warning_count, + format!( + "lineage path for `{}` contains sensitive-looking text: {}", + entity_id.as_str(), + entry.imported_from.display() + ), + ); + } + } + for record in &entity.rename_history { + if path_contains_sensitive_term(&record.from) { + push_privacy_warning( + &mut warnings, + &mut warning_count, + format!( + "rename source for `{}` contains sensitive-looking text: {}", + entity_id.as_str(), + record.from.display() + ), + ); + } + if path_contains_sensitive_term(&record.to) { + push_privacy_warning( + &mut warnings, + &mut warning_count, + format!( + "rename target for `{}` contains sensitive-looking text: {}", + entity_id.as_str(), + record.to.display() + ), + ); + } + } + } + + for (entity_id, overrides) in &lockfile.overrides { + for (runtime, override_entry) in overrides { + collect_sensitive_override_keys( + entity_id, + runtime, + &override_entry.0, + &mut warnings, + &mut warning_count, + ); + } + } + + let mut findings = Vec::new(); + if warning_count > 0 { + findings.push(format!("lockfile_privacy_warnings: {warning_count}")); + } + findings.extend(warnings); + if warning_count > DOCTOR_PRIVACY_WARNING_DETAIL_LIMIT { + findings.push(format!( + "lockfile_privacy_warnings_truncated: {} additional warning(s)", + warning_count - DOCTOR_PRIVACY_WARNING_DETAIL_LIMIT + )); + } + + LockfilePrivacyFindings { + warning_count, + findings, + } +} + +fn push_privacy_warning(warnings: &mut Vec, warning_count: &mut usize, detail: String) { + *warning_count += 1; + if warnings.len() < DOCTOR_PRIVACY_WARNING_DETAIL_LIMIT { + warnings.push(format!( + "lockfile_privacy_warning_{warning_count}: {detail}" + )); + } +} + +fn collect_sensitive_override_keys( + entity_id: &EntityId, + runtime: &RuntimeName, + values: &BTreeMap, + warnings: &mut Vec, + warning_count: &mut usize, +) { + for (key, value) in values { + collect_sensitive_json_keys( + entity_id, + runtime, + Some(key), + value, + warnings, + warning_count, + ); + } +} + +fn collect_sensitive_json_keys( + entity_id: &EntityId, + runtime: &RuntimeName, + key: Option<&str>, + value: &Value, + warnings: &mut Vec, + warning_count: &mut usize, +) { + if let Some(key) = key + && contains_sensitive_term(key) + { + push_privacy_warning( + warnings, + warning_count, + format!( + "override key `{key}` for `{}` at `{}` looks sensitive; keep secrets in machine-local config or environment variables", + entity_id.as_str(), + runtime.as_str() + ), + ); + } + + match value { + Value::Object(map) => { + for (child_key, child_value) in map { + collect_sensitive_json_keys( + entity_id, + runtime, + Some(child_key), + child_value, + warnings, + warning_count, + ); + } + } + Value::Array(values) => { + for child_value in values { + collect_sensitive_json_keys( + entity_id, + runtime, + None, + child_value, + warnings, + warning_count, + ); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } +} + +fn path_contains_sensitive_term(path: &Path) -> bool { + path.components() + .any(|component| contains_sensitive_term(&component.as_os_str().to_string_lossy())) +} + +fn contains_sensitive_term(value: &str) -> bool { + let normalized = value.to_ascii_lowercase(); + [ + "access-key", + "access_key", + "apikey", + "api-key", + "api_key", + "auth-token", + "auth_token", + "bearer", + "client-secret", + "client_secret", + "cookie", + "credential", + "jwt", + "oauth", + "passwd", + "password", + "private-key", + "private_key", + "secret", + "session", + "token", + ] + .iter() + .any(|term| normalized.contains(term)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct EntitySyncState { + in_sync: usize, + out_of_sync: usize, +} + +fn failed_pending_records(dir: &Path) -> Result { + let mut count = 0; + match fs::read_dir(dir) { + Ok(entries) => { + for entry in entries { + let entry = entry.map_err(|source| PipelineError::Io { + action: "read directory entry", + path: dir.to_path_buf(), + source, + })?; + if entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with("failed-")) + { + count += 1; + } + } + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => { + return Err(PipelineError::Io { + action: "read directory", + path: dir.to_path_buf(), + source, + }); + } + } + Ok(count) +} + +fn doctor_pending_failure_findings(dir: &Path) -> Result> { + let mut findings = Vec::new(); + match fs::read_dir(dir) { + Ok(entries) => { + for entry in entries { + let entry = entry.map_err(|source| PipelineError::Io { + action: "read directory entry", + path: dir.to_path_buf(), + source, + })?; + let path = entry.path(); + if !entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with("failed-")) + { + continue; + } + let record = read_json::(&path)?; + findings.push(format!( + "pending_failure_{}: path={} attempts={} error={}", + record.pending_id, + path.display(), + record.attempts, + record.last_error.as_deref().unwrap_or("unknown") + )); + } + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => { + return Err(PipelineError::Io { + action: "read directory", + path: dir.to_path_buf(), + source, + }); + } + } + findings.sort(); + Ok(findings) +} + +fn entity_sync_state(repo_root: &Path, lockfile: &Lockfile) -> Result { + let mut state = EntitySyncState::default(); + for entity in lockfile.entities.values() { + let mut out_of_sync = entity.locations.is_empty(); + for (location, path) in &entity.locations { + let Some(expected_hash) = entity.emitted_native_sha256.get(location).or_else(|| { + if location.as_str() == ".ai" { + Some(&entity.canonical_sha256) + } else { + None + } + }) else { + out_of_sync = true; + continue; + }; + let Some(actual_hash) = + entity_location_hash(repo_root, entity.entity_type, location, path)? + else { + out_of_sync = true; + continue; + }; + if &actual_hash != expected_hash { + out_of_sync = true; + } + } + if out_of_sync { + state.out_of_sync += 1; + } else { + state.in_sync += 1; + } + } + Ok(state) +} + +fn doctor_integrity_findings(repo_root: &Path, cache: &CacheLayout) -> Result> { + let current = current_integrity_pin(repo_root)?; + match read_integrity_pin(&cache.integrity_json) { + Ok(pin) => { + let mode = if pin.binary_path.is_absolute() { + "pinned-absolute" + } else { + "path-resolved" + }; + let status = if pin.binary_path == current.binary_path + && pin.binary_sha256 == current.binary_sha256 + { + "match" + } else { + "mismatch" + }; + Ok(vec![ + format!("integrity: {status}"), + format!("integrity_mode: {mode}"), + format!("integrity_pinned_binary: {}", pin.binary_path.display()), + format!("integrity_pinned_sha256: {}", pin.binary_sha256.as_str()), + format!( + "integrity_current_binary: {}", + current.binary_path.display() + ), + format!( + "integrity_current_sha256: {}", + current.binary_sha256.as_str() + ), + format!("integrity_version: {}", pin.binary_version), + ]) + } + Err(StateError::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => { + Ok(vec![ + "integrity: unpinned".to_string(), + format!( + "integrity_current_binary: {}", + current.binary_path.display() + ), + format!( + "integrity_current_sha256: {}", + current.binary_sha256.as_str() + ), + ]) + } + Err(error) => Err(error.into()), + } +} + +fn doctor_adapter_findings( + repo_root: &Path, + lockfile: &Lockfile, + adapters: &dyn AdapterRegistry, +) -> Result> { + let markers = detect_runtime_markers(repo_root, adapters)?; + let known = [ + (runtime_name("claude")?, markers.claude), + (runtime_name("codex")?, markers.codex), + ]; + let mut findings = Vec::new(); + let mut known_runtimes = BTreeSet::new(); + for (runtime, detected) in &known { + known_runtimes.insert(runtime.clone()); + if let Some(adapter) = lockfile.adapters.get(runtime) { + findings.push(format!( + "adapter_{}: detected={} declared=true mode={} protocol={} entities={} hooks={}", + runtime.as_str(), + detected, + adapter_mode_name(adapter.mode), + adapter.protocol_version, + adapter.entities.len(), + adapter.hooks.len() + )); + } else { + findings.push(format!( + "adapter_{}: detected={} declared=false", + runtime.as_str(), + detected + )); + } + } + findings.extend( + lockfile + .adapters + .iter() + .filter(|(runtime, _)| !known_runtimes.contains(*runtime)) + .map(|(runtime, adapter)| { + format!( + "adapter_{}: detected=false declared=true mode={} protocol={} entities={} hooks={}", + runtime.as_str(), + adapter_mode_name(adapter.mode), + adapter.protocol_version, + adapter.entities.len(), + adapter.hooks.len() + ) + }), + ); + for entity_type in [ + EntityType::Instructions, + EntityType::Skill, + EntityType::Subagent, + ] { + let runtimes = lockfile + .adapters + .iter() + .filter(|(_, adapter)| adapter.entities.contains(&entity_type)) + .map(|(runtime, _)| runtime.as_str()) + .collect::>(); + let coverage = if runtimes.is_empty() { + "none".to_string() + } else { + runtimes.join(",") + }; + findings.push(format!( + "adapter_coverage_{}: {coverage}", + entity_type.as_str() + )); + } + if lockfile.adapters.is_empty() { + findings.push("adapters: none".to_string()); + } + Ok(findings) +} + +fn adapter_mode_name(mode: AdapterMode) -> &'static str { + match mode { + AdapterMode::Bundled => "bundled", + } +} + +fn doctor_hook_findings(repo_root: &Path, cache: &CacheLayout) -> Result> { + match read_hook_ownership(&cache.hook_ownership_json) { + Ok(ownership) if ownership.0.is_empty() => Ok(vec!["hooks: none".to_string()]), + Ok(ownership) => Ok(ownership + .0 + .iter() + .map(|(runtime, entry)| { + let overlay = repo_root.join(&entry.overlay_file); + let overlay_exists = overlay.is_file(); + let command_present = if overlay_exists { + fs::read_to_string(&overlay) + .map(|contents| { + contents.contains("agentmesh") + && contents.contains(&format!("{}-hook", runtime.as_str())) + }) + .unwrap_or(false) + } else { + false + }; + let drift = !overlay_exists || entry.entry_paths.is_empty() || !command_present; + format!( + "hook_{}: overlay={} entries={} exists={} command_present={} drift={}", + runtime.as_str(), + entry.overlay_file.display(), + entry.entry_paths.len(), + overlay_exists, + command_present, + drift + ) + }) + .collect()), + Err(StateError::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => { + Ok(vec!["hooks: none".to_string()]) + } + Err(error) => Err(error.into()), + } +} + +pub(super) fn doctor_conflict_findings( + cache: &CacheLayout, + lockfile: &Lockfile, +) -> Result> { + let mut preserved = 0; + match fs::read_dir(&cache.conflicts_dir) { + Ok(entries) => { + for entry in entries { + let entry = entry.map_err(|source| PipelineError::Io { + action: "read directory entry", + path: cache.conflicts_dir.clone(), + source, + })?; + if entry.path().is_dir() { + preserved += 1; + } + } + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => { + return Err(PipelineError::Io { + action: "read directory", + path: cache.conflicts_dir.clone(), + source, + }); + } + } + let pending = lockfile + .entities + .values() + .filter(|entry| entry.pending_conflict_resolution == Some(true)) + .count(); + let mut findings = vec![ + format!("preserved_conflict_entities: {preserved}"), + format!("pending_conflict_entities: {pending}"), + ]; + for (entity_id, entity) in &lockfile.entities { + if entity.pending_conflict_resolution != Some(true) { + continue; + } + let preserved_paths = preserved_conflict_paths(cache, entity_id)?; + let preserved = if preserved_paths.is_empty() { + "none".to_string() + } else { + preserved_paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(",") + }; + findings.push(format!( + "conflict_{}: pending=true preserved={preserved}", + entity_id.as_str() + )); + } + Ok(findings) +} + +fn preserved_conflict_paths(cache: &CacheLayout, entity_id: &EntityId) -> Result> { + let dir = conflict_entity_dir(&cache.conflicts_dir, entity_id); + let mut paths = Vec::new(); + match fs::read_dir(&dir) { + Ok(entries) => { + for entry in entries { + let entry = entry.map_err(|source| PipelineError::Io { + action: "read directory entry", + path: dir.clone(), + source, + })?; + let path = entry.path(); + if path.is_file() { + paths.push(path); + } + } + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => { + return Err(PipelineError::Io { + action: "read directory", + path: dir, + source, + }); + } + } + paths.sort(); + Ok(paths) +} diff --git a/crates/agentmesh-watcher/src/lib.rs b/crates/agentmesh-watcher/src/lib.rs index 697030f..38d505f 100644 --- a/crates/agentmesh-watcher/src/lib.rs +++ b/crates/agentmesh-watcher/src/lib.rs @@ -16,6 +16,9 @@ use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use thiserror::Error; +mod service; +use service::register_service; + const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(500); const DEFAULT_VCS_THROTTLE: Duration = Duration::from_secs(2); const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60); @@ -398,224 +401,6 @@ fn wait_for_background_start(layout: &WatcherLayout, pid: u32) -> Result<()> { } } -fn register_service(repo_root: &Path, opts: &WatchOptions, layout: &WatcherLayout) -> Result<()> { - let executable = env::current_exe().map_err(|source| WatcherError::Io { - action: "resolve current executable", - path: PathBuf::from("."), - source, - })?; - let service_path = service_definition_path(layout)?; - let service_kind = service_kind().to_string(); - let service_name = service_name(layout); - let definition = service_definition_contents(repo_root, &executable, &service_name, opts)?; - if let Some(parent) = service_path.parent() { - fs::create_dir_all(parent).map_err(|source| WatcherError::ServiceRegistration { - path: parent.to_path_buf(), - source, - })?; - } - fs::write(&service_path, definition).map_err(|source| WatcherError::ServiceRegistration { - path: service_path.clone(), - source, - })?; - let mut record = WatcherRecord::new( - 0, - repo_root, - opts, - false, - STATE_SERVICE_REGISTERED, - DRAIN_IDLE, - ); - record.persistent = true; - record.service_file = Some(service_path.clone()); - record.service_kind = Some(service_kind.clone()); - write_record(layout, &record)?; - append_log( - &layout.log_file, - "register-service", - json!({ - "path": service_path, - "kind": service_kind, - "service_name": service_name, - }), - )?; - Ok(()) -} - -#[cfg(target_os = "macos")] -fn service_definition_path(layout: &WatcherLayout) -> Result { - let home = home_dir()?; - let name = layout - .root - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("repo"); - Ok(home - .join("Library/LaunchAgents") - .join(format!("sh.agentmesh.watch.{name}.plist"))) -} - -#[cfg(target_os = "linux")] -fn service_definition_path(layout: &WatcherLayout) -> Result { - let home = home_dir()?; - let name = layout - .root - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("repo"); - Ok(home - .join(".config/systemd/user") - .join(format!("agentmesh-watch-{name}.service"))) -} - -#[cfg(target_os = "windows")] -fn service_definition_path(layout: &WatcherLayout) -> Result { - Ok(layout.root.join("agentmesh-watch-task.xml")) -} - -#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] -fn service_definition_path(layout: &WatcherLayout) -> Result { - Ok(layout.root.join("agentmesh-watch-service.txt")) -} - -#[cfg(target_os = "macos")] -fn service_kind() -> &'static str { - "launchd" -} - -#[cfg(target_os = "linux")] -fn service_kind() -> &'static str { - "systemd" -} - -#[cfg(target_os = "windows")] -fn service_kind() -> &'static str { - "windows-task" -} - -#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] -fn service_kind() -> &'static str { - "service-file" -} - -fn service_name(layout: &WatcherLayout) -> String { - let name = layout - .root - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("repo"); - if cfg!(target_os = "macos") { - format!("sh.agentmesh.watch.{name}") - } else { - format!("agentmesh-watch-{name}") - } -} - -#[cfg(target_os = "macos")] -fn service_definition_contents( - repo_root: &Path, - executable: &Path, - service_name: &str, - _opts: &WatchOptions, -) -> Result { - Ok(format!( - r#" - - - - Label - {} - ProgramArguments - - {} - --cwd - {} - watch - --foreground - --persistent - - RunAtLoad - - WorkingDirectory - {} - - -"#, - escape_xml(service_name), - escape_xml(&executable.display().to_string()), - escape_xml(&repo_root.display().to_string()), - escape_xml(&repo_root.display().to_string()) - )) -} - -#[cfg(target_os = "linux")] -fn service_definition_contents( - repo_root: &Path, - executable: &Path, - _service_name: &str, - _opts: &WatchOptions, -) -> Result { - Ok(format!( - "[Unit]\nDescription=AgentMesh watcher\n\n[Service]\nType=simple\nWorkingDirectory={}\nExecStart={} --cwd {} watch --foreground --persistent\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n", - systemd_escape(&repo_root.display().to_string()), - systemd_escape(&executable.display().to_string()), - systemd_escape(&repo_root.display().to_string()) - )) -} - -#[cfg(target_os = "windows")] -fn service_definition_contents( - repo_root: &Path, - executable: &Path, - service_name: &str, - _opts: &WatchOptions, -) -> Result { - Ok(format!( - r#" - - - AgentMesh watcher for {} - - - true - - - InteractiveTokenLeastPrivilege - - - IgnoreNew - PT1M3 - - - - {} - --cwd "{}" watch --foreground --persistent - {} - - - -"#, - service_name, - executable.display(), - repo_root.display(), - repo_root.display() - )) -} - -#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] -fn service_definition_contents( - repo_root: &Path, - executable: &Path, - _service_name: &str, - _opts: &WatchOptions, -) -> Result { - Ok(format!( - "{} --cwd {} watch --foreground --persistent\n", - executable.display(), - repo_root.display() - )) -} - fn run_foreground( repo_root: &Path, opts: WatchOptions, @@ -1598,9 +1383,10 @@ mod tests { use super::{ DEFAULT_IDLE_TIMEOUT, DRAIN_IDLE, ForegroundLoop, MAX_LOG_BYTES, STATE_BACKGROUND_SPAWNED, STATE_STOPPED, SelfWriteIndex, WatchOptions, WatcherLayout, WatcherRecord, append_log, - contains_vcs_path, idle_timeout, rotated_log_path, service_definition_contents, - service_name, sha256_file_hex, start_with_cache_root, status_with_cache_root, - stop_with_cache_root, write_record, + contains_vcs_path, idle_timeout, rotated_log_path, + service::{service_definition_contents, service_name}, + sha256_file_hex, start_with_cache_root, status_with_cache_root, stop_with_cache_root, + write_record, }; #[test] diff --git a/crates/agentmesh-watcher/src/service.rs b/crates/agentmesh-watcher/src/service.rs new file mode 100644 index 0000000..3051f74 --- /dev/null +++ b/crates/agentmesh-watcher/src/service.rs @@ -0,0 +1,229 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::json; + +use super::*; + +pub(crate) fn register_service( + repo_root: &Path, + opts: &WatchOptions, + layout: &WatcherLayout, +) -> Result<()> { + let executable = env::current_exe().map_err(|source| WatcherError::Io { + action: "resolve current executable", + path: PathBuf::from("."), + source, + })?; + let service_path = service_definition_path(layout)?; + let service_kind = service_kind().to_string(); + let service_name = service_name(layout); + let definition = service_definition_contents(repo_root, &executable, &service_name, opts)?; + if let Some(parent) = service_path.parent() { + fs::create_dir_all(parent).map_err(|source| WatcherError::ServiceRegistration { + path: parent.to_path_buf(), + source, + })?; + } + fs::write(&service_path, definition).map_err(|source| WatcherError::ServiceRegistration { + path: service_path.clone(), + source, + })?; + let mut record = WatcherRecord::new( + 0, + repo_root, + opts, + false, + STATE_SERVICE_REGISTERED, + DRAIN_IDLE, + ); + record.persistent = true; + record.service_file = Some(service_path.clone()); + record.service_kind = Some(service_kind.clone()); + write_record(layout, &record)?; + append_log( + &layout.log_file, + "register-service", + json!({ + "path": service_path, + "kind": service_kind, + "service_name": service_name, + }), + )?; + Ok(()) +} + +#[cfg(target_os = "macos")] +fn service_definition_path(layout: &WatcherLayout) -> Result { + let home = home_dir()?; + let name = layout + .root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("repo"); + Ok(home + .join("Library/LaunchAgents") + .join(format!("sh.agentmesh.watch.{name}.plist"))) +} + +#[cfg(target_os = "linux")] +fn service_definition_path(layout: &WatcherLayout) -> Result { + let home = home_dir()?; + let name = layout + .root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("repo"); + Ok(home + .join(".config/systemd/user") + .join(format!("agentmesh-watch-{name}.service"))) +} + +#[cfg(target_os = "windows")] +fn service_definition_path(layout: &WatcherLayout) -> Result { + Ok(layout.root.join("agentmesh-watch-task.xml")) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn service_definition_path(layout: &WatcherLayout) -> Result { + Ok(layout.root.join("agentmesh-watch-service.txt")) +} + +#[cfg(target_os = "macos")] +fn service_kind() -> &'static str { + "launchd" +} + +#[cfg(target_os = "linux")] +fn service_kind() -> &'static str { + "systemd" +} + +#[cfg(target_os = "windows")] +fn service_kind() -> &'static str { + "windows-task" +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn service_kind() -> &'static str { + "service-file" +} + +pub(crate) fn service_name(layout: &WatcherLayout) -> String { + let name = layout + .root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("repo"); + if cfg!(target_os = "macos") { + format!("sh.agentmesh.watch.{name}") + } else { + format!("agentmesh-watch-{name}") + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn service_definition_contents( + repo_root: &Path, + executable: &Path, + service_name: &str, + _opts: &WatchOptions, +) -> Result { + Ok(format!( + r#" + + + + Label + {} + ProgramArguments + + {} + --cwd + {} + watch + --foreground + --persistent + + RunAtLoad + + WorkingDirectory + {} + + +"#, + escape_xml(service_name), + escape_xml(&executable.display().to_string()), + escape_xml(&repo_root.display().to_string()), + escape_xml(&repo_root.display().to_string()) + )) +} + +#[cfg(target_os = "linux")] +pub(crate) fn service_definition_contents( + repo_root: &Path, + executable: &Path, + _service_name: &str, + _opts: &WatchOptions, +) -> Result { + Ok(format!( + "[Unit]\nDescription=AgentMesh watcher\n\n[Service]\nType=simple\nWorkingDirectory={}\nExecStart={} --cwd {} watch --foreground --persistent\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n", + systemd_escape(&repo_root.display().to_string()), + systemd_escape(&executable.display().to_string()), + systemd_escape(&repo_root.display().to_string()) + )) +} + +#[cfg(target_os = "windows")] +pub(crate) fn service_definition_contents( + repo_root: &Path, + executable: &Path, + service_name: &str, + _opts: &WatchOptions, +) -> Result { + Ok(format!( + r#" + + + AgentMesh watcher for {} + + + true + + + InteractiveTokenLeastPrivilege + + + IgnoreNew + PT1M3 + + + + {} + --cwd "{}" watch --foreground --persistent + {} + + + +"#, + service_name, + executable.display(), + repo_root.display(), + repo_root.display() + )) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +pub(crate) fn service_definition_contents( + repo_root: &Path, + executable: &Path, + _service_name: &str, + _opts: &WatchOptions, +) -> Result { + Ok(format!( + "{} --cwd {} watch --foreground --persistent\n", + executable.display(), + repo_root.display() + )) +} diff --git a/crates/agentmesh/src/hooks.rs b/crates/agentmesh/src/hooks.rs new file mode 100644 index 0000000..4d2476d --- /dev/null +++ b/crates/agentmesh/src/hooks.rs @@ -0,0 +1,608 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use agentmesh_adapter_sdk_rust::Adapter; +use agentmesh_protocol::{InstallHooksRequest, RemoveHooksRequest}; + +use super::*; + +pub(crate) fn print_runtime_install_dry_run(context: &CliContext, runtime: &str) -> Result<()> { + let binary_path = std::env::current_exe().map_err(CliError::from_io)?; + let overlay = match runtime { + "claude" => ".claude/settings.local.json", + "codex" => ".codex/hooks.json", + other => { + return Err(CliError::new( + format!("unknown bundled runtime: {other}"), + AgentmeshExitCode::Usage, + )); + } + }; + if !context.silent { + println!( + "{} Would install {runtime} sync hook:", + context.paint(OutputStyle::Info, "→") + ); + println!(" Overlay: {}", context.repo_root.join(overlay).display()); + println!( + " Command: {} sync --trigger={runtime}-hook --silent", + binary_path.display() + ); + } + Ok(()) +} + +pub(crate) fn print_git_pre_commit_dry_run(context: &CliContext) -> Result<()> { + let hook = context.repo_root.join(".git/hooks/pre-commit"); + if !context.silent { + println!( + "{} Would install git pre-commit hook at {}", + context.paint(OutputStyle::Info, "→"), + hook.display() + ); + println!(" Command: agentmesh sync --check --trigger=git-pre-commit --silent"); + } + Ok(()) +} + +pub(crate) fn print_upgrade_dry_run(context: &CliContext) -> Result<()> { + let binary_path = std::env::current_exe().map_err(CliError::from_io)?; + if !context.silent { + println!( + "{} Would repin integrity to {}", + context.paint(OutputStyle::Info, "→"), + binary_path.display() + ); + println!( + "{} Would rewrite recorded runtime hook entries to the current binary path", + context.paint(OutputStyle::Info, "→") + ); + } + Ok(()) +} + +pub(crate) fn install_detected_runtime_hooks(context: &CliContext) -> Result<()> { + let claude = agentmesh_adapter_claude::ClaudeAdapter + .detect(&context.repo_root) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter))?; + if claude.present { + install_runtime_hook(context, "claude")?; + } + + let codex = agentmesh_adapter_codex::CodexAdapter + .detect(&context.repo_root) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter))?; + if codex.present { + install_runtime_hook(context, "codex")?; + } + + Ok(()) +} + +pub(crate) fn install_git_pre_commit_hook(context: &CliContext, force: bool) -> Result<()> { + let hook = context.repo_root.join(GIT_PRE_COMMIT_HOOK); + let saved = context.repo_root.join(GIT_PRE_COMMIT_SAVED); + let Some(parent) = hook.parent() else { + return Err(CliError::new( + "cannot resolve .git/hooks directory", + AgentmeshExitCode::Io, + )); + }; + if !parent.is_dir() { + return Err(CliError::new( + "git hooks directory not found; run from a git worktree", + AgentmeshExitCode::Usage, + )); + } + + let binary_path = std::env::current_exe().map_err(CliError::from_io)?; + let existing = match fs::read_to_string(&hook) { + Ok(existing) => Some(existing), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(CliError::from_io(error)), + }; + let existing_mode = if existing.is_some() { + file_mode(&hook)? + } else { + None + }; + let existing_is_agentmesh = existing + .as_deref() + .is_some_and(|content| content.contains(GIT_PRE_COMMIT_MARKER)); + let chain_original = if let Some(content) = existing.as_deref() { + if existing_is_agentmesh { + saved.exists() + } else { + if let Some(framework) = detect_pre_commit_framework(content) { + if !force { + return Err(CliError::new( + format!( + "detected {framework} managing pre-commit; add AgentMesh to that framework or rerun with --force" + ), + AgentmeshExitCode::Usage, + )); + } + } + if saved.exists() { + return Err(CliError::new( + format!( + "{} already exists; remove it or run uninstall before reinstalling", + saved.display() + ), + AgentmeshExitCode::Usage, + )); + } + write_text_atomic_with_mode(&saved, content, existing_mode)?; + true + } + } else { + false + }; + + write_text_atomic_with_mode( + &hook, + &git_pre_commit_body(&binary_path, chain_original), + hook_wrapper_mode(existing_mode), + )?; + record_git_pre_commit_ownership(context, chain_original)?; + + if !context.silent { + println!( + "{} Installed git pre-commit sync check at {}", + check(context, true), + hook.display() + ); + } + Ok(()) +} + +fn detect_pre_commit_framework(content: &str) -> Option<&'static str> { + let body = content + .lines() + .filter(|line| !line.starts_with("#!")) + .collect::>() + .join("\n"); + if body.contains("# File generated by pre-commit:") + || body.contains("pre-commit run --hook-stage") + { + Some("pre-commit") + } else if body.contains("husky.sh") || body.contains("_husky.sh") { + Some("husky") + } else if body.contains("lefthook run pre-commit") || body.contains("lefthook install") { + Some("lefthook") + } else { + None + } +} + +fn git_pre_commit_body(binary_path: &Path, chain_original: bool) -> String { + let original = if chain_original { + format!( + "\nif [ -x {} ]; then\n {} \"$@\" || exit $?\nfi\n", + shell_quote_path(Path::new(GIT_PRE_COMMIT_SAVED)), + shell_quote_path(Path::new(GIT_PRE_COMMIT_SAVED)) + ) + } else { + String::new() + }; + format!( + "#!/usr/bin/env bash\n# {GIT_PRE_COMMIT_MARKER} - do not edit directly\n\nset -e\n{original}\n{} sync --check --trigger=git-pre-commit --silent\n", + shell_quote_path(binary_path) + ) +} + +pub(crate) fn install_runtime_hook(context: &CliContext, runtime: &str) -> Result<()> { + let binary_path = std::env::current_exe().map_err(CliError::from_io)?; + let response = match runtime { + "claude" => agentmesh_adapter_claude::ClaudeAdapter.install_hooks(InstallHooksRequest { + runtime_dir: context.repo_root.join(".claude"), + agentmesh_binary_path: binary_path, + matcher_extra: None, + }), + "codex" => agentmesh_adapter_codex::CodexAdapter.install_hooks(InstallHooksRequest { + runtime_dir: context.repo_root.join(".codex"), + agentmesh_binary_path: binary_path, + matcher_extra: None, + }), + other => { + return Err(CliError::new( + format!("unknown bundled runtime: {other}"), + AgentmeshExitCode::Usage, + )); + } + } + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter))?; + + record_hook_ownership(context, runtime, &response.hooks_installed)?; + + if !context.silent { + println!( + "{} Installing {runtime} sync hook:", + context.paint(OutputStyle::Info, "→") + ); + for hook in &response.hooks_installed { + println!( + " {} Wrote {} [{}]", + check(context, true), + hook.overlay_file.display(), + hook.entry_path + ); + } + println!( + " {} Recorded ownership in machine-local cache", + check(context, true) + ); + if runtime == "codex" { + println!( + " {} Recommend adding .codex/hooks.json to .gitignore", + context.paint(OutputStyle::Info, "↗") + ); + print_codex_trust_prompt(context, &response.hooks_installed); + } + } + + Ok(()) +} + +pub(crate) fn rewrite_installed_runtime_hooks(context: &CliContext) -> Result<()> { + let layout = cache_layout(&context.repo_root)?; + let ownership = match agentmesh_core::state::read_hook_ownership(&layout.hook_ownership_json) { + Ok(ownership) => ownership, + Err(agentmesh_core::state::StateError::Io { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => + { + return Ok(()); + } + Err(error) => return Err(CliError::new(error.to_string(), AgentmeshExitCode::Io)), + }; + + for runtime in ownership.0.keys() { + match runtime.as_str() { + "claude" | "codex" => { + remove_runtime_hook_entries(context, runtime.as_str())?; + install_runtime_hook(context, runtime.as_str())?; + } + GIT_PRE_COMMIT_RUNTIME => rewrite_git_pre_commit_hook(context)?, + _ => {} + } + } + + Ok(()) +} + +fn rewrite_git_pre_commit_hook(context: &CliContext) -> Result<()> { + let hook = context.repo_root.join(GIT_PRE_COMMIT_HOOK); + let content = match fs::read_to_string(&hook) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(CliError::from_io(error)), + }; + if !content.contains(GIT_PRE_COMMIT_MARKER) { + return Ok(()); + } + let binary_path = std::env::current_exe().map_err(CliError::from_io)?; + let saved = context.repo_root.join(GIT_PRE_COMMIT_SAVED); + let existing_mode = file_mode(&hook)?; + write_text_atomic_with_mode( + &hook, + &git_pre_commit_body(&binary_path, saved.exists()), + hook_wrapper_mode(existing_mode), + ) +} + +fn print_codex_trust_prompt(context: &CliContext, hooks: &[agentmesh_protocol::InstalledHook]) { + if let Some(hook) = hooks.first() { + println!(); + println!( + "{} Codex requires you to review and trust new command hooks before they run.", + context.paint(OutputStyle::Warning, "⚠") + ); + println!(" What to do:"); + println!(" 1. Open Codex in this repository."); + println!( + " 2. Run any Codex action that uses a tool, such as a file read or shell command." + ); + println!(" 3. When Codex shows the hook trust prompt, approve this command:"); + println!(); + println!(" {}", hook.command); + println!(); + println!(" This is a one-time Codex security approval. Until approved, AgentMesh still"); + println!(" syncs via the watcher, Claude hooks, and manual `agentmesh sync`, but Codex"); + println!(" will not run its own hook."); + } +} + +fn record_hook_ownership( + context: &CliContext, + runtime: &str, + hooks: &[agentmesh_protocol::InstalledHook], +) -> Result<()> { + if hooks.is_empty() { + return Ok(()); + } + let runtime_name = agentmesh_core::RuntimeName::new(runtime) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Usage))?; + let layout = cache_layout(&context.repo_root)?; + layout + .ensure_dirs() + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io))?; + let mut ownership = if layout.hook_ownership_json.exists() { + agentmesh_core::state::read_hook_ownership(&layout.hook_ownership_json) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io))? + } else { + agentmesh_core::state::HookOwnership::default() + }; + + let overlay_file = hooks[0].overlay_file.clone(); + let entry_paths = hooks.iter().map(|hook| hook.entry_path.clone()).collect(); + ownership.0.insert( + runtime_name, + agentmesh_core::state::HookOwnershipEntry { + overlay_file, + entry_paths, + installed_at: timestamp_string(), + installer_version: agentmesh_core::VERSION.to_string(), + }, + ); + agentmesh_core::state::write_hook_ownership(&layout.hook_ownership_json, &ownership) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io)) +} + +fn record_git_pre_commit_ownership(context: &CliContext, saved_original: bool) -> Result<()> { + let runtime_name = agentmesh_core::RuntimeName::new(GIT_PRE_COMMIT_RUNTIME) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Usage))?; + let layout = cache_layout(&context.repo_root)?; + layout + .ensure_dirs() + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io))?; + let mut ownership = if layout.hook_ownership_json.exists() { + agentmesh_core::state::read_hook_ownership(&layout.hook_ownership_json) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io))? + } else { + agentmesh_core::state::HookOwnership::default() + }; + + let mut entry_paths = vec!["agentmesh-wrapper".to_string()]; + if saved_original { + entry_paths.push(GIT_PRE_COMMIT_SAVED.to_string()); + } + ownership.0.insert( + runtime_name, + agentmesh_core::state::HookOwnershipEntry { + overlay_file: PathBuf::from(GIT_PRE_COMMIT_HOOK), + entry_paths, + installed_at: timestamp_string(), + installer_version: agentmesh_core::VERSION.to_string(), + }, + ); + agentmesh_core::state::write_hook_ownership(&layout.hook_ownership_json, &ownership) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io)) +} + +pub(crate) fn uninstall_runtime_hooks(context: &CliContext, dry_run: bool) -> Result<()> { + let layout = cache_layout(&context.repo_root)?; + if !layout.hook_ownership_json.exists() { + if !context.silent { + println!( + "{} hook-ownership.json missing. Cannot determine which entries to remove.", + context.paint(OutputStyle::Warning, "⚠") + ); + } + return Ok(()); + } + + let ownership = agentmesh_core::state::read_hook_ownership(&layout.hook_ownership_json) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io))?; + if !context.silent { + println!( + "{} Removing AgentMesh-owned entries on this machine:", + context.paint(OutputStyle::Info, "→") + ); + } + + for (runtime, entry) in ownership.0 { + if runtime.as_str() == GIT_PRE_COMMIT_RUNTIME { + uninstall_git_pre_commit_hook(context, &entry, dry_run)?; + continue; + } + if dry_run { + if !context.silent { + println!( + " {} Would remove {} hook(s) from {}", + context.paint(OutputStyle::Info, "→"), + entry.entry_paths.len(), + entry.overlay_file.display() + ); + } + continue; + } + + let response = + remove_runtime_hook_entries_with_paths(context, runtime.as_str(), entry.entry_paths)?; + + if !context.silent { + if response.ok { + println!( + " {} Removed {} hook(s) from {}", + check(context, true), + response.removed_count, + entry.overlay_file.display() + ); + } else if let Some(error) = response.error { + println!( + " {} {}: {error}", + context.paint(OutputStyle::Warning, "⚠"), + runtime.as_str() + ); + } + } + } + + Ok(()) +} + +fn uninstall_git_pre_commit_hook( + context: &CliContext, + entry: &agentmesh_core::state::HookOwnershipEntry, + dry_run: bool, +) -> Result<()> { + let hook = context.repo_root.join(&entry.overlay_file); + let saved = context.repo_root.join(GIT_PRE_COMMIT_SAVED); + if dry_run { + if !context.silent { + let action = if saved.exists() { "restore" } else { "remove" }; + println!( + " {} Would {action} git pre-commit hook at {}", + context.paint(OutputStyle::Info, "→"), + hook.display() + ); + } + return Ok(()); + } + + if saved.exists() { + fs::rename(&saved, &hook).map_err(CliError::from_io)?; + if !context.silent { + println!( + " {} Restored original git pre-commit hook", + check(context, true) + ); + } + return Ok(()); + } + + match fs::read_to_string(&hook) { + Ok(content) if content.contains(GIT_PRE_COMMIT_MARKER) => { + fs::remove_file(&hook).map_err(CliError::from_io)?; + if !context.silent { + println!( + " {} Removed git pre-commit hook at {}", + check(context, true), + hook.display() + ); + } + } + Ok(_) => { + if !context.silent { + println!( + " {} Git pre-commit hook changed after install; leaving it untouched", + context.paint(OutputStyle::Warning, "⚠") + ); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(CliError::from_io(error)), + } + Ok(()) +} + +fn remove_runtime_hook_entries(context: &CliContext, runtime: &str) -> Result<()> { + let layout = cache_layout(&context.repo_root)?; + let ownership = match agentmesh_core::state::read_hook_ownership(&layout.hook_ownership_json) { + Ok(ownership) => ownership, + Err(agentmesh_core::state::StateError::Io { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => + { + return Ok(()); + } + Err(error) => return Err(CliError::new(error.to_string(), AgentmeshExitCode::Io)), + }; + let runtime_name = agentmesh_core::RuntimeName::new(runtime.to_string()) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Usage))?; + let Some(entry) = ownership.0.get(&runtime_name) else { + return Ok(()); + }; + remove_runtime_hook_entries_with_paths(context, runtime, entry.entry_paths.clone()).map(|_| ()) +} + +fn remove_runtime_hook_entries_with_paths( + context: &CliContext, + runtime: &str, + entry_paths: Vec, +) -> Result { + match runtime { + "claude" => agentmesh_adapter_claude::ClaudeAdapter.remove_hooks(RemoveHooksRequest { + runtime_dir: context.repo_root.join(".claude"), + entry_paths, + }), + "codex" => agentmesh_adapter_codex::CodexAdapter.remove_hooks(RemoveHooksRequest { + runtime_dir: context.repo_root.join(".codex"), + entry_paths, + }), + _ => Ok(agentmesh_protocol::RemoveHooksResponse { + ok: true, + removed_count: 0, + error: None, + }), + } + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter)) +} + +fn shell_quote_path(path: &Path) -> String { + let value = path.to_string_lossy(); + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn write_text_atomic_with_mode(path: &Path, content: &str, mode: Option) -> Result<()> { + let Some(parent) = path.parent() else { + return Err(CliError::new( + format!("cannot resolve parent directory for {}", path.display()), + AgentmeshExitCode::Io, + )); + }; + fs::create_dir_all(parent).map_err(CliError::from_io)?; + let temp = parent.join(format!(".agentmesh-{}.tmp", std::process::id())); + fs::write(&temp, content).map_err(CliError::from_io)?; + set_file_mode(&temp, mode)?; + fs::rename(&temp, path).map_err(CliError::from_io) +} + +fn file_mode(path: &Path) -> Result> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let metadata = fs::metadata(path).map_err(CliError::from_io)?; + Ok(Some(metadata.permissions().mode() & 0o777)) + } + + #[cfg(not(unix))] + { + let _ = path; + Ok(None) + } +} + +fn hook_wrapper_mode(existing_mode: Option) -> Option { + #[cfg(unix)] + { + Some(existing_mode.unwrap_or(0o600) | 0o100) + } + + #[cfg(not(unix))] + { + let _ = existing_mode; + None + } +} + +fn set_file_mode(path: &Path, mode: Option) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + if let Some(mode) = mode { + let mut permissions = fs::metadata(path).map_err(CliError::from_io)?.permissions(); + permissions.set_mode(mode); + fs::set_permissions(path, permissions).map_err(CliError::from_io)?; + } + } + + #[cfg(not(unix))] + { + let _ = path; + let _ = mode; + } + + Ok(()) +} diff --git a/crates/agentmesh/src/inspect.rs b/crates/agentmesh/src/inspect.rs new file mode 100644 index 0000000..d69064b --- /dev/null +++ b/crates/agentmesh/src/inspect.rs @@ -0,0 +1,925 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use agentmesh_adapter_sdk_rust::Adapter; +use agentmesh_protocol::ImportRequest; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use super::*; + +#[derive(Debug)] +pub(crate) struct RepoSnapshot { + pub(crate) repo_root: PathBuf, + pub(crate) repo_name: String, + pub(crate) lockfile: LockfileSnapshot, + pub(crate) integrity: IntegritySnapshot, + pub(crate) hook_ownership: HookOwnershipSnapshot, + pub(crate) watcher: WatcherSnapshot, + pub(crate) pending_syncs: usize, + pub(crate) runtimes: Vec, + pub(crate) unknown_runtimes: Vec, + pub(crate) core_findings: Vec, + pub(crate) core_health: Option, +} + +#[derive(Debug)] +pub(crate) struct LockfileSnapshot { + pub(crate) status: String, + pub(crate) schema: Option, + pub(crate) entities: usize, + pub(crate) pending_conflicts: usize, + pub(crate) pending_conflict_ids: Vec, +} + +#[derive(Debug)] +pub(crate) struct IntegritySnapshot { + pub(crate) status: String, + pub(crate) cache_root: PathBuf, + pub(crate) pinned_path: Option, + pub(crate) pinned_sha256: Option, + pub(crate) running_path: Option, + pub(crate) running_sha256: Option, + pub(crate) matches_running_binary: Option, +} + +#[derive(Debug)] +pub(crate) struct HookOwnershipSnapshot { + pub(crate) status: String, + pub(crate) path: PathBuf, + pub(crate) entries: Vec, + pub(crate) issues: Vec, +} + +#[derive(Debug)] +pub(crate) struct HookOwnershipRuntimeSnapshot { + pub(crate) runtime: String, + pub(crate) overlay_file: PathBuf, + pub(crate) entry_paths: Vec, + pub(crate) installed_at: String, + pub(crate) installer_version: String, + pub(crate) hook_present: bool, +} + +#[derive(Debug)] +pub(crate) struct WatcherSnapshot { + pub(crate) status: String, + pub(crate) running: bool, + pub(crate) drain_status: String, + pub(crate) log_file: Option, +} + +#[derive(Debug)] +pub(crate) struct RuntimeSnapshot { + pub(crate) name: &'static str, + pub(crate) present: bool, + pub(crate) evidence: Vec, + pub(crate) entities: Vec, + pub(crate) import_error: Option, + pub(crate) hook_overlay: PathBuf, + pub(crate) hook_installed: bool, + pub(crate) hook_note: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ReviewedDiffState { + pub(crate) repo_root: PathBuf, + pub(crate) created_at: String, + pub(crate) summary: ReviewedDiffSummary, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ReviewedDiffSummary { + pub(crate) changed: bool, + pub(crate) entities_changed: usize, + pub(crate) pending_conflicts: usize, + pub(crate) capability_skipped: usize, +} + +impl From<&agentmesh_core::SyncSummary> for ReviewedDiffSummary { + fn from(summary: &agentmesh_core::SyncSummary) -> Self { + Self { + changed: summary.changed, + entities_changed: summary.entities_changed, + pending_conflicts: summary.pending_conflicts, + capability_skipped: summary.capability_skipped, + } + } +} + +pub(crate) fn inspect_repo(context: &CliContext) -> Result { + inspect_repo_with_options( + context, + InspectOptions { + import_entities: true, + include_core_findings: true, + include_unknown_runtimes: true, + }, + ) +} + +pub(crate) fn inspect_status_repo(context: &CliContext) -> Result { + inspect_repo_with_options( + context, + InspectOptions { + import_entities: false, + include_core_findings: false, + include_unknown_runtimes: false, + }, + ) +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct InspectOptions { + pub(crate) import_entities: bool, + pub(crate) include_core_findings: bool, + pub(crate) include_unknown_runtimes: bool, +} + +fn inspect_repo_with_options( + context: &CliContext, + options: InspectOptions, +) -> Result { + context.touch(); + let repo_name = context + .repo_root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("repo") + .to_string(); + let cache = cache_layout(&context.repo_root)?; + let runtimes = vec![ + inspect_claude(context, options.import_entities)?, + inspect_codex(context, options.import_entities)?, + ]; + let hook_ownership = inspect_hook_ownership(context, &cache, &runtimes)?; + let (core_findings, core_health) = if options.include_core_findings { + let report = agentmesh_core::doctor(&context.repo_root).map_err(map_core_error)?; + (report.findings, Some(report.health)) + } else { + (Vec::new(), None) + }; + let unknown_runtimes = if options.include_unknown_runtimes { + inspect_unknown_runtime_dirs(&context.repo_root)? + } else { + Vec::new() + }; + + Ok(RepoSnapshot { + repo_root: context.repo_root.clone(), + repo_name, + lockfile: inspect_lockfile(&context.repo_root), + integrity: inspect_integrity(&cache), + hook_ownership, + watcher: inspect_watcher(&context.repo_root), + pending_syncs: inspect_pending_syncs(&cache)?, + runtimes, + unknown_runtimes, + core_findings, + core_health, + }) +} + +fn inspect_pending_syncs(cache: &agentmesh_core::state::CacheLayout) -> Result { + agentmesh_core::pending_queue::PendingQueue::new(&cache.pending_syncs_dir) + .read_ready() + .map(|records| records.len()) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io)) +} + +pub(crate) fn inspect_unknown_runtime_dirs(repo_root: &Path) -> Result> { + let mut unknown = Vec::new(); + let entries = match fs::read_dir(repo_root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(unknown), + Err(error) => return Err(CliError::from_io(error)), + }; + for entry in entries { + let entry = entry.map_err(CliError::from_io)?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !name.starts_with('.') || matches!(name, ".ai" | ".claude" | ".codex" | ".git") { + continue; + } + if path.join("skills").is_dir() + || path.join("agents").is_dir() + || path.join("rules").is_dir() + || path.join("hooks.json").is_file() + { + unknown.push(PathBuf::from(name)); + } + } + unknown.sort(); + Ok(unknown) +} + +fn inspect_lockfile(repo_root: &Path) -> LockfileSnapshot { + match agentmesh_core::lockfile::read_lockfile(repo_root) { + Ok(lockfile) => { + let pending_conflict_ids = lockfile + .entities + .iter() + .filter(|(_, entity)| entity.pending_conflict_resolution == Some(true)) + .map(|(entity_id, _)| entity_id.as_str().to_string()) + .collect::>(); + LockfileSnapshot { + status: "present".to_string(), + schema: Some(lockfile.schema), + pending_conflicts: pending_conflict_ids.len(), + pending_conflict_ids, + entities: lockfile.entities.len(), + } + } + Err(error) => LockfileSnapshot { + status: format!("not ready ({error})"), + schema: None, + entities: 0, + pending_conflicts: 0, + pending_conflict_ids: Vec::new(), + }, + } +} + +fn inspect_integrity(cache: &agentmesh_core::state::CacheLayout) -> IntegritySnapshot { + let running = std::env::current_exe().ok().and_then(|path| { + agentmesh_core::state::sha256_file(&path) + .ok() + .map(|hash| (path, hash)) + }); + + match agentmesh_core::state::read_integrity_pin(&cache.integrity_json) { + Ok(pin) => { + let matches_running_binary = running + .as_ref() + .map(|(path, hash)| path == &pin.binary_path && hash == &pin.binary_sha256); + let status = match matches_running_binary { + Some(true) => "pinned".to_string(), + Some(false) => "mismatch".to_string(), + None => "unknown (could not hash running binary)".to_string(), + }; + let (running_path, running_sha256) = running + .map(|(path, hash)| (Some(path), Some(hash.to_string()))) + .unwrap_or((None, None)); + IntegritySnapshot { + status, + cache_root: cache.root.clone(), + pinned_path: Some(pin.binary_path), + pinned_sha256: Some(pin.binary_sha256.to_string()), + running_path, + running_sha256, + matches_running_binary, + } + } + Err(_) => IntegritySnapshot { + status: "not pinned".to_string(), + cache_root: cache.root.clone(), + pinned_path: None, + pinned_sha256: None, + running_path: running.as_ref().map(|(path, _)| path.clone()), + running_sha256: running.map(|(_, hash)| hash.to_string()), + matches_running_binary: None, + }, + } +} + +pub(crate) fn snapshot_exit_code(snapshot: &RepoSnapshot) -> AgentmeshExitCode { + if integrity_exit_code(snapshot) == AgentmeshExitCode::Integrity + || !snapshot.hook_ownership.issues.is_empty() + { + AgentmeshExitCode::Integrity + } else if snapshot.lockfile.pending_conflicts > 0 + || snapshot.pending_syncs > 0 + || snapshot.core_health.as_ref().is_some_and(|health| { + health.entities_out_of_sync > 0 + || health.failed_pending_syncs > 0 + || health.capability_skips > 0 + || health.pending_conflicts > 0 + || health.pending_syncs > 0 + || health.lockfile_privacy_warnings > 0 + }) + { + AgentmeshExitCode::Drift + } else { + AgentmeshExitCode::Success + } +} + +pub(crate) fn integrity_exit_code(snapshot: &RepoSnapshot) -> AgentmeshExitCode { + if snapshot.integrity.matches_running_binary == Some(false) { + AgentmeshExitCode::Integrity + } else { + AgentmeshExitCode::Success + } +} + +fn inspect_hook_ownership( + context: &CliContext, + cache: &agentmesh_core::state::CacheLayout, + runtimes: &[RuntimeSnapshot], +) -> Result { + let path = cache.hook_ownership_json.clone(); + let ownership = match agentmesh_core::state::read_hook_ownership(&path) { + Ok(ownership) => ownership, + Err(agentmesh_core::state::StateError::Io { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => + { + let issues = runtimes + .iter() + .filter(|runtime| runtime.hook_installed) + .map(|runtime| { + format!( + "{} hook is installed but hook ownership is not recorded", + runtime.name + ) + }) + .collect::>(); + let status = if issues.is_empty() { + "not recorded".to_string() + } else { + "mismatch".to_string() + }; + return Ok(HookOwnershipSnapshot { + status, + path, + entries: Vec::new(), + issues, + }); + } + Err(error) => return Err(CliError::new(error.to_string(), AgentmeshExitCode::Io)), + }; + + let mut entries = Vec::new(); + let mut issues = Vec::new(); + for (runtime, entry) in &ownership.0 { + let overlay_path = context.repo_root.join(&entry.overlay_file); + let hook_present = if runtime.as_str() == GIT_PRE_COMMIT_RUNTIME { + fs::read_to_string(&overlay_path) + .map(|content| { + content.contains(GIT_PRE_COMMIT_MARKER) + && content.contains("--trigger=git-pre-commit") + }) + .unwrap_or(false) + } else { + let trigger = format!("{}-hook", runtime.as_str()); + fs::read_to_string(&overlay_path) + .map(|content| content.contains(&trigger)) + .unwrap_or(false) + }; + if !hook_present { + issues.push(format!( + "{} ownership is recorded but no matching hook was found in {}", + runtime.as_str(), + entry.overlay_file.display() + )); + } + entries.push(HookOwnershipRuntimeSnapshot { + runtime: runtime.as_str().to_string(), + overlay_file: entry.overlay_file.clone(), + entry_paths: entry.entry_paths.clone(), + installed_at: entry.installed_at.clone(), + installer_version: entry.installer_version.clone(), + hook_present, + }); + } + + for runtime in runtimes.iter().filter(|runtime| runtime.hook_installed) { + let owned = entries.iter().any(|entry| entry.runtime == runtime.name); + if !owned { + issues.push(format!( + "{} hook is installed but hook ownership has no entry", + runtime.name + )); + } + } + + let status = if issues.is_empty() { "ok" } else { "mismatch" }.to_string(); + Ok(HookOwnershipSnapshot { + status, + path, + entries, + issues, + }) +} + +fn reviewed_diff_path(cache: &agentmesh_core::state::CacheLayout) -> PathBuf { + cache.root.join("reviewed-diff.json") +} + +pub(crate) fn write_reviewed_diff_state( + context: &CliContext, + summary: &agentmesh_core::SyncSummary, +) -> Result> { + let cache = cache_layout(&context.repo_root)?; + let path = reviewed_diff_path(&cache); + if !summary.changed { + match fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(CliError::from_io(error)), + } + return Ok(None); + } + + cache + .ensure_dirs() + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io))?; + let state = ReviewedDiffState { + repo_root: context.repo_root.clone(), + created_at: timestamp_string(), + summary: ReviewedDiffSummary::from(summary), + }; + let bytes = serde_json::to_vec_pretty(&state) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter))?; + fs::write(&path, bytes).map_err(CliError::from_io)?; + Ok(Some(path)) +} + +pub(crate) fn read_reviewed_diff_state( + context: &CliContext, +) -> Result<(PathBuf, ReviewedDiffState)> { + let cache = cache_layout(&context.repo_root)?; + let path = reviewed_diff_path(&cache); + let bytes = fs::read(&path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + CliError::new( + "apply requires a reviewed diff; run `agentmesh diff` first", + AgentmeshExitCode::Cancelled, + ) + } else { + CliError::from_io(error) + } + })?; + let state = serde_json::from_slice(&bytes) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io))?; + Ok((path, state)) +} + +pub(crate) fn clear_reviewed_diff_state(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(CliError::from_io(error)), + } +} + +fn inspect_watcher(repo_root: &Path) -> WatcherSnapshot { + match agentmesh_watcher::status(repo_root) { + Ok(status) => WatcherSnapshot { + status: status.state, + running: status.running, + drain_status: status.drain_status, + log_file: Some(status.log_file), + }, + Err(error) => WatcherSnapshot { + status: format!("unavailable ({error})"), + running: false, + drain_status: "unknown".to_string(), + log_file: None, + }, + } +} + +fn inspect_claude(context: &CliContext, import_entities: bool) -> Result { + inspect_runtime( + context, + "claude", + ".claude", + ".claude/settings.local.json", + "claude-hook", + import_entities, + agentmesh_adapter_claude::ClaudeAdapter, + ) +} + +fn inspect_codex(context: &CliContext, import_entities: bool) -> Result { + let mut runtime = inspect_runtime( + context, + "codex", + ".codex", + ".codex/hooks.json", + "codex-hook", + import_entities, + agentmesh_adapter_codex::CodexAdapter, + )?; + if runtime.hook_installed { + runtime.hook_note = Some( + "Codex requires one-time trust approval before this command hook runs".to_string(), + ); + } + Ok(runtime) +} + +fn inspect_runtime( + context: &CliContext, + name: &'static str, + runtime_dir_name: &str, + overlay: &str, + hook_trigger: &str, + import_entities: bool, + adapter: A, +) -> Result +where + A: Adapter, +{ + let detected = adapter + .detect(&context.repo_root) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter))?; + let runtime_dir = context.repo_root.join(runtime_dir_name); + let mut entities = Vec::new(); + let mut import_error = None; + + if detected.present && import_entities { + match adapter.import(ImportRequest { + canonical_dir: context.repo_root.join(".ai"), + runtime_dir, + filter: None, + }) { + Ok(imported) => { + entities = imported + .entities + .into_iter() + .map(|entity| entity.id) + .collect(); + } + Err(error) => { + import_error = Some(error.to_string()); + } + } + } + + let overlay_path = PathBuf::from(overlay); + let hook_installed = fs::read_to_string(context.repo_root.join(&overlay_path)) + .map(|content| content.contains(hook_trigger)) + .unwrap_or(false); + + Ok(RuntimeSnapshot { + name, + present: detected.present, + evidence: detected.files, + entities, + import_error, + hook_overlay: overlay_path, + hook_installed, + hook_note: None, + }) +} + +pub(crate) fn status_json(snapshot: &RepoSnapshot) -> Result { + serde_json::to_string_pretty(&json!({ + "repo": snapshot.repo_name, + "repo_root": snapshot.repo_root, + "lockfile": { + "status": snapshot.lockfile.status, + "schema": snapshot.lockfile.schema, + "entities": snapshot.lockfile.entities, + "pending_conflicts": snapshot.lockfile.pending_conflicts, + "pending_conflict_ids": snapshot.lockfile.pending_conflict_ids, + }, + "integrity": { + "status": snapshot.integrity.status, + "pinned_path": snapshot.integrity.pinned_path, + "pinned_sha256": snapshot.integrity.pinned_sha256, + "running_path": snapshot.integrity.running_path, + "running_sha256": snapshot.integrity.running_sha256, + "matches_running_binary": snapshot.integrity.matches_running_binary, + }, + "hook_ownership": hook_ownership_json(&snapshot.hook_ownership), + "watcher": { + "status": snapshot.watcher.status, + "running": snapshot.watcher.running, + "drain_status": snapshot.watcher.drain_status, + "log_file": snapshot.watcher.log_file, + }, + "pending_syncs": snapshot.pending_syncs, + "unknown_runtimes": snapshot.unknown_runtimes, + "core_findings": snapshot.core_findings, + "core_health": core_health_json(snapshot.core_health.as_ref()), + "runtimes": snapshot.runtimes.iter().map(runtime_json).collect::>(), + })) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter)) +} + +pub(crate) fn scan_json(snapshot: &RepoSnapshot) -> Result { + serde_json::to_string_pretty(&json!({ + "runtimes": snapshot.runtimes.iter().map(runtime_json).collect::>(), + "entity_count": snapshot.runtimes.iter().map(|runtime| runtime.entities.len()).sum::(), + })) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter)) +} + +pub(crate) fn doctor_json(snapshot: &RepoSnapshot) -> Result { + serde_json::to_string_pretty(&json!({ + "version": agentmesh_core::VERSION, + "repo_root": snapshot.repo_root, + "lockfile": { + "status": snapshot.lockfile.status, + "schema": snapshot.lockfile.schema, + "entities": snapshot.lockfile.entities, + "pending_conflicts": snapshot.lockfile.pending_conflicts, + "pending_conflict_ids": snapshot.lockfile.pending_conflict_ids, + }, + "integrity": { + "status": snapshot.integrity.status, + "cache_root": snapshot.integrity.cache_root, + "pinned_path": snapshot.integrity.pinned_path, + "pinned_sha256": snapshot.integrity.pinned_sha256, + "running_path": snapshot.integrity.running_path, + "running_sha256": snapshot.integrity.running_sha256, + "matches_running_binary": snapshot.integrity.matches_running_binary, + }, + "hook_ownership": hook_ownership_json(&snapshot.hook_ownership), + "runtimes": snapshot.runtimes.iter().map(runtime_json).collect::>(), + "watcher": { + "status": snapshot.watcher.status, + "running": snapshot.watcher.running, + "drain_status": snapshot.watcher.drain_status, + "log_file": snapshot.watcher.log_file, + }, + "pending_syncs": snapshot.pending_syncs, + "unknown_runtimes": snapshot.unknown_runtimes, + "core_findings": snapshot.core_findings, + "core_health": core_health_json(snapshot.core_health.as_ref()), + })) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter)) +} + +fn core_health_json(health: Option<&agentmesh_core::DoctorHealth>) -> serde_json::Value { + match health { + Some(health) => json!({ + "entities_out_of_sync": health.entities_out_of_sync, + "pending_conflicts": health.pending_conflicts, + "pending_syncs": health.pending_syncs, + "failed_pending_syncs": health.failed_pending_syncs, + "capability_skips": health.capability_skips, + "lockfile_privacy_warnings": health.lockfile_privacy_warnings, + }), + None => serde_json::Value::Null, + } +} + +fn runtime_json(runtime: &RuntimeSnapshot) -> serde_json::Value { + json!({ + "name": runtime.name, + "present": runtime.present, + "evidence": runtime.evidence, + "entities": runtime.entities, + "import_error": runtime.import_error, + "hook_overlay": runtime.hook_overlay, + "hook_installed": runtime.hook_installed, + "hook_note": runtime.hook_note, + }) +} + +fn hook_ownership_json(ownership: &HookOwnershipSnapshot) -> serde_json::Value { + json!({ + "status": ownership.status, + "path": ownership.path, + "entries": ownership.entries.iter().map(|entry| { + json!({ + "runtime": &entry.runtime, + "overlay_file": &entry.overlay_file, + "entry_paths": &entry.entry_paths, + "installed_at": &entry.installed_at, + "installer_version": &entry.installer_version, + "hook_present": entry.hook_present, + }) + }).collect::>(), + "issues": &ownership.issues, + }) +} + +pub(crate) fn print_status(_context: &CliContext, snapshot: &RepoSnapshot) { + println!( + "AgentMesh {} repo: {} lockfile: {}", + agentmesh_core::VERSION, + snapshot.repo_name, + snapshot.lockfile.status + ); + println!( + " hooks: {}", + snapshot + .runtimes + .iter() + .map(|runtime| format!( + "{} {}", + runtime.name, + check(_context, runtime.hook_installed) + )) + .collect::>() + .join(" ") + ); + println!( + " watcher: {} (drain: {})", + snapshot.watcher.status, snapshot.watcher.drain_status + ); + println!(" pending: {} in queue", snapshot.pending_syncs); + println!( + " conflicts: {} unresolved", + snapshot.lockfile.pending_conflicts + ); + println!(" integrity: {}", snapshot.integrity.status); + if _context.verbose() { + println!(" runtime details:"); + for runtime in &snapshot.runtimes { + println!( + " {:<7} present={} hook={} entities={}", + runtime.name, + runtime.present, + runtime.hook_installed, + runtime.entities.len() + ); + if _context.debug() && !runtime.evidence.is_empty() { + println!( + " evidence={}", + runtime + .evidence + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + ); + } + } + if _context.debug() { + println!(" cache: {}", snapshot.integrity.cache_root.display()); + for finding in &snapshot.core_findings { + println!(" finding: {finding}"); + } + } + } +} + +pub(crate) fn print_scan(context: &CliContext, snapshot: &RepoSnapshot) { + println!("Detected runtimes:"); + for runtime in &snapshot.runtimes { + let marker = check(context, runtime.present); + let evidence = if runtime.evidence.is_empty() { + "not detected".to_string() + } else { + runtime + .evidence + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + }; + println!(" {marker} {:<7} ({evidence})", runtime.name); + } + + println!(); + println!("Detected entities:"); + let mut count = 0usize; + for runtime in &snapshot.runtimes { + if let Some(error) = &runtime.import_error { + println!( + " {} {:<7} import failed: {error}", + context.paint(OutputStyle::Warning, "⚠"), + runtime.name + ); + continue; + } + for entity in &runtime.entities { + count += 1; + println!(" {entity:<28} ({})", runtime.name); + } + } + println!(); + println!("{count} runtime entity view(s) detected."); +} + +pub(crate) fn print_doctor(context: &CliContext, snapshot: &RepoSnapshot) { + println!("AgentMesh {}", agentmesh_core::VERSION); + println!("Repository: {}", snapshot.repo_root.display()); + println!(); + println!("Adapters:"); + for runtime in &snapshot.runtimes { + let state = if runtime.present { + format!("{} detected", check(context, true)) + } else { + format!("{} not detected", check(context, false)) + }; + println!( + " {:<7} {} bundled, protocol 1, entities [instructions, skill, subagent]", + runtime.name, state + ); + } + for runtime in &snapshot.unknown_runtimes { + println!( + " unknown {} unsupported runtime candidate ({})", + check(context, false), + runtime.display() + ); + } + println!(); + print_integrity(snapshot); + println!(); + println!("Hook entries:"); + for runtime in &snapshot.runtimes { + println!( + " {:<7} {} pinned-absolute ({})", + runtime.name, + check(context, runtime.hook_installed), + runtime.hook_overlay.display() + ); + if let Some(note) = &runtime.hook_note { + println!( + " {} {note}", + context.paint(OutputStyle::Warning, "⚠") + ); + } + } + println!(" Ownership: {}", snapshot.hook_ownership.status); + println!( + " Ownership file: {}", + snapshot.hook_ownership.path.display() + ); + for entry in &snapshot.hook_ownership.entries { + println!( + " {:<7} {} owned entries ({})", + entry.runtime, + entry.entry_paths.len(), + check(context, entry.hook_present) + ); + } + for issue in &snapshot.hook_ownership.issues { + println!(" {} {issue}", context.paint(OutputStyle::Warning, "⚠")); + } + println!(); + println!("Watcher daemon:"); + println!(" Status: {}", snapshot.watcher.status); + println!(" Drain: {}", snapshot.watcher.drain_status); + if let Some(log_file) = &snapshot.watcher.log_file { + println!(" Log: {}", log_file.display()); + } + println!(); + println!("Lockfile:"); + println!(" Status: {}", snapshot.lockfile.status); + if let Some(schema) = snapshot.lockfile.schema { + println!(" Schema: {schema} (current)"); + } + println!(" Entities: {}", snapshot.lockfile.entities); + println!( + " Pending conflicts: {}", + snapshot.lockfile.pending_conflicts + ); + for entity_id in &snapshot.lockfile.pending_conflict_ids { + println!(" {entity_id}"); + println!( + " restore: agentmesh restore {entity_id} --from --at -y" + ); + println!(" acknowledge: agentmesh ack {entity_id} -y"); + } + if !snapshot.core_findings.is_empty() { + println!(); + println!("Core findings:"); + for finding in &snapshot.core_findings { + println!(" {finding}"); + } + } +} + +pub(crate) fn print_versions(snapshot: &RepoSnapshot) { + println!("AgentMesh: {}", agentmesh_core::VERSION); + println!("Protocol versions: supported [1]"); + println!( + "Lockfile schema: {}", + snapshot + .lockfile + .schema + .map(|schema| format!("{schema} (current)")) + .unwrap_or_else(|| "not present".to_string()) + ); + println!(); + println!("Built-in adapters:"); + println!(" claude bundled protocol [1] entities [instructions, skill, subagent]"); + println!(" codex bundled protocol [1] entities [instructions, skill, subagent]"); +} + +pub(crate) fn print_integrity(snapshot: &RepoSnapshot) { + println!("Hook integrity:"); + println!(" Status: {}", snapshot.integrity.status); + println!( + " Cache: {}", + snapshot.integrity.cache_root.display() + ); + if let Some(path) = &snapshot.integrity.pinned_path { + println!(" Binary path: {} (pinned)", path.display()); + } else { + println!(" Binary path: not pinned yet"); + } + if let Some(hash) = &snapshot.integrity.pinned_sha256 { + println!(" Pinned sha256: {hash}"); + } + if let Some(path) = &snapshot.integrity.running_path { + println!(" Running binary: {}", path.display()); + } + if let Some(hash) = &snapshot.integrity.running_sha256 { + println!(" Running sha256: {hash}"); + } + println!(" Hook entry style: pinned-absolute for Claude and Codex when installed"); +} diff --git a/crates/agentmesh/src/main.rs b/crates/agentmesh/src/main.rs index c00eed4..b1707c4 100644 --- a/crates/agentmesh/src/main.rs +++ b/crates/agentmesh/src/main.rs @@ -6,13 +6,26 @@ use std::time::{SystemTime, UNIX_EPOCH}; use agentmesh_adapter_sdk_rust::Adapter; use agentmesh_protocol::{ - DetectResponse, EmitRequest, EmitResponse, ImportRequest, ImportResponse, InstallHooksRequest, - RemoveHooksRequest, + DetectResponse, EmitRequest, EmitResponse, ImportRequest, ImportResponse, }; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; -use serde::{Deserialize, Serialize}; use serde_json::json; +use hooks::{ + install_detected_runtime_hooks, install_git_pre_commit_hook, install_runtime_hook, + print_git_pre_commit_dry_run, print_runtime_install_dry_run, print_upgrade_dry_run, + rewrite_installed_runtime_hooks, uninstall_runtime_hooks, +}; +use inspect::{ + ReviewedDiffSummary, clear_reviewed_diff_state, doctor_json, inspect_repo, inspect_status_repo, + integrity_exit_code, print_doctor, print_integrity, print_scan, print_status, print_versions, + read_reviewed_diff_state, scan_json, snapshot_exit_code, status_json, + write_reviewed_diff_state, +}; + +mod hooks; +mod inspect; + #[derive(Debug, Parser)] #[command( name = "agentmesh", @@ -1622,105 +1635,6 @@ fn handle_reserved_v02( Ok(AgentmeshExitCode::Usage) } -#[derive(Debug)] -struct RepoSnapshot { - repo_root: PathBuf, - repo_name: String, - lockfile: LockfileSnapshot, - integrity: IntegritySnapshot, - hook_ownership: HookOwnershipSnapshot, - watcher: WatcherSnapshot, - pending_syncs: usize, - runtimes: Vec, - unknown_runtimes: Vec, - core_findings: Vec, - core_health: Option, -} - -#[derive(Debug)] -struct LockfileSnapshot { - status: String, - schema: Option, - entities: usize, - pending_conflicts: usize, - pending_conflict_ids: Vec, -} - -#[derive(Debug)] -struct IntegritySnapshot { - status: String, - cache_root: PathBuf, - pinned_path: Option, - pinned_sha256: Option, - running_path: Option, - running_sha256: Option, - matches_running_binary: Option, -} - -#[derive(Debug)] -struct HookOwnershipSnapshot { - status: String, - path: PathBuf, - entries: Vec, - issues: Vec, -} - -#[derive(Debug)] -struct HookOwnershipRuntimeSnapshot { - runtime: String, - overlay_file: PathBuf, - entry_paths: Vec, - installed_at: String, - installer_version: String, - hook_present: bool, -} - -#[derive(Debug)] -struct WatcherSnapshot { - status: String, - running: bool, - drain_status: String, - log_file: Option, -} - -#[derive(Debug)] -struct RuntimeSnapshot { - name: &'static str, - present: bool, - evidence: Vec, - entities: Vec, - import_error: Option, - hook_overlay: PathBuf, - hook_installed: bool, - hook_note: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct ReviewedDiffState { - repo_root: PathBuf, - created_at: String, - summary: ReviewedDiffSummary, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct ReviewedDiffSummary { - changed: bool, - entities_changed: usize, - pending_conflicts: usize, - capability_skipped: usize, -} - -impl From<&agentmesh_core::SyncSummary> for ReviewedDiffSummary { - fn from(summary: &agentmesh_core::SyncSummary) -> Self { - Self { - changed: summary.changed, - entities_changed: summary.entities_changed, - pending_conflicts: summary.pending_conflicts, - capability_skipped: summary.capability_skipped, - } - } -} - #[derive(Debug)] struct RestorePlan { preserved_path: PathBuf, @@ -1728,1594 +1642,178 @@ struct RestorePlan { timestamp: Option, } -fn inspect_repo(context: &CliContext) -> Result { - inspect_repo_with_options( - context, - InspectOptions { - import_entities: true, - include_core_findings: true, - include_unknown_runtimes: true, - }, - ) +fn check(context: &CliContext, ok: bool) -> String { + if ok { + context.paint(OutputStyle::Success, "✓") + } else { + context.paint(OutputStyle::Danger, "✗") + } } -fn inspect_status_repo(context: &CliContext) -> Result { - inspect_repo_with_options( - context, - InspectOptions { - import_entities: false, - include_core_findings: false, - include_unknown_runtimes: false, +fn handle_drain_pending(context: &CliContext, _background: bool) -> Result { + let summary = agentmesh_core::sync_with_adapter_registry( + &context.repo_root, + agentmesh_core::SyncOptions { + drain_pending: true, + background: _background, + silent: context.silent, + ..agentmesh_core::SyncOptions::default() }, + &CliAdapterRegistry, ) -} + .map_err(map_core_error)?; -#[derive(Debug, Clone, Copy)] -struct InspectOptions { - import_entities: bool, - include_core_findings: bool, - include_unknown_runtimes: bool, + if !context.silent { + println!("drainer: processed={}", summary.pending_drained); + } + Ok(AgentmeshExitCode::Success) } -fn inspect_repo_with_options( - context: &CliContext, - options: InspectOptions, -) -> Result { - context.touch(); - let repo_name = context - .repo_root - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("repo") - .to_string(); - let cache = cache_layout(&context.repo_root)?; - let runtimes = vec![ - inspect_claude(context, options.import_entities)?, - inspect_codex(context, options.import_entities)?, - ]; - let hook_ownership = inspect_hook_ownership(context, &cache, &runtimes)?; - let (core_findings, core_health) = if options.include_core_findings { - let report = agentmesh_core::doctor(&context.repo_root).map_err(map_core_error)?; - (report.findings, Some(report.health)) - } else { - (Vec::new(), None) - }; - let unknown_runtimes = if options.include_unknown_runtimes { - inspect_unknown_runtime_dirs(&context.repo_root)? - } else { - Vec::new() - }; - - Ok(RepoSnapshot { - repo_root: context.repo_root.clone(), - repo_name, - lockfile: inspect_lockfile(&context.repo_root), - integrity: inspect_integrity(&cache), - hook_ownership, - watcher: inspect_watcher(&context.repo_root), - pending_syncs: inspect_pending_syncs(&cache)?, - runtimes, - unknown_runtimes, - core_findings, - core_health, +fn spawn_background_drain(context: &CliContext) -> Result<()> { + if watcher_is_running(&context.repo_root) { + return Ok(()); + } + let executable = std::env::current_exe().map_err(CliError::from_io)?; + let mut command = ProcessCommand::new(&executable); + command + .arg("--cwd") + .arg(&context.repo_root) + .arg("sync") + .arg("--background") + .arg("--drain-pending") + .arg("--silent") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + command.spawn().map(|_| ()).map_err(|source| { + CliError::new( + format!( + "failed to spawn background drainer from {}: {source}", + executable.display() + ), + AgentmeshExitCode::Io, + ) }) } -fn inspect_pending_syncs(cache: &agentmesh_core::state::CacheLayout) -> Result { - agentmesh_core::pending_queue::PendingQueue::new(&cache.pending_syncs_dir) - .read_ready() - .map(|records| records.len()) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io)) -} - -fn inspect_unknown_runtime_dirs(repo_root: &Path) -> Result> { - let mut unknown = Vec::new(); - let entries = match fs::read_dir(repo_root) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(unknown), - Err(error) => return Err(CliError::from_io(error)), - }; - for entry in entries { - let entry = entry.map_err(CliError::from_io)?; - let path = entry.path(); - if !path.is_dir() { - continue; - } - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if !name.starts_with('.') || matches!(name, ".ai" | ".claude" | ".codex" | ".git") { - continue; - } - if path.join("skills").is_dir() - || path.join("agents").is_dir() - || path.join("rules").is_dir() - || path.join("hooks.json").is_file() - { - unknown.push(PathBuf::from(name)); - } +fn ensure_watcher_for_trigger(context: &CliContext, options: &ParsedSyncOptions) -> Result<()> { + if !matches!( + options.trigger, + SyncTrigger::ClaudeHook | SyncTrigger::CodexHook + ) { + return Ok(()); } - unknown.sort(); - Ok(unknown) -} - -fn inspect_lockfile(repo_root: &Path) -> LockfileSnapshot { - match agentmesh_core::lockfile::read_lockfile(repo_root) { - Ok(lockfile) => { - let pending_conflict_ids = lockfile - .entities - .iter() - .filter(|(_, entity)| entity.pending_conflict_resolution == Some(true)) - .map(|(entity_id, _)| entity_id.as_str().to_string()) - .collect::>(); - LockfileSnapshot { - status: "present".to_string(), - schema: Some(lockfile.schema), - pending_conflicts: pending_conflict_ids.len(), - pending_conflict_ids, - entities: lockfile.entities.len(), - } - } - Err(error) => LockfileSnapshot { - status: format!("not ready ({error})"), - schema: None, - entities: 0, - pending_conflicts: 0, - pending_conflict_ids: Vec::new(), - }, + if !hook_ownership_exists_for_trigger(context, &options.trigger)? { + return Ok(()); + } + if watcher_is_running(&context.repo_root) { + return Ok(()); } + agentmesh_watcher::start( + &context.repo_root, + agentmesh_watcher::WatchOptions { + persistent: false, + foreground: false, + register_as_service: false, + ..agentmesh_watcher::WatchOptions::default() + }, + ) + .map(|_| ()) + .map_err(map_watcher_error) } -fn inspect_integrity(cache: &agentmesh_core::state::CacheLayout) -> IntegritySnapshot { - let running = std::env::current_exe().ok().and_then(|path| { - agentmesh_core::state::sha256_file(&path) - .ok() - .map(|hash| (path, hash)) - }); +fn start_sync_watcher(context: &CliContext) -> Result<()> { + if std::env::var_os("AGENTMESH_DISABLE_WATCHER_AUTOSTART").is_some() { + return Ok(()); + } - match agentmesh_core::state::read_integrity_pin(&cache.integrity_json) { - Ok(pin) => { - let matches_running_binary = running - .as_ref() - .map(|(path, hash)| path == &pin.binary_path && hash == &pin.binary_sha256); - let status = match matches_running_binary { - Some(true) => "pinned".to_string(), - Some(false) => "mismatch".to_string(), - None => "unknown (could not hash running binary)".to_string(), - }; - let (running_path, running_sha256) = running - .map(|(path, hash)| (Some(path), Some(hash.to_string()))) - .unwrap_or((None, None)); - IntegritySnapshot { - status, - cache_root: cache.root.clone(), - pinned_path: Some(pin.binary_path), - pinned_sha256: Some(pin.binary_sha256.to_string()), - running_path, - running_sha256, - matches_running_binary, - } - } - Err(_) => IntegritySnapshot { - status: "not pinned".to_string(), - cache_root: cache.root.clone(), - pinned_path: None, - pinned_sha256: None, - running_path: running.as_ref().map(|(path, _)| path.clone()), - running_sha256: running.map(|(_, hash)| hash.to_string()), - matches_running_binary: None, + let handle = agentmesh_watcher::start( + &context.repo_root, + agentmesh_watcher::WatchOptions { + persistent: true, + foreground: false, + register_as_service: false, + ..agentmesh_watcher::WatchOptions::default() }, - } -} + ) + .map_err(map_watcher_error)?; -fn snapshot_exit_code(snapshot: &RepoSnapshot) -> AgentmeshExitCode { - if integrity_exit_code(snapshot) == AgentmeshExitCode::Integrity - || !snapshot.hook_ownership.issues.is_empty() - { - AgentmeshExitCode::Integrity - } else if snapshot.lockfile.pending_conflicts > 0 - || snapshot.pending_syncs > 0 - || snapshot.core_health.as_ref().is_some_and(|health| { - health.entities_out_of_sync > 0 - || health.failed_pending_syncs > 0 - || health.capability_skips > 0 - || health.pending_conflicts > 0 - || health.pending_syncs > 0 - || health.lockfile_privacy_warnings > 0 - }) - { - AgentmeshExitCode::Drift - } else { - AgentmeshExitCode::Success + if !context.silent { + println!(" watcher: running"); + println!(" watcher state: {}", handle.state_file.display()); + println!(" watcher log: {}", handle.log_file.display()); } -} -fn integrity_exit_code(snapshot: &RepoSnapshot) -> AgentmeshExitCode { - if snapshot.integrity.matches_running_binary == Some(false) { - AgentmeshExitCode::Integrity - } else { - AgentmeshExitCode::Success - } + Ok(()) } -fn inspect_hook_ownership( - context: &CliContext, - cache: &agentmesh_core::state::CacheLayout, - runtimes: &[RuntimeSnapshot], -) -> Result { - let path = cache.hook_ownership_json.clone(); - let ownership = match agentmesh_core::state::read_hook_ownership(&path) { - Ok(ownership) => ownership, +fn hook_ownership_exists_for_trigger(context: &CliContext, trigger: &SyncTrigger) -> Result { + let runtime = match trigger { + SyncTrigger::ClaudeHook => "claude", + SyncTrigger::CodexHook => "codex", + _ => return Ok(false), + }; + let runtime = agentmesh_core::RuntimeName::new(runtime) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Usage))?; + let layout = cache_layout(&context.repo_root)?; + match agentmesh_core::state::read_hook_ownership(&layout.hook_ownership_json) { + Ok(ownership) => Ok(ownership.0.contains_key(&runtime)), Err(agentmesh_core::state::StateError::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => { - let issues = runtimes - .iter() - .filter(|runtime| runtime.hook_installed) - .map(|runtime| { - format!( - "{} hook is installed but hook ownership is not recorded", - runtime.name - ) - }) - .collect::>(); - let status = if issues.is_empty() { - "not recorded".to_string() - } else { - "mismatch".to_string() - }; - return Ok(HookOwnershipSnapshot { - status, - path, - entries: Vec::new(), - issues, - }); - } - Err(error) => return Err(CliError::new(error.to_string(), AgentmeshExitCode::Io)), - }; - - let mut entries = Vec::new(); - let mut issues = Vec::new(); - for (runtime, entry) in &ownership.0 { - let overlay_path = context.repo_root.join(&entry.overlay_file); - let hook_present = if runtime.as_str() == GIT_PRE_COMMIT_RUNTIME { - fs::read_to_string(&overlay_path) - .map(|content| { - content.contains(GIT_PRE_COMMIT_MARKER) - && content.contains("--trigger=git-pre-commit") - }) - .unwrap_or(false) - } else { - let trigger = format!("{}-hook", runtime.as_str()); - fs::read_to_string(&overlay_path) - .map(|content| content.contains(&trigger)) - .unwrap_or(false) - }; - if !hook_present { - issues.push(format!( - "{} ownership is recorded but no matching hook was found in {}", - runtime.as_str(), - entry.overlay_file.display() - )); - } - entries.push(HookOwnershipRuntimeSnapshot { - runtime: runtime.as_str().to_string(), - overlay_file: entry.overlay_file.clone(), - entry_paths: entry.entry_paths.clone(), - installed_at: entry.installed_at.clone(), - installer_version: entry.installer_version.clone(), - hook_present, - }); - } - - for runtime in runtimes.iter().filter(|runtime| runtime.hook_installed) { - let owned = entries.iter().any(|entry| entry.runtime == runtime.name); - if !owned { - issues.push(format!( - "{} hook is installed but hook ownership has no entry", - runtime.name - )); + Ok(false) } + Err(error) => Err(CliError::new(error.to_string(), AgentmeshExitCode::Io)), } - - let status = if issues.is_empty() { "ok" } else { "mismatch" }.to_string(); - Ok(HookOwnershipSnapshot { - status, - path, - entries, - issues, - }) -} - -fn reviewed_diff_path(cache: &agentmesh_core::state::CacheLayout) -> PathBuf { - cache.root.join("reviewed-diff.json") } -fn write_reviewed_diff_state( - context: &CliContext, - summary: &agentmesh_core::SyncSummary, -) -> Result> { - let cache = cache_layout(&context.repo_root)?; - let path = reviewed_diff_path(&cache); - if !summary.changed { - match fs::remove_file(&path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(CliError::from_io(error)), - } - return Ok(None); - } - - cache - .ensure_dirs() - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io))?; - let state = ReviewedDiffState { - repo_root: context.repo_root.clone(), - created_at: timestamp_string(), - summary: ReviewedDiffSummary::from(summary), - }; - let bytes = serde_json::to_vec_pretty(&state) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter))?; - fs::write(&path, bytes).map_err(CliError::from_io)?; - Ok(Some(path)) +fn watcher_is_running(repo_root: &Path) -> bool { + agentmesh_watcher::status(repo_root) + .map(|status| status.running) + .unwrap_or(false) } -fn read_reviewed_diff_state(context: &CliContext) -> Result<(PathBuf, ReviewedDiffState)> { - let cache = cache_layout(&context.repo_root)?; - let path = reviewed_diff_path(&cache); - let bytes = fs::read(&path).map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - CliError::new( - "apply requires a reviewed diff; run `agentmesh diff` first", - AgentmeshExitCode::Cancelled, - ) - } else { - CliError::from_io(error) - } - })?; - let state = serde_json::from_slice(&bytes) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io))?; - Ok((path, state)) +fn cache_layout(repo_root: &Path) -> Result { + agentmesh_core::state::CacheLayout::new(&cache_root()?, repo_root) + .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io)) } -fn clear_reviewed_diff_state(path: &Path) -> Result<()> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(CliError::from_io(error)), +fn cache_root() -> Result { + if let Some(path) = std::env::var_os("AGENTMESH_CACHE_DIR") { + return Ok(PathBuf::from(path)); } -} - -fn inspect_watcher(repo_root: &Path) -> WatcherSnapshot { - match agentmesh_watcher::status(repo_root) { - Ok(status) => WatcherSnapshot { - status: status.state, - running: status.running, - drain_status: status.drain_status, - log_file: Some(status.log_file), - }, - Err(error) => WatcherSnapshot { - status: format!("unavailable ({error})"), - running: false, - drain_status: "unknown".to_string(), - log_file: None, - }, + if let Some(path) = std::env::var_os("XDG_CACHE_HOME") { + return Ok(PathBuf::from(path).join("agentmesh")); } + if let Some(path) = std::env::var_os("LOCALAPPDATA") { + return Ok(PathBuf::from(path).join("agentmesh")); + } + if let Some(path) = std::env::var_os("HOME") { + return Ok(PathBuf::from(path).join(".cache").join("agentmesh")); + } + Err(CliError::new( + "cannot determine machine-local cache directory", + AgentmeshExitCode::Io, + )) } -fn inspect_claude(context: &CliContext, import_entities: bool) -> Result { - inspect_runtime( - context, - "claude", - ".claude", - ".claude/settings.local.json", - "claude-hook", - import_entities, - agentmesh_adapter_claude::ClaudeAdapter, - ) -} - -fn inspect_codex(context: &CliContext, import_entities: bool) -> Result { - let mut runtime = inspect_runtime( - context, - "codex", - ".codex", - ".codex/hooks.json", - "codex-hook", - import_entities, - agentmesh_adapter_codex::CodexAdapter, - )?; - if runtime.hook_installed { - runtime.hook_note = Some( - "Codex requires one-time trust approval before this command hook runs".to_string(), - ); +fn timestamp_string() -> String { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => format!( + "unix:{}.{:09}Z", + duration.as_secs(), + duration.subsec_nanos() + ), + Err(_) => "unix:0.000000000Z".to_string(), } - Ok(runtime) } -fn inspect_runtime( +fn sync_check_exit_code( context: &CliContext, - name: &'static str, - runtime_dir_name: &str, - overlay: &str, - hook_trigger: &str, - import_entities: bool, - adapter: A, -) -> Result -where - A: Adapter, -{ - let detected = adapter - .detect(&context.repo_root) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter))?; - let runtime_dir = context.repo_root.join(runtime_dir_name); - let mut entities = Vec::new(); - let mut import_error = None; - - if detected.present && import_entities { - match adapter.import(ImportRequest { - canonical_dir: context.repo_root.join(".ai"), - runtime_dir, - filter: None, - }) { - Ok(imported) => { - entities = imported - .entities - .into_iter() - .map(|entity| entity.id) - .collect(); - } - Err(error) => { - import_error = Some(error.to_string()); - } - } - } - - let overlay_path = PathBuf::from(overlay); - let hook_installed = fs::read_to_string(context.repo_root.join(&overlay_path)) - .map(|content| content.contains(hook_trigger)) - .unwrap_or(false); - - Ok(RuntimeSnapshot { - name, - present: detected.present, - evidence: detected.files, - entities, - import_error, - hook_overlay: overlay_path, - hook_installed, - hook_note: None, - }) -} - -fn status_json(snapshot: &RepoSnapshot) -> Result { - serde_json::to_string_pretty(&json!({ - "repo": snapshot.repo_name, - "repo_root": snapshot.repo_root, - "lockfile": { - "status": snapshot.lockfile.status, - "schema": snapshot.lockfile.schema, - "entities": snapshot.lockfile.entities, - "pending_conflicts": snapshot.lockfile.pending_conflicts, - "pending_conflict_ids": snapshot.lockfile.pending_conflict_ids, - }, - "integrity": { - "status": snapshot.integrity.status, - "pinned_path": snapshot.integrity.pinned_path, - "pinned_sha256": snapshot.integrity.pinned_sha256, - "running_path": snapshot.integrity.running_path, - "running_sha256": snapshot.integrity.running_sha256, - "matches_running_binary": snapshot.integrity.matches_running_binary, - }, - "hook_ownership": hook_ownership_json(&snapshot.hook_ownership), - "watcher": { - "status": snapshot.watcher.status, - "running": snapshot.watcher.running, - "drain_status": snapshot.watcher.drain_status, - "log_file": snapshot.watcher.log_file, - }, - "pending_syncs": snapshot.pending_syncs, - "unknown_runtimes": snapshot.unknown_runtimes, - "core_findings": snapshot.core_findings, - "core_health": core_health_json(snapshot.core_health.as_ref()), - "runtimes": snapshot.runtimes.iter().map(runtime_json).collect::>(), - })) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter)) -} - -fn scan_json(snapshot: &RepoSnapshot) -> Result { - serde_json::to_string_pretty(&json!({ - "runtimes": snapshot.runtimes.iter().map(runtime_json).collect::>(), - "entity_count": snapshot.runtimes.iter().map(|runtime| runtime.entities.len()).sum::(), - })) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter)) -} - -fn doctor_json(snapshot: &RepoSnapshot) -> Result { - serde_json::to_string_pretty(&json!({ - "version": agentmesh_core::VERSION, - "repo_root": snapshot.repo_root, - "lockfile": { - "status": snapshot.lockfile.status, - "schema": snapshot.lockfile.schema, - "entities": snapshot.lockfile.entities, - "pending_conflicts": snapshot.lockfile.pending_conflicts, - "pending_conflict_ids": snapshot.lockfile.pending_conflict_ids, - }, - "integrity": { - "status": snapshot.integrity.status, - "cache_root": snapshot.integrity.cache_root, - "pinned_path": snapshot.integrity.pinned_path, - "pinned_sha256": snapshot.integrity.pinned_sha256, - "running_path": snapshot.integrity.running_path, - "running_sha256": snapshot.integrity.running_sha256, - "matches_running_binary": snapshot.integrity.matches_running_binary, - }, - "hook_ownership": hook_ownership_json(&snapshot.hook_ownership), - "runtimes": snapshot.runtimes.iter().map(runtime_json).collect::>(), - "watcher": { - "status": snapshot.watcher.status, - "running": snapshot.watcher.running, - "drain_status": snapshot.watcher.drain_status, - "log_file": snapshot.watcher.log_file, - }, - "pending_syncs": snapshot.pending_syncs, - "unknown_runtimes": snapshot.unknown_runtimes, - "core_findings": snapshot.core_findings, - "core_health": core_health_json(snapshot.core_health.as_ref()), - })) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter)) -} - -fn core_health_json(health: Option<&agentmesh_core::DoctorHealth>) -> serde_json::Value { - match health { - Some(health) => json!({ - "entities_out_of_sync": health.entities_out_of_sync, - "pending_conflicts": health.pending_conflicts, - "pending_syncs": health.pending_syncs, - "failed_pending_syncs": health.failed_pending_syncs, - "capability_skips": health.capability_skips, - "lockfile_privacy_warnings": health.lockfile_privacy_warnings, - }), - None => serde_json::Value::Null, - } -} - -fn runtime_json(runtime: &RuntimeSnapshot) -> serde_json::Value { - json!({ - "name": runtime.name, - "present": runtime.present, - "evidence": runtime.evidence, - "entities": runtime.entities, - "import_error": runtime.import_error, - "hook_overlay": runtime.hook_overlay, - "hook_installed": runtime.hook_installed, - "hook_note": runtime.hook_note, - }) -} - -fn hook_ownership_json(ownership: &HookOwnershipSnapshot) -> serde_json::Value { - json!({ - "status": ownership.status, - "path": ownership.path, - "entries": ownership.entries.iter().map(|entry| { - json!({ - "runtime": &entry.runtime, - "overlay_file": &entry.overlay_file, - "entry_paths": &entry.entry_paths, - "installed_at": &entry.installed_at, - "installer_version": &entry.installer_version, - "hook_present": entry.hook_present, - }) - }).collect::>(), - "issues": &ownership.issues, - }) -} - -fn print_status(_context: &CliContext, snapshot: &RepoSnapshot) { - println!( - "AgentMesh {} repo: {} lockfile: {}", - agentmesh_core::VERSION, - snapshot.repo_name, - snapshot.lockfile.status - ); - println!( - " hooks: {}", - snapshot - .runtimes - .iter() - .map(|runtime| format!( - "{} {}", - runtime.name, - check(_context, runtime.hook_installed) - )) - .collect::>() - .join(" ") - ); - println!( - " watcher: {} (drain: {})", - snapshot.watcher.status, snapshot.watcher.drain_status - ); - println!(" pending: {} in queue", snapshot.pending_syncs); - println!( - " conflicts: {} unresolved", - snapshot.lockfile.pending_conflicts - ); - println!(" integrity: {}", snapshot.integrity.status); - if _context.verbose() { - println!(" runtime details:"); - for runtime in &snapshot.runtimes { - println!( - " {:<7} present={} hook={} entities={}", - runtime.name, - runtime.present, - runtime.hook_installed, - runtime.entities.len() - ); - if _context.debug() && !runtime.evidence.is_empty() { - println!( - " evidence={}", - runtime - .evidence - .iter() - .map(|path| path.display().to_string()) - .collect::>() - .join(", ") - ); - } - } - if _context.debug() { - println!(" cache: {}", snapshot.integrity.cache_root.display()); - for finding in &snapshot.core_findings { - println!(" finding: {finding}"); - } - } - } -} - -fn print_scan(context: &CliContext, snapshot: &RepoSnapshot) { - println!("Detected runtimes:"); - for runtime in &snapshot.runtimes { - let marker = check(context, runtime.present); - let evidence = if runtime.evidence.is_empty() { - "not detected".to_string() - } else { - runtime - .evidence - .iter() - .map(|path| path.display().to_string()) - .collect::>() - .join(", ") - }; - println!(" {marker} {:<7} ({evidence})", runtime.name); - } - - println!(); - println!("Detected entities:"); - let mut count = 0usize; - for runtime in &snapshot.runtimes { - if let Some(error) = &runtime.import_error { - println!( - " {} {:<7} import failed: {error}", - context.paint(OutputStyle::Warning, "⚠"), - runtime.name - ); - continue; - } - for entity in &runtime.entities { - count += 1; - println!(" {entity:<28} ({})", runtime.name); - } - } - println!(); - println!("{count} runtime entity view(s) detected."); -} - -fn print_doctor(context: &CliContext, snapshot: &RepoSnapshot) { - println!("AgentMesh {}", agentmesh_core::VERSION); - println!("Repository: {}", snapshot.repo_root.display()); - println!(); - println!("Adapters:"); - for runtime in &snapshot.runtimes { - let state = if runtime.present { - format!("{} detected", check(context, true)) - } else { - format!("{} not detected", check(context, false)) - }; - println!( - " {:<7} {} bundled, protocol 1, entities [instructions, skill, subagent]", - runtime.name, state - ); - } - for runtime in &snapshot.unknown_runtimes { - println!( - " unknown {} unsupported runtime candidate ({})", - check(context, false), - runtime.display() - ); - } - println!(); - print_integrity(snapshot); - println!(); - println!("Hook entries:"); - for runtime in &snapshot.runtimes { - println!( - " {:<7} {} pinned-absolute ({})", - runtime.name, - check(context, runtime.hook_installed), - runtime.hook_overlay.display() - ); - if let Some(note) = &runtime.hook_note { - println!( - " {} {note}", - context.paint(OutputStyle::Warning, "⚠") - ); - } - } - println!(" Ownership: {}", snapshot.hook_ownership.status); - println!( - " Ownership file: {}", - snapshot.hook_ownership.path.display() - ); - for entry in &snapshot.hook_ownership.entries { - println!( - " {:<7} {} owned entries ({})", - entry.runtime, - entry.entry_paths.len(), - check(context, entry.hook_present) - ); - } - for issue in &snapshot.hook_ownership.issues { - println!(" {} {issue}", context.paint(OutputStyle::Warning, "⚠")); - } - println!(); - println!("Watcher daemon:"); - println!(" Status: {}", snapshot.watcher.status); - println!(" Drain: {}", snapshot.watcher.drain_status); - if let Some(log_file) = &snapshot.watcher.log_file { - println!(" Log: {}", log_file.display()); - } - println!(); - println!("Lockfile:"); - println!(" Status: {}", snapshot.lockfile.status); - if let Some(schema) = snapshot.lockfile.schema { - println!(" Schema: {schema} (current)"); - } - println!(" Entities: {}", snapshot.lockfile.entities); - println!( - " Pending conflicts: {}", - snapshot.lockfile.pending_conflicts - ); - for entity_id in &snapshot.lockfile.pending_conflict_ids { - println!(" {entity_id}"); - println!( - " restore: agentmesh restore {entity_id} --from --at -y" - ); - println!(" acknowledge: agentmesh ack {entity_id} -y"); - } - if !snapshot.core_findings.is_empty() { - println!(); - println!("Core findings:"); - for finding in &snapshot.core_findings { - println!(" {finding}"); - } - } -} - -fn print_versions(snapshot: &RepoSnapshot) { - println!("AgentMesh: {}", agentmesh_core::VERSION); - println!("Protocol versions: supported [1]"); - println!( - "Lockfile schema: {}", - snapshot - .lockfile - .schema - .map(|schema| format!("{schema} (current)")) - .unwrap_or_else(|| "not present".to_string()) - ); - println!(); - println!("Built-in adapters:"); - println!(" claude bundled protocol [1] entities [instructions, skill, subagent]"); - println!(" codex bundled protocol [1] entities [instructions, skill, subagent]"); -} - -fn print_integrity(snapshot: &RepoSnapshot) { - println!("Hook integrity:"); - println!(" Status: {}", snapshot.integrity.status); - println!( - " Cache: {}", - snapshot.integrity.cache_root.display() - ); - if let Some(path) = &snapshot.integrity.pinned_path { - println!(" Binary path: {} (pinned)", path.display()); - } else { - println!(" Binary path: not pinned yet"); - } - if let Some(hash) = &snapshot.integrity.pinned_sha256 { - println!(" Pinned sha256: {hash}"); - } - if let Some(path) = &snapshot.integrity.running_path { - println!(" Running binary: {}", path.display()); - } - if let Some(hash) = &snapshot.integrity.running_sha256 { - println!(" Running sha256: {hash}"); - } - println!(" Hook entry style: pinned-absolute for Claude and Codex when installed"); -} - -fn check(context: &CliContext, ok: bool) -> String { - if ok { - context.paint(OutputStyle::Success, "✓") - } else { - context.paint(OutputStyle::Danger, "✗") - } -} - -fn print_runtime_install_dry_run(context: &CliContext, runtime: &str) -> Result<()> { - let binary_path = std::env::current_exe().map_err(CliError::from_io)?; - let overlay = match runtime { - "claude" => ".claude/settings.local.json", - "codex" => ".codex/hooks.json", - other => { - return Err(CliError::new( - format!("unknown bundled runtime: {other}"), - AgentmeshExitCode::Usage, - )); - } - }; - if !context.silent { - println!( - "{} Would install {runtime} sync hook:", - context.paint(OutputStyle::Info, "→") - ); - println!(" Overlay: {}", context.repo_root.join(overlay).display()); - println!( - " Command: {} sync --trigger={runtime}-hook --silent", - binary_path.display() - ); - } - Ok(()) -} - -fn print_git_pre_commit_dry_run(context: &CliContext) -> Result<()> { - let hook = context.repo_root.join(".git/hooks/pre-commit"); - if !context.silent { - println!( - "{} Would install git pre-commit hook at {}", - context.paint(OutputStyle::Info, "→"), - hook.display() - ); - println!(" Command: agentmesh sync --check --trigger=git-pre-commit --silent"); - } - Ok(()) -} - -fn print_upgrade_dry_run(context: &CliContext) -> Result<()> { - let binary_path = std::env::current_exe().map_err(CliError::from_io)?; - if !context.silent { - println!( - "{} Would repin integrity to {}", - context.paint(OutputStyle::Info, "→"), - binary_path.display() - ); - println!( - "{} Would rewrite recorded runtime hook entries to the current binary path", - context.paint(OutputStyle::Info, "→") - ); - } - Ok(()) -} - -fn install_detected_runtime_hooks(context: &CliContext) -> Result<()> { - let claude = agentmesh_adapter_claude::ClaudeAdapter - .detect(&context.repo_root) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter))?; - if claude.present { - install_runtime_hook(context, "claude")?; - } - - let codex = agentmesh_adapter_codex::CodexAdapter - .detect(&context.repo_root) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter))?; - if codex.present { - install_runtime_hook(context, "codex")?; - } - - Ok(()) -} - -fn install_git_pre_commit_hook(context: &CliContext, force: bool) -> Result<()> { - let hook = context.repo_root.join(GIT_PRE_COMMIT_HOOK); - let saved = context.repo_root.join(GIT_PRE_COMMIT_SAVED); - let Some(parent) = hook.parent() else { - return Err(CliError::new( - "cannot resolve .git/hooks directory", - AgentmeshExitCode::Io, - )); - }; - if !parent.is_dir() { - return Err(CliError::new( - "git hooks directory not found; run from a git worktree", - AgentmeshExitCode::Usage, - )); - } - - let binary_path = std::env::current_exe().map_err(CliError::from_io)?; - let existing = match fs::read_to_string(&hook) { - Ok(existing) => Some(existing), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - Err(error) => return Err(CliError::from_io(error)), - }; - let existing_mode = if existing.is_some() { - file_mode(&hook)? - } else { - None - }; - let existing_is_agentmesh = existing - .as_deref() - .is_some_and(|content| content.contains(GIT_PRE_COMMIT_MARKER)); - let chain_original = if let Some(content) = existing.as_deref() { - if existing_is_agentmesh { - saved.exists() - } else { - if let Some(framework) = detect_pre_commit_framework(content) { - if !force { - return Err(CliError::new( - format!( - "detected {framework} managing pre-commit; add AgentMesh to that framework or rerun with --force" - ), - AgentmeshExitCode::Usage, - )); - } - } - if saved.exists() { - return Err(CliError::new( - format!( - "{} already exists; remove it or run uninstall before reinstalling", - saved.display() - ), - AgentmeshExitCode::Usage, - )); - } - write_text_atomic_with_mode(&saved, content, existing_mode)?; - true - } - } else { - false - }; - - write_text_atomic_with_mode( - &hook, - &git_pre_commit_body(&binary_path, chain_original), - hook_wrapper_mode(existing_mode), - )?; - record_git_pre_commit_ownership(context, chain_original)?; - - if !context.silent { - println!( - "{} Installed git pre-commit sync check at {}", - check(context, true), - hook.display() - ); - } - Ok(()) -} - -fn detect_pre_commit_framework(content: &str) -> Option<&'static str> { - let body = content - .lines() - .filter(|line| !line.starts_with("#!")) - .collect::>() - .join("\n"); - if body.contains("# File generated by pre-commit:") - || body.contains("pre-commit run --hook-stage") - { - Some("pre-commit") - } else if body.contains("husky.sh") || body.contains("_husky.sh") { - Some("husky") - } else if body.contains("lefthook run pre-commit") || body.contains("lefthook install") { - Some("lefthook") - } else { - None - } -} - -fn git_pre_commit_body(binary_path: &Path, chain_original: bool) -> String { - let original = if chain_original { - format!( - "\nif [ -x {} ]; then\n {} \"$@\" || exit $?\nfi\n", - shell_quote_path(Path::new(GIT_PRE_COMMIT_SAVED)), - shell_quote_path(Path::new(GIT_PRE_COMMIT_SAVED)) - ) - } else { - String::new() - }; - format!( - "#!/usr/bin/env bash\n# {GIT_PRE_COMMIT_MARKER} - do not edit directly\n\nset -e\n{original}\n{} sync --check --trigger=git-pre-commit --silent\n", - shell_quote_path(binary_path) - ) -} - -fn install_runtime_hook(context: &CliContext, runtime: &str) -> Result<()> { - let binary_path = std::env::current_exe().map_err(CliError::from_io)?; - let response = match runtime { - "claude" => agentmesh_adapter_claude::ClaudeAdapter.install_hooks(InstallHooksRequest { - runtime_dir: context.repo_root.join(".claude"), - agentmesh_binary_path: binary_path, - matcher_extra: None, - }), - "codex" => agentmesh_adapter_codex::CodexAdapter.install_hooks(InstallHooksRequest { - runtime_dir: context.repo_root.join(".codex"), - agentmesh_binary_path: binary_path, - matcher_extra: None, - }), - other => { - return Err(CliError::new( - format!("unknown bundled runtime: {other}"), - AgentmeshExitCode::Usage, - )); - } - } - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter))?; - - record_hook_ownership(context, runtime, &response.hooks_installed)?; - - if !context.silent { - println!( - "{} Installing {runtime} sync hook:", - context.paint(OutputStyle::Info, "→") - ); - for hook in &response.hooks_installed { - println!( - " {} Wrote {} [{}]", - check(context, true), - hook.overlay_file.display(), - hook.entry_path - ); - } - println!( - " {} Recorded ownership in machine-local cache", - check(context, true) - ); - if runtime == "codex" { - println!( - " {} Recommend adding .codex/hooks.json to .gitignore", - context.paint(OutputStyle::Info, "↗") - ); - print_codex_trust_prompt(context, &response.hooks_installed); - } - } - - Ok(()) -} - -fn rewrite_installed_runtime_hooks(context: &CliContext) -> Result<()> { - let layout = cache_layout(&context.repo_root)?; - let ownership = match agentmesh_core::state::read_hook_ownership(&layout.hook_ownership_json) { - Ok(ownership) => ownership, - Err(agentmesh_core::state::StateError::Io { source, .. }) - if source.kind() == std::io::ErrorKind::NotFound => - { - return Ok(()); - } - Err(error) => return Err(CliError::new(error.to_string(), AgentmeshExitCode::Io)), - }; - - for runtime in ownership.0.keys() { - match runtime.as_str() { - "claude" | "codex" => { - remove_runtime_hook_entries(context, runtime.as_str())?; - install_runtime_hook(context, runtime.as_str())?; - } - GIT_PRE_COMMIT_RUNTIME => rewrite_git_pre_commit_hook(context)?, - _ => {} - } - } - - Ok(()) -} - -fn rewrite_git_pre_commit_hook(context: &CliContext) -> Result<()> { - let hook = context.repo_root.join(GIT_PRE_COMMIT_HOOK); - let content = match fs::read_to_string(&hook) { - Ok(content) => content, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(CliError::from_io(error)), - }; - if !content.contains(GIT_PRE_COMMIT_MARKER) { - return Ok(()); - } - let binary_path = std::env::current_exe().map_err(CliError::from_io)?; - let saved = context.repo_root.join(GIT_PRE_COMMIT_SAVED); - let existing_mode = file_mode(&hook)?; - write_text_atomic_with_mode( - &hook, - &git_pre_commit_body(&binary_path, saved.exists()), - hook_wrapper_mode(existing_mode), - ) -} - -fn print_codex_trust_prompt(context: &CliContext, hooks: &[agentmesh_protocol::InstalledHook]) { - if let Some(hook) = hooks.first() { - println!(); - println!( - "{} Codex requires you to review and trust new command hooks before they run.", - context.paint(OutputStyle::Warning, "⚠") - ); - println!(" What to do:"); - println!(" 1. Open Codex in this repository."); - println!( - " 2. Run any Codex action that uses a tool, such as a file read or shell command." - ); - println!(" 3. When Codex shows the hook trust prompt, approve this command:"); - println!(); - println!(" {}", hook.command); - println!(); - println!(" This is a one-time Codex security approval. Until approved, AgentMesh still"); - println!(" syncs via the watcher, Claude hooks, and manual `agentmesh sync`, but Codex"); - println!(" will not run its own hook."); - } -} - -fn record_hook_ownership( - context: &CliContext, - runtime: &str, - hooks: &[agentmesh_protocol::InstalledHook], -) -> Result<()> { - if hooks.is_empty() { - return Ok(()); - } - let runtime_name = agentmesh_core::RuntimeName::new(runtime) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Usage))?; - let layout = cache_layout(&context.repo_root)?; - layout - .ensure_dirs() - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io))?; - let mut ownership = if layout.hook_ownership_json.exists() { - agentmesh_core::state::read_hook_ownership(&layout.hook_ownership_json) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io))? - } else { - agentmesh_core::state::HookOwnership::default() - }; - - let overlay_file = hooks[0].overlay_file.clone(); - let entry_paths = hooks.iter().map(|hook| hook.entry_path.clone()).collect(); - ownership.0.insert( - runtime_name, - agentmesh_core::state::HookOwnershipEntry { - overlay_file, - entry_paths, - installed_at: timestamp_string(), - installer_version: agentmesh_core::VERSION.to_string(), - }, - ); - agentmesh_core::state::write_hook_ownership(&layout.hook_ownership_json, &ownership) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io)) -} - -fn record_git_pre_commit_ownership(context: &CliContext, saved_original: bool) -> Result<()> { - let runtime_name = agentmesh_core::RuntimeName::new(GIT_PRE_COMMIT_RUNTIME) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Usage))?; - let layout = cache_layout(&context.repo_root)?; - layout - .ensure_dirs() - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io))?; - let mut ownership = if layout.hook_ownership_json.exists() { - agentmesh_core::state::read_hook_ownership(&layout.hook_ownership_json) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io))? - } else { - agentmesh_core::state::HookOwnership::default() - }; - - let mut entry_paths = vec!["agentmesh-wrapper".to_string()]; - if saved_original { - entry_paths.push(GIT_PRE_COMMIT_SAVED.to_string()); - } - ownership.0.insert( - runtime_name, - agentmesh_core::state::HookOwnershipEntry { - overlay_file: PathBuf::from(GIT_PRE_COMMIT_HOOK), - entry_paths, - installed_at: timestamp_string(), - installer_version: agentmesh_core::VERSION.to_string(), - }, - ); - agentmesh_core::state::write_hook_ownership(&layout.hook_ownership_json, &ownership) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io)) -} - -fn uninstall_runtime_hooks(context: &CliContext, dry_run: bool) -> Result<()> { - let layout = cache_layout(&context.repo_root)?; - if !layout.hook_ownership_json.exists() { - if !context.silent { - println!( - "{} hook-ownership.json missing. Cannot determine which entries to remove.", - context.paint(OutputStyle::Warning, "⚠") - ); - } - return Ok(()); - } - - let ownership = agentmesh_core::state::read_hook_ownership(&layout.hook_ownership_json) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io))?; - if !context.silent { - println!( - "{} Removing AgentMesh-owned entries on this machine:", - context.paint(OutputStyle::Info, "→") - ); - } - - for (runtime, entry) in ownership.0 { - if runtime.as_str() == GIT_PRE_COMMIT_RUNTIME { - uninstall_git_pre_commit_hook(context, &entry, dry_run)?; - continue; - } - if dry_run { - if !context.silent { - println!( - " {} Would remove {} hook(s) from {}", - context.paint(OutputStyle::Info, "→"), - entry.entry_paths.len(), - entry.overlay_file.display() - ); - } - continue; - } - - let response = - remove_runtime_hook_entries_with_paths(context, runtime.as_str(), entry.entry_paths)?; - - if !context.silent { - if response.ok { - println!( - " {} Removed {} hook(s) from {}", - check(context, true), - response.removed_count, - entry.overlay_file.display() - ); - } else if let Some(error) = response.error { - println!( - " {} {}: {error}", - context.paint(OutputStyle::Warning, "⚠"), - runtime.as_str() - ); - } - } - } - - Ok(()) -} - -fn uninstall_git_pre_commit_hook( - context: &CliContext, - entry: &agentmesh_core::state::HookOwnershipEntry, - dry_run: bool, -) -> Result<()> { - let hook = context.repo_root.join(&entry.overlay_file); - let saved = context.repo_root.join(GIT_PRE_COMMIT_SAVED); - if dry_run { - if !context.silent { - let action = if saved.exists() { "restore" } else { "remove" }; - println!( - " {} Would {action} git pre-commit hook at {}", - context.paint(OutputStyle::Info, "→"), - hook.display() - ); - } - return Ok(()); - } - - if saved.exists() { - fs::rename(&saved, &hook).map_err(CliError::from_io)?; - if !context.silent { - println!( - " {} Restored original git pre-commit hook", - check(context, true) - ); - } - return Ok(()); - } - - match fs::read_to_string(&hook) { - Ok(content) if content.contains(GIT_PRE_COMMIT_MARKER) => { - fs::remove_file(&hook).map_err(CliError::from_io)?; - if !context.silent { - println!( - " {} Removed git pre-commit hook at {}", - check(context, true), - hook.display() - ); - } - } - Ok(_) => { - if !context.silent { - println!( - " {} Git pre-commit hook changed after install; leaving it untouched", - context.paint(OutputStyle::Warning, "⚠") - ); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(CliError::from_io(error)), - } - Ok(()) -} - -fn remove_runtime_hook_entries(context: &CliContext, runtime: &str) -> Result<()> { - let layout = cache_layout(&context.repo_root)?; - let ownership = match agentmesh_core::state::read_hook_ownership(&layout.hook_ownership_json) { - Ok(ownership) => ownership, - Err(agentmesh_core::state::StateError::Io { source, .. }) - if source.kind() == std::io::ErrorKind::NotFound => - { - return Ok(()); - } - Err(error) => return Err(CliError::new(error.to_string(), AgentmeshExitCode::Io)), - }; - let runtime_name = agentmesh_core::RuntimeName::new(runtime.to_string()) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Usage))?; - let Some(entry) = ownership.0.get(&runtime_name) else { - return Ok(()); - }; - remove_runtime_hook_entries_with_paths(context, runtime, entry.entry_paths.clone()).map(|_| ()) -} - -fn remove_runtime_hook_entries_with_paths( - context: &CliContext, - runtime: &str, - entry_paths: Vec, -) -> Result { - match runtime { - "claude" => agentmesh_adapter_claude::ClaudeAdapter.remove_hooks(RemoveHooksRequest { - runtime_dir: context.repo_root.join(".claude"), - entry_paths, - }), - "codex" => agentmesh_adapter_codex::CodexAdapter.remove_hooks(RemoveHooksRequest { - runtime_dir: context.repo_root.join(".codex"), - entry_paths, - }), - _ => Ok(agentmesh_protocol::RemoveHooksResponse { - ok: true, - removed_count: 0, - error: None, - }), - } - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter)) -} - -fn handle_drain_pending(context: &CliContext, _background: bool) -> Result { - let summary = agentmesh_core::sync_with_adapter_registry( - &context.repo_root, - agentmesh_core::SyncOptions { - drain_pending: true, - background: _background, - silent: context.silent, - ..agentmesh_core::SyncOptions::default() - }, - &CliAdapterRegistry, - ) - .map_err(map_core_error)?; - - if !context.silent { - println!("drainer: processed={}", summary.pending_drained); - } - Ok(AgentmeshExitCode::Success) -} - -fn spawn_background_drain(context: &CliContext) -> Result<()> { - if watcher_is_running(&context.repo_root) { - return Ok(()); - } - let executable = std::env::current_exe().map_err(CliError::from_io)?; - let mut command = ProcessCommand::new(&executable); - command - .arg("--cwd") - .arg(&context.repo_root) - .arg("sync") - .arg("--background") - .arg("--drain-pending") - .arg("--silent") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - command.spawn().map(|_| ()).map_err(|source| { - CliError::new( - format!( - "failed to spawn background drainer from {}: {source}", - executable.display() - ), - AgentmeshExitCode::Io, - ) - }) -} - -fn ensure_watcher_for_trigger(context: &CliContext, options: &ParsedSyncOptions) -> Result<()> { - if !matches!( - options.trigger, - SyncTrigger::ClaudeHook | SyncTrigger::CodexHook - ) { - return Ok(()); - } - if !hook_ownership_exists_for_trigger(context, &options.trigger)? { - return Ok(()); - } - if watcher_is_running(&context.repo_root) { - return Ok(()); - } - agentmesh_watcher::start( - &context.repo_root, - agentmesh_watcher::WatchOptions { - persistent: false, - foreground: false, - register_as_service: false, - ..agentmesh_watcher::WatchOptions::default() - }, - ) - .map(|_| ()) - .map_err(map_watcher_error) -} - -fn start_sync_watcher(context: &CliContext) -> Result<()> { - if std::env::var_os("AGENTMESH_DISABLE_WATCHER_AUTOSTART").is_some() { - return Ok(()); - } - - let handle = agentmesh_watcher::start( - &context.repo_root, - agentmesh_watcher::WatchOptions { - persistent: true, - foreground: false, - register_as_service: false, - ..agentmesh_watcher::WatchOptions::default() - }, - ) - .map_err(map_watcher_error)?; - - if !context.silent { - println!(" watcher: running"); - println!(" watcher state: {}", handle.state_file.display()); - println!(" watcher log: {}", handle.log_file.display()); - } - - Ok(()) -} - -fn hook_ownership_exists_for_trigger(context: &CliContext, trigger: &SyncTrigger) -> Result { - let runtime = match trigger { - SyncTrigger::ClaudeHook => "claude", - SyncTrigger::CodexHook => "codex", - _ => return Ok(false), - }; - let runtime = agentmesh_core::RuntimeName::new(runtime) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Usage))?; - let layout = cache_layout(&context.repo_root)?; - match agentmesh_core::state::read_hook_ownership(&layout.hook_ownership_json) { - Ok(ownership) => Ok(ownership.0.contains_key(&runtime)), - Err(agentmesh_core::state::StateError::Io { source, .. }) - if source.kind() == std::io::ErrorKind::NotFound => - { - Ok(false) - } - Err(error) => Err(CliError::new(error.to_string(), AgentmeshExitCode::Io)), - } -} - -fn watcher_is_running(repo_root: &Path) -> bool { - agentmesh_watcher::status(repo_root) - .map(|status| status.running) - .unwrap_or(false) -} - -fn cache_layout(repo_root: &Path) -> Result { - agentmesh_core::state::CacheLayout::new(&cache_root()?, repo_root) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Io)) -} - -fn cache_root() -> Result { - if let Some(path) = std::env::var_os("AGENTMESH_CACHE_DIR") { - return Ok(PathBuf::from(path)); - } - if let Some(path) = std::env::var_os("XDG_CACHE_HOME") { - return Ok(PathBuf::from(path).join("agentmesh")); - } - if let Some(path) = std::env::var_os("LOCALAPPDATA") { - return Ok(PathBuf::from(path).join("agentmesh")); - } - if let Some(path) = std::env::var_os("HOME") { - return Ok(PathBuf::from(path).join(".cache").join("agentmesh")); - } - Err(CliError::new( - "cannot determine machine-local cache directory", - AgentmeshExitCode::Io, - )) -} - -fn timestamp_string() -> String { - match SystemTime::now().duration_since(UNIX_EPOCH) { - Ok(duration) => format!( - "unix:{}.{:09}Z", - duration.as_secs(), - duration.subsec_nanos() - ), - Err(_) => "unix:0.000000000Z".to_string(), - } -} - -fn shell_quote_path(path: &Path) -> String { - let value = path.to_string_lossy(); - format!("'{}'", value.replace('\'', "'\"'\"'")) -} - -fn write_text_atomic_with_mode(path: &Path, content: &str, mode: Option) -> Result<()> { - let Some(parent) = path.parent() else { - return Err(CliError::new( - format!("cannot resolve parent directory for {}", path.display()), - AgentmeshExitCode::Io, - )); - }; - fs::create_dir_all(parent).map_err(CliError::from_io)?; - let temp = parent.join(format!(".agentmesh-{}.tmp", std::process::id())); - fs::write(&temp, content).map_err(CliError::from_io)?; - set_file_mode(&temp, mode)?; - fs::rename(&temp, path).map_err(CliError::from_io) -} - -fn file_mode(path: &Path) -> Result> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - let metadata = fs::metadata(path).map_err(CliError::from_io)?; - Ok(Some(metadata.permissions().mode() & 0o777)) - } - - #[cfg(not(unix))] - { - let _ = path; - Ok(None) - } -} - -fn hook_wrapper_mode(existing_mode: Option) -> Option { - #[cfg(unix)] - { - Some(existing_mode.unwrap_or(0o600) | 0o100) - } - - #[cfg(not(unix))] - { - let _ = existing_mode; - None - } -} - -fn set_file_mode(path: &Path, mode: Option) -> Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - if let Some(mode) = mode { - let mut permissions = fs::metadata(path).map_err(CliError::from_io)?.permissions(); - permissions.set_mode(mode); - fs::set_permissions(path, permissions).map_err(CliError::from_io)?; - } - } - - #[cfg(not(unix))] - { - let _ = path; - let _ = mode; - } - - Ok(()) -} - -fn sync_check_exit_code( - context: &CliContext, - summary: &agentmesh_core::SyncSummary, -) -> Result { - if !summary.changed { - return Ok(AgentmeshExitCode::Success); + summary: &agentmesh_core::SyncSummary, +) -> Result { + if !summary.changed { + return Ok(AgentmeshExitCode::Success); } let config = agentmesh_core::config::load_config(&context.repo_root) @@ -3461,11 +1959,14 @@ mod tests { use std::fs; use std::path::PathBuf; + use super::inspect::{ + HookOwnershipSnapshot, IntegritySnapshot, LockfileSnapshot, RepoSnapshot, RuntimeSnapshot, + WatcherSnapshot, status_json, + }; use super::{ - CanonicalInstructionSource, Cli, Command, HookOwnershipSnapshot, InitCommand, - IntegritySnapshot, LockfileSnapshot, ParsedInitOptions, ParsedSyncOptions, RepoSnapshot, - RuntimeSnapshot, SyncCommand, SyncTrigger, WatcherSnapshot, parsed_init_options, - parsed_sync_options, status_json, sync_check_exit_code, + CanonicalInstructionSource, Cli, Command, InitCommand, ParsedInitOptions, + ParsedSyncOptions, SyncCommand, SyncTrigger, parsed_init_options, parsed_sync_options, + sync_check_exit_code, }; use clap::{CommandFactory, Parser}; @@ -3635,7 +2136,7 @@ mod tests { panic!("rule file should be written: {error}"); } - let unknown = match super::inspect_unknown_runtime_dirs(temp.path()) { + let unknown = match super::inspect::inspect_unknown_runtime_dirs(temp.path()) { Ok(unknown) => unknown, Err(error) => panic!("unknown runtime scan should succeed: {error}"), }; From b6a807dd7cb1ab4de31b98c3aff157008a361a30 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 1 Jun 2026 17:42:57 +0200 Subject: [PATCH 5/7] fix: Update install.sh with macOS code-sign repair --- Makefile | 1 + installers/README.md | 12 +++-- installers/install.sh | 37 +++++++++++++-- installers/test-install.sh | 96 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 8 deletions(-) create mode 100755 installers/test-install.sh diff --git a/Makefile b/Makefile index a3e0c30..9bcb247 100644 --- a/Makefile +++ b/Makefile @@ -50,6 +50,7 @@ fuzz-check: installer-smoke: @sh installers/install.sh --smoke @sh installers/install.sh --upgrade-help + @sh installers/test-install.sh ci-rust: fmt-check check clippy test build bench-check fuzz-check @echo "$(GREEN)[SUCCESS]$(NC) Rust CI checks passed" diff --git a/installers/README.md b/installers/README.md index db518ec..d2688c1 100644 --- a/installers/README.md +++ b/installers/README.md @@ -2,14 +2,15 @@ This directory holds packaging wrappers around the single AgentMesh binary. -| Path | Purpose | -| ------------- | ---------------------------------------------- | -| `install.sh` | macOS and Linux installer | -| `install.ps1` | Windows installer | +| Path | Purpose | +| ------------- | ------------------------- | +| `install.sh` | macOS and Linux installer | +| `install.ps1` | Windows installer | Release installers resolve the current platform archive, verify it against the published `SHA256SUMS` manifest, verify the manifest signature and Sigstore bundle with cosign, and install -the single binary. +the single binary. The shell installer also verifies that the installed binary launches before +reporting success. Public docs: [agentmesh.sh/installation/curl](https://agentmesh.sh/installation/curl) @@ -102,6 +103,7 @@ Smoke checks run without network access: ```bash sh installers/install.sh --smoke sh installers/install.sh --upgrade-help +sh installers/test-install.sh pwsh -NoProfile -ExecutionPolicy Bypass -File installers/install.ps1 -Smoke pwsh -NoProfile -ExecutionPolicy Bypass -File installers/install.ps1 -UpgradeHelp ``` diff --git a/installers/install.sh b/installers/install.sh index 415283f..b9b3c00 100755 --- a/installers/install.sh +++ b/installers/install.sh @@ -442,6 +442,35 @@ verify_manifest_signature() { } } +repair_macos_codesign() { + binary_path="$1" + if ! command -v codesign >/dev/null 2>&1; then + echo "codesign is required to repair the macOS code signature for $binary_path" >&2 + return 127 + fi + output="$(codesign --force --sign - "$binary_path" 2>&1)" || { + printf '%s\n' "$output" >&2 + return 1 + } +} + +verify_installed_binary_launches() { + binary_path="$1" + if "$binary_path" --version >/dev/null 2>&1; then + return 0 + else + status="$?" + fi + + if [ "$(uname -s)" = "Darwin" ] && [ "$status" -eq 137 ]; then + repair_macos_codesign "$binary_path" || return "$?" + "$binary_path" --version >/dev/null 2>&1 + return "$?" + fi + + return "$status" +} + print_success_banner() { binary_path="$1" tag="$2" @@ -548,6 +577,7 @@ install_archive() { mkdir -p "$install_dir" chmod +x "$binary" run_install_step "Installing AgentMesh into $install_dir" cp "$binary" "$install_dir/$binary_name" + run_install_step "Verifying installed AgentMesh launches" verify_installed_binary_launches "$install_dir/$binary_name" print_success_banner "$install_dir/$binary_name" "$tag" case ":${PATH:-}:" in *":$install_dir:"*) ;; @@ -665,9 +695,10 @@ Usage: install.sh --smoke The installer downloads the platform archive, verifies it against SHA256SUMS, -verifies the SHA256SUMS signature with cosign, and installs the single binary. -Stable installs resolve the latest GitHub release by default. Set -AGENTMESH_VERSION=x.y.z to install a specific stable version. +verifies the SHA256SUMS signature with cosign, installs the single binary, and +checks that the installed binary launches. Stable installs resolve the latest +GitHub release by default. Set AGENTMESH_VERSION=x.y.z to install a specific +stable version. USAGE exit 0 ;; diff --git a/installers/test-install.sh b/installers/test-install.sh new file mode 100755 index 0000000..3e83f38 --- /dev/null +++ b/installers/test-install.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env sh +set -eu + +script_dir="$(CDPATH='' cd "$(dirname "$0")" && pwd)" +tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/agentmesh-installer-test.XXXXXX")" +trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM + +functions_file="$tmpdir/install-functions.sh" +awk '/^channel="stable"/ { exit } { print }' "$script_dir/install.sh" > "$functions_file" + +write_fake_uname() { + directory="$1" + cat > "$directory/uname" <<'EOF' +#!/usr/bin/env sh +if [ "${1:-}" = "-s" ]; then + printf '%s\n' Darwin + exit 0 +fi +exit 1 +EOF + chmod +x "$directory/uname" +} + +write_fake_codesign() { + directory="$1" + cat > "$directory/codesign" <<'EOF' +#!/usr/bin/env sh +for last do :; done +: > "$last.signed" +EOF + chmod +x "$directory/codesign" +} + +test_macos_sigkill_repairs_and_retries() { + case_dir="$tmpdir/sigkill" + mkdir -p "$case_dir" + write_fake_uname "$case_dir" + write_fake_codesign "$case_dir" + cat > "$case_dir/agentmesh" <<'EOF' +#!/usr/bin/env sh +if [ -f "$0.signed" ]; then + printf '%s\n' "agentmesh test" + exit 0 +fi +exit 137 +EOF + chmod +x "$case_dir/agentmesh" + + PATH="$case_dir:$PATH" sh -c \ + '. "$1"; verify_installed_binary_launches "$2"; test -f "$2.signed"' \ + sh "$functions_file" "$case_dir/agentmesh" +} + +test_non_sigkill_status_is_preserved() { + case_dir="$tmpdir/non-sigkill" + mkdir -p "$case_dir" + write_fake_uname "$case_dir" + write_fake_codesign "$case_dir" + cat > "$case_dir/agentmesh" <<'EOF' +#!/usr/bin/env sh +exit 42 +EOF + chmod +x "$case_dir/agentmesh" + + PATH="$case_dir:$PATH" sh -c ' + . "$1" + set +e + verify_installed_binary_launches "$2" + status="$?" + set -e + [ "$status" -eq 42 ] + [ ! -f "$2.signed" ] + ' sh "$functions_file" "$case_dir/agentmesh" +} + +test_success_does_not_codesign() { + case_dir="$tmpdir/success" + mkdir -p "$case_dir" + write_fake_uname "$case_dir" + write_fake_codesign "$case_dir" + cat > "$case_dir/agentmesh" <<'EOF' +#!/usr/bin/env sh +printf '%s\n' "agentmesh test" +EOF + chmod +x "$case_dir/agentmesh" + + PATH="$case_dir:$PATH" sh -c \ + '. "$1"; verify_installed_binary_launches "$2"; [ ! -f "$2.signed" ]' \ + sh "$functions_file" "$case_dir/agentmesh" +} + +test_macos_sigkill_repairs_and_retries +test_non_sigkill_status_is_preserved +test_success_does_not_codesign + +printf '%s\n' "agentmesh installer tests ok" From efbb5a300a552e2d913a917efb77693562ef2618 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 1 Jun 2026 17:56:52 +0200 Subject: [PATCH 6/7] fix: restore ci compatibility --- crates/agentmesh-core/src/pipeline/doctor.rs | 24 ++++++++++---------- deny.toml | 3 +++ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/crates/agentmesh-core/src/pipeline/doctor.rs b/crates/agentmesh-core/src/pipeline/doctor.rs index c686777..32c51bb 100644 --- a/crates/agentmesh-core/src/pipeline/doctor.rs +++ b/crates/agentmesh-core/src/pipeline/doctor.rs @@ -198,18 +198,18 @@ fn collect_sensitive_json_keys( warnings: &mut Vec, warning_count: &mut usize, ) { - if let Some(key) = key - && contains_sensitive_term(key) - { - push_privacy_warning( - warnings, - warning_count, - format!( - "override key `{key}` for `{}` at `{}` looks sensitive; keep secrets in machine-local config or environment variables", - entity_id.as_str(), - runtime.as_str() - ), - ); + if let Some(key) = key { + if contains_sensitive_term(key) { + push_privacy_warning( + warnings, + warning_count, + format!( + "override key `{key}` for `{}` at `{}` looks sensitive; keep secrets in machine-local config or environment variables", + entity_id.as_str(), + runtime.as_str() + ), + ); + } } match value { diff --git a/deny.toml b/deny.toml index 56c9178..478c736 100644 --- a/deny.toml +++ b/deny.toml @@ -19,6 +19,9 @@ allow = [ "Unlicense", "Zlib", ] +exceptions = [ + { allow = ["NCSA"], crate = "libfuzzer-sys" }, +] [bans] multiple-versions = "warn" From 1fe8c6653ec4c24a3df2a6c9a9c8b42d103d3e72 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 1 Jun 2026 18:02:04 +0200 Subject: [PATCH 7/7] fix: avoid windows test import warning --- crates/agentmesh-adapter-sdk-rust/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/agentmesh-adapter-sdk-rust/src/lib.rs b/crates/agentmesh-adapter-sdk-rust/src/lib.rs index 7a5a8a1..304cfa1 100644 --- a/crates/agentmesh-adapter-sdk-rust/src/lib.rs +++ b/crates/agentmesh-adapter-sdk-rust/src/lib.rs @@ -1173,8 +1173,7 @@ mod tests { use super::{ Adapter, AdapterError, AdapterMetadata, FormatTranslation, canonicalize_frontmatter, - collect_entity_files, log_notification, parse_frontmatter, run_adapter_with_io, - sha256_bytes, write_atomic, + log_notification, parse_frontmatter, run_adapter_with_io, sha256_bytes, write_atomic, }; use agentmesh_protocol::EntityType; use serde_norway::Value as YamlValue; @@ -1412,6 +1411,7 @@ mod tests { #[cfg(unix)] #[test] fn collect_entity_files_rejects_symlinked_paths() { + use super::collect_entity_files; use std::os::unix::fs::symlink; let temp = match tempfile::tempdir() {