From ee490f5eafdd47f9271c7d48fafecb16a8a3bd84 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 20 Aug 2026 10:53:50 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20d=C3=A9tection=20externe=20=E2=80=94=20c?= =?UTF-8?q?hamp=20detect=20(sonde=20PATH=20explicite)=20pour=20les=20lance?= =?UTF-8?q?urs=20interpr=C3=A9teurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lazycodex (run: npx lazycodex-ai) et nanobot (run: python -m nanobot) étaient déclarés « external » dès que npx/python étaient sur le PATH : la sonde utilisait le premier mot de run au lieu du vrai binaire. - AgentDef.detect : sonde PATH explicite, repli sur first_token sinon - probe.rs : cache invalidé quand les tokens du catalogue changent (token_hash), plus besoin d'attendre un changement de PATH - config.yaml : detect: lazycodex-ai / detect: nanobot - tests : detect_token_prefers_explicit_binary + 432 tests verts - v1.0.3, man pages régénérées --- Cargo.lock | 2 +- Cargo.toml | 2 +- ROADMAP.md | 6 ++++-- config.yaml | 6 ++++++ man/am.1 | 4 ++-- src/agent_config.rs | 1 + src/commands/run_cmd.rs | 1 + src/commands/suggest_cmd.rs | 1 + src/config.rs | 38 +++++++++++++++++++++++++++++++++++++ src/probe.rs | 38 ++++++++++++++++++++++++++++++++----- 10 files changed, 88 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 28043d5..67ded37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "1.0.2" +version = "1.0.3" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index c1f7a8f..3b4c073 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "1.0.2" +version = "1.0.3" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" diff --git a/ROADMAP.md b/ROADMAP.md index ac3e85d..0d109d6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,8 +29,10 @@ > am serve (API HTTP + WebSocket). v1.0.1 : self-update décompresse les > archives, prompt REPL avec shell actif. > -> 🔧 **Maintenance v1.0.2 (2026-08-20)** : commandes REPL ask/serve/registry -> routées vers am (au lieu du shell système) ; docs alignées. +> 🔧 **Maintenance v1.0.3 (2026-08-20)** : commandes REPL ask/serve/registry +> routées vers am (v1.0.2) ; détection externe corrigée — champ `detect` +> (sonde PATH explicite) pour les lanceurs interpréteurs (npx/python), +> plus de faux « external » ; docs alignées. --- diff --git a/config.yaml b/config.yaml index 621261b..a60e52c 100644 --- a/config.yaml +++ b/config.yaml @@ -798,6 +798,9 @@ agents: dependencies: - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } run: "npx lazycodex-ai" + # Sonde PATH explicite : le premier mot de `run` est npx (interpréteur) — + # sans `detect`, npx sur le PATH suffirait à déclarer l'agent « external ». + detect: lazycodex-ai note: "s'intègre dans Codex (nécessite @openai/codex)" tags: [harness, codex, plan] @@ -895,6 +898,9 @@ agents: dependencies: - { name: python, min_version: "3.9.0", install_hint: "https://python.org" } run: "python -m nanobot" + # Sonde PATH explicite : le premier mot de `run` est python (interpréteur) — + # sans `detect`, /usr/bin/python suffirait à déclarer l'agent « external ». + detect: nanobot tags: [python, lightweight] - name: zeroclaw diff --git a/man/am.1 b/man/am.1 index 51518da..5b7c653 100644 --- a/man/am.1 +++ b/man/am.1 @@ -1,6 +1,6 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH am 1 "am 1.0.2" +.TH am 1 "am 1.0.3" .SH NAME am \- agent\-manager (am) — manage local AI coding agents .SH SYNOPSIS @@ -244,4 +244,4 @@ Export the configuration and installation state (backup) am\-import(1) Import a previously exported configuration and state .SH VERSION -v1.0.2 +v1.0.3 diff --git a/src/agent_config.rs b/src/agent_config.rs index 605a1dd..344de48 100644 --- a/src/agent_config.rs +++ b/src/agent_config.rs @@ -408,6 +408,7 @@ mod tests { install: None, dependencies: vec![], run: Some("x".to_string()), + detect: None, args: vec![], env: BTreeMap::new(), version: None, diff --git a/src/commands/run_cmd.rs b/src/commands/run_cmd.rs index 71d34d8..e84fff4 100644 --- a/src/commands/run_cmd.rs +++ b/src/commands/run_cmd.rs @@ -655,6 +655,7 @@ mod tests { install: None, dependencies: vec![], run: Some("demo".to_string()), + detect: None, args: vec![], env: BTreeMap::new(), version: None, diff --git a/src/commands/suggest_cmd.rs b/src/commands/suggest_cmd.rs index 4dd470e..a97f7e1 100644 --- a/src/commands/suggest_cmd.rs +++ b/src/commands/suggest_cmd.rs @@ -190,6 +190,7 @@ mod tests { install: None, dependencies: vec![], run: Some(name.to_string()), + detect: None, args: vec![], env: BTreeMap::new(), version: None, diff --git a/src/config.rs b/src/config.rs index 463fbc6..6dfd40e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -398,6 +398,13 @@ pub struct AgentDef { /// Command used to start the agent (e.g. "claude", "python -m nanobot"). #[serde(default)] pub run: Option, + /// Binary name probed on the PATH for external detection. When unset, + /// the first token of `run` is used — which is wrong for interpreter + /// launchers ("npx pkg", "python -m pkg"): the interpreter itself would + /// be matched, reporting the agent as present whenever node/python is + /// on the PATH. Set it to the real entry point (e.g. "lazycodex-ai"). + #[serde(default)] + pub detect: Option, /// Default arguments appended to the run command. #[serde(default)] pub args: Vec, @@ -516,6 +523,12 @@ impl AgentDef { }) } + /// Binary probed on the PATH for external detection: the explicit + /// `detect` name when set, otherwise the first token of `run`. + pub fn detect_token(&self) -> Option { + self.detect.clone().or_else(|| self.first_token()) + } + /// The run command split into tokens (empty when unset). pub fn run_tokens(&self) -> Vec { self.run @@ -1167,6 +1180,31 @@ mod tests { assert!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg)); } + #[test] + fn detect_token_prefers_explicit_binary() { + // Interpreter launcher: without `detect`, the interpreter itself + // would be probed (false "external" when node/python is on PATH). + let a: AgentDef = serde_yaml::from_str( + "name: lazycodex\nrun: \"npx lazycodex-ai\"\ndetect: lazycodex-ai\n", + ) + .unwrap(); + assert_eq!(a.detect_token().as_deref(), Some("lazycodex-ai")); + // No `detect`: falls back to the first token of run. + let b: AgentDef = + serde_yaml::from_str("name: nanobot\nrun: \"python -m nanobot\"\n").unwrap(); + assert_eq!(b.detect_token().as_deref(), Some("python")); + assert_eq!(b.first_token().as_deref(), Some("python")); + // Plain binary command is unchanged. + let c: AgentDef = serde_yaml::from_str("name: claude\nrun: claude\n").unwrap(); + assert_eq!(c.detect_token().as_deref(), Some("claude")); + // The embedded catalog must stay valid with the new field. + let cfg: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap(); + let lazy = cfg.agents.iter().find(|a| a.name == "lazycodex").unwrap(); + assert_eq!(lazy.detect_token().as_deref(), Some("lazycodex-ai")); + let nano = cfg.agents.iter().find(|a| a.name == "nanobot").unwrap(); + assert_eq!(nano.detect_token().as_deref(), Some("nanobot")); + } + #[test] fn expands_paths() { std::env::set_var("AM_TEST_EXPAND_VAR", "expanded-value"); diff --git a/src/probe.rs b/src/probe.rs index a2a3645..92ea656 100644 --- a/src/probe.rs +++ b/src/probe.rs @@ -79,6 +79,11 @@ struct ProbeCache { /// FNV-1a hash of the PATH string the token index was built from. #[serde(default)] path_hash: u64, + /// FNV-1a hash of the catalog's detect tokens. When the catalog changes + /// which binaries it probes (new agent, `detect` added), the token + /// index must be rebuilt even if the PATH is unchanged. + #[serde(default)] + token_hash: u64, /// Catalog run-command first token -> resolved path (null = not found). #[serde(default)] tokens: BTreeMap>, @@ -98,6 +103,26 @@ fn path_hash() -> u64 { h } +/// Hash of the sorted catalog detect tokens: cache invalidation for catalog +/// changes (new agent, added `detect` field) without re-scanning the PATH. +fn catalog_token_hash(app: &App) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut tokens: Vec = app + .catalog + .agents() + .iter() + .filter_map(|a| a.detect_token()) + .collect(); + tokens.sort(); + for t in tokens { + for b in t.bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + } + h +} + /// Build an index of every executable on the PATH in a single pass. /// Much cheaper than one which() call per agent when the catalog is large. fn build_path_index() -> BTreeMap { @@ -163,13 +188,15 @@ pub fn detect_externals(app: &App) -> ExternalInfo { .unwrap_or_default(); let mut dirty = false; - // Resolve the run-command tokens of the catalog (once per PATH change). + // Resolve the detect tokens of the catalog (once per PATH change or + // catalog change). let current_hash = path_hash(); - if cache.path_hash != current_hash { + let current_tokens = catalog_token_hash(app); + if cache.path_hash != current_hash || cache.token_hash != current_tokens { let index = build_path_index(); cache.tokens.clear(); for agent in app.catalog.agents() { - let Some(token) = agent.first_token() else { + let Some(token) = agent.detect_token() else { continue; }; if !cache.tokens.contains_key(&token) { @@ -180,6 +207,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo { } } cache.path_hash = current_hash; + cache.token_hash = current_tokens; dirty = true; } @@ -195,7 +223,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo { let index = build_path_index(); cache.tokens.clear(); for agent in app.catalog.agents() { - let Some(token) = agent.first_token() else { + let Some(token) = agent.detect_token() else { continue; }; if !cache.tokens.contains_key(&token) { @@ -211,7 +239,7 @@ pub fn detect_externals(app: &App) -> ExternalInfo { if agent.hidden || !agent.installable || managed.contains_key(&agent.name) { continue; } - let Some(token) = agent.first_token() else { + let Some(token) = agent.detect_token() else { continue; }; if let Some(Some(path)) = cache.tokens.get(&token) {