Files
agent-manager/src/agent_config.rs
T
bruno ee490f5eaf fix: détection externe — champ detect (sonde PATH explicite) pour les lanceurs interpréteurs
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
2026-08-20 10:53:59 -04:00

519 lines
19 KiB
Rust

//! Post-install agent configuration (issue #91): the `config:` block of an
//! agent definition wires the resolved provider/model into the agent —
//! the environment variables it expects (env_map) and the config files to
//! write or softly edit (files).
//!
//! Pure functions + per-format tests (probe.rs pattern). Security rule: a
//! token is never written in clear — the api_key slot stays the `@secret`
//! reference (issue #89), resolved from the OS keyring at start/run time.
use crate::app::App;
use crate::config::{AgentDef, Config};
use anyhow::{anyhow, Context, Result};
use std::collections::BTreeMap;
use std::path::Path;
/// The four format families covering ~every agent (spec #91).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileFormat {
Toml,
Yaml,
Json,
KeyValue,
}
impl FileFormat {
pub fn name(self) -> &'static str {
match self {
FileFormat::Toml => "toml",
FileFormat::Yaml => "yaml",
FileFormat::Json => "json",
FileFormat::KeyValue => "key=value",
}
}
}
/// Detect the format family from the file extension.
pub fn format_of(path: &Path) -> Option<FileFormat> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"toml" => Some(FileFormat::Toml),
"yaml" | "yml" => Some(FileFormat::Yaml),
"json" => Some(FileFormat::Json),
"conf" | "ini" | "env" | "properties" | "cfg" => Some(FileFormat::KeyValue),
_ => None,
}
}
/// Provider preferred by an agent: `provider:` of the definition, then the
/// `config.provider_default` of its config block (issue #91).
pub fn provider_pref(agent: &AgentDef) -> Option<&str> {
agent
.provider
.as_deref()
.or(agent.config.as_ref().and_then(|c| c.provider_default.as_deref()))
}
/// Merge the resolved `config.env_map` of an agent into its default env
/// (issue #91): api_key -> `@secret` (resolved by resolve_env_secrets),
/// model/base_url/provider -> values of the resolved provider. Explicit
/// values already present in agent.env are never overwritten.
pub fn apply_env_map(env: &mut BTreeMap<String, String>, agent: &AgentDef, config: &Config) {
apply_env_map_resolved(env, agent, config, None, None);
}
/// Same as `apply_env_map` but with the provider and model fixed by the
/// caller (issue #92: `am run/start --provider/--model`): the flags win
/// over the agent declaration and the settings default.
pub fn apply_env_map_resolved(
env: &mut BTreeMap<String, String>,
agent: &AgentDef,
config: &Config,
provider_override: Option<&str>,
model_override: Option<&str>,
) {
let Some(cfg) = &agent.config else {
return;
};
if cfg.env_map.is_empty() {
return;
}
let Some(pname) = provider_override
.filter(|p| !p.is_empty())
.or_else(|| provider_pref(agent))
.or_else(|| crate::providers::default_name(config))
else {
return;
};
let Some(def) = crate::providers::get(config, pname) else {
return;
};
let model = model_override
.filter(|m| !m.is_empty())
.or(agent.model.as_deref())
.or(def.default_model.as_deref());
for (slot, var) in &cfg.env_map {
let value = match slot.as_str() {
"api_key" => "@secret".to_string(),
"model" => {
let Some(m) = model else { continue };
m.to_string()
}
"base_url" => def.base_url.clone(),
"provider" => pname.to_string(),
_ => continue, // unknown slots are ignored
};
env.entry(var.clone()).or_insert(value);
}
}
/// Write the agent configuration files after a successful install
/// (issue #91). The caller skips this on `--no-config`, on non-configurable
/// agents, and in dry-run (never reached). Never writes a token in clear:
/// the api_key slot is written as the `@secret` reference.
pub fn apply_post_install(
app: &App,
agent: &AgentDef,
resolved: &Option<(String, Option<String>)>,
) -> Result<()> {
let Some(cfg) = &agent.config else {
app.log.info(crate::i18n::tr(
"agent sans adaptateur de config — voir sa doc",
));
return Ok(());
};
if cfg.files.is_empty() {
if cfg.env_map.is_empty() {
app.log.info(crate::i18n::tr(
"agent sans adaptateur de config — voir sa doc",
));
} else {
app.log.info(crate::i18n::tr(
"config par variables d'env — injectée au démarrage",
));
}
return Ok(());
}
let Some((pname, model)) = resolved else {
app.log.warn(crate::i18n::tr(
"aucun provider résolu — fichiers de config non écrits",
));
return Ok(());
};
let Some(def) = crate::providers::get(&app.config, pname) else {
app.log.warn(&crate::tr_fmt!(
"provider '{}' introuvable — fichiers de config non écrits",
pname
));
return Ok(());
};
for f in &cfg.files {
let path = crate::config::expand_path(&f.path);
let Some(fmt) = format_of(&path) else {
app.log.warn(&crate::tr_fmt!(
"format inconnu pour {} — adaptateurs: toml, yaml, json, key=value",
path.display()
));
continue;
};
let mut updates: Vec<(String, String)> = Vec::new();
for key in &f.keys {
let value = match key.as_str() {
"api_key" => "@secret".to_string(),
"model" => model.clone().unwrap_or_default(),
"base_url" => def.base_url.clone(),
"provider" => pname.clone(),
other => {
app.log.warn(&crate::tr_fmt!(
"clé '{}' inconnue dans config.files de {}",
other,
agent.name
));
continue;
}
};
updates.push((key.clone(), value));
}
write_soft(&path, fmt, &updates)?;
app.log
.success(&crate::tr_fmt!("config écrite: {}", path.display()));
}
Ok(())
}
/// Soft edit of a config file: existing unknown keys are preserved, known
/// keys are updated, missing keys are appended. Creates parent dirs.
pub fn write_soft(path: &Path, format: FileFormat, updates: &[(String, String)]) -> Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
}
let text = if path.exists() {
std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?
} else {
String::new()
};
let out = match format {
FileFormat::Toml => edit_toml(&text, updates)?,
FileFormat::Yaml => edit_yaml(&text, updates)?,
FileFormat::Json => edit_json(&text, updates)?,
FileFormat::KeyValue => edit_key_value(&text, updates),
};
std::fs::write(path, out).with_context(|| format!("cannot write {}", path.display()))?;
Ok(())
}
/// TOML soft edit. Note: toml::Value does not retain comments on
/// serialization — unknown KEYS are preserved, comments are dropped.
fn edit_toml(text: &str, updates: &[(String, String)]) -> Result<String> {
let mut root: toml::Value = if text.trim().is_empty() {
toml::Value::Table(Default::default())
} else {
text.parse::<toml::Value>()
.map_err(|e| anyhow!("invalid TOML: {e}"))?
};
let table = root
.as_table_mut()
.ok_or_else(|| anyhow!("TOML root is not a table"))?;
for (k, v) in updates {
table.insert(k.clone(), toml::Value::String(v.clone()));
}
Ok(root.to_string())
}
fn edit_yaml(text: &str, updates: &[(String, String)]) -> Result<String> {
let mut root: serde_yaml::Value = if text.trim().is_empty() {
serde_yaml::Value::Mapping(Default::default())
} else {
serde_yaml::from_str(text).map_err(|e| anyhow!("invalid YAML: {e}"))?
};
let map = root
.as_mapping_mut()
.ok_or_else(|| anyhow!("YAML root is not a mapping"))?;
for (k, v) in updates {
map.insert(
serde_yaml::Value::String(k.clone()),
serde_yaml::Value::String(v.clone()),
);
}
serde_yaml::to_string(&root).map_err(|e| anyhow!("cannot serialize YAML: {e}"))
}
fn edit_json(text: &str, updates: &[(String, String)]) -> Result<String> {
let mut root: serde_json::Value = if text.trim().is_empty() {
serde_json::Value::Object(Default::default())
} else {
serde_json::from_str(text).map_err(|e| anyhow!("invalid JSON: {e}"))?
};
let obj = root
.as_object_mut()
.ok_or_else(|| anyhow!("JSON root is not an object"))?;
for (k, v) in updates {
obj.insert(k.clone(), serde_json::Value::String(v.clone()));
}
serde_json::to_string_pretty(&root).map_err(|e| anyhow!("cannot serialize JSON: {e}"))
}
fn edit_key_value(text: &str, updates: &[(String, String)]) -> String {
let mut lines: Vec<String> = text.lines().map(String::from).collect();
let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
for (i, line) in lines.iter_mut().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with(';')
|| !trimmed.contains('=')
{
continue;
}
let key = trimmed.split('=').next().unwrap_or("").trim();
// Case-insensitive match (API_KEY vs api_key in .env files); the
// existing line keeps its casing.
if let Some((k, v)) = updates.iter().find(|(k, _)| k.eq_ignore_ascii_case(key)) {
*line = format!("{key}={v}");
seen.insert(k, i);
}
}
for (k, v) in updates {
if !seen.contains_key(k.as_str()) {
lines.push(format!("{k}={v}"));
}
}
let mut out = lines.join("\n");
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use crate::config::{AgentConfig, Config};
use std::collections::BTreeMap;
fn updates() -> Vec<(String, String)> {
vec![
("model".to_string(), "gpt-5.2".to_string()),
("base_url".to_string(), "https://api.openai.com/v1".to_string()),
("api_key".to_string(), "@secret".to_string()),
]
}
fn tmp(name: &str, content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join(name);
if !content.is_empty() {
std::fs::write(&p, content).unwrap();
}
(dir, p)
}
#[test]
fn format_detection_covers_the_four_families() {
assert_eq!(format_of(Path::new("a.toml")), Some(FileFormat::Toml));
assert_eq!(format_of(Path::new("a.yaml")), Some(FileFormat::Yaml));
assert_eq!(format_of(Path::new("a.yml")), Some(FileFormat::Yaml));
assert_eq!(format_of(Path::new("a.json")), Some(FileFormat::Json));
assert_eq!(format_of(Path::new("a.conf")), Some(FileFormat::KeyValue));
assert_eq!(format_of(Path::new("a.env")), Some(FileFormat::KeyValue));
assert_eq!(format_of(Path::new("a.ini")), Some(FileFormat::KeyValue));
assert_eq!(format_of(Path::new("a.md")), None);
}
#[test]
fn toml_round_trip_keeps_unknown_keys() {
let (_d, p) = tmp("c.toml", "# existing\nmodel = \"old\"\ntemperature = 0.7\n");
write_soft(&p, FileFormat::Toml, &updates()).unwrap();
let out = std::fs::read_to_string(&p).unwrap();
assert!(out.contains("model = \"gpt-5.2\""), "{out}");
assert!(
out.contains("base_url = \"https://api.openai.com/v1\""),
"{out}"
);
assert!(out.contains("api_key = \"@secret\""), "{out}");
assert!(out.contains("temperature = 0.7"), "unknown key lost: {out}");
}
#[test]
fn toml_creates_the_file_when_missing() {
let (_d, p) = tmp("new.toml", "");
write_soft(&p, FileFormat::Toml, &updates()).unwrap();
let out = std::fs::read_to_string(&p).unwrap();
assert!(out.contains("model = \"gpt-5.2\""), "{out}");
assert!(out.contains("api_key = \"@secret\""), "{out}");
}
#[test]
fn yaml_round_trip_keeps_unknown_keys() {
let (_d, p) = tmp("c.yaml", "model: old\nverbose: true\n");
write_soft(&p, FileFormat::Yaml, &updates()).unwrap();
let out = std::fs::read_to_string(&p).unwrap();
assert!(out.contains("model: gpt-5.2"), "{out}");
assert!(out.contains("base_url: https://api.openai.com/v1"), "{out}");
assert!(out.contains("verbose: true"), "unknown key lost: {out}");
}
#[test]
fn json_round_trip_keeps_unknown_keys() {
let (_d, p) = tmp("c.json", "{\"model\": \"old\", \"verbose\": true}");
write_soft(&p, FileFormat::Json, &updates()).unwrap();
let out = std::fs::read_to_string(&p).unwrap();
assert!(out.contains("\"model\": \"gpt-5.2\""), "{out}");
assert!(out.contains("\"api_key\": \"@secret\""), "{out}");
assert!(out.contains("\"verbose\": true"), "unknown key lost: {out}");
}
#[test]
fn key_value_round_trip_keeps_unknown_lines() {
let (_d, p) = tmp(
"c.conf",
"# header\nMODEL=old\nOTHER=keep\n",
);
write_soft(&p, FileFormat::KeyValue, &updates()).unwrap();
let out = std::fs::read_to_string(&p).unwrap();
assert!(out.contains("MODEL=gpt-5.2"), "{out}");
assert!(out.contains("OTHER=keep"), "unknown line lost: {out}");
assert!(out.contains("# header"), "comment lost: {out}");
assert!(out.contains("api_key=@secret"), "{out}");
// No duplicate MODEL line.
assert_eq!(out.matches("MODEL=").count(), 1, "{out}");
}
#[test]
fn key_value_appends_missing_keys() {
let (_d, p) = tmp("empty.env", "");
write_soft(&p, FileFormat::KeyValue, &updates()).unwrap();
let out = std::fs::read_to_string(&p).unwrap();
assert!(out.contains("model=gpt-5.2"), "{out}");
assert!(out.contains("base_url=https://api.openai.com/v1"), "{out}");
assert!(out.contains("api_key=@secret"), "{out}");
}
fn agent_with_config(config: AgentConfig, provider: Option<String>) -> AgentDef {
AgentDef {
name: "x".to_string(),
display_name: None,
description: None,
category: None,
website: None,
install: None,
dependencies: vec![],
run: Some("x".to_string()),
detect: None,
args: vec![],
env: BTreeMap::new(),
version: None,
pin_version: None,
model_env: None,
model_arg: None,
provider,
model: None,
config: Some(config),
sandbox: None,
tags: vec![],
installable: false,
note: None,
hidden: false,
platforms: vec![],
healthcheck: None,
container: None,
cost_model: None,
}
}
#[test]
fn apply_env_map_wires_the_resolved_provider() {
let mut c: Config = serde_yaml::from_str(
"version: \"1.0\"\nsettings:\n default_provider: openai\n providers:\n openai:\n base_url: https://api.openai.com/v1\n default_model: gpt-5.2\n models: [gpt-5.2]\n",
)
.unwrap();
let mut env_map = BTreeMap::new();
env_map.insert("api_key".to_string(), "MY_KEY".to_string());
env_map.insert("model".to_string(), "MY_MODEL".to_string());
env_map.insert("base_url".to_string(), "MY_URL".to_string());
let agent = agent_with_config(
AgentConfig {
env_map,
files: vec![],
provider_default: None,
},
None,
);
let mut env = BTreeMap::new();
apply_env_map(&mut env, &agent, &c);
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
// Explicit agent.env values win.
let mut env2 = BTreeMap::new();
env2.insert("MY_MODEL".to_string(), "explicit".to_string());
apply_env_map(&mut env2, &agent, &c);
assert_eq!(env2.get("MY_MODEL").unwrap(), "explicit");
// Unset default provider + no provider_default: no-op.
c.settings.default_provider = None;
let mut env3 = BTreeMap::new();
apply_env_map(&mut env3, &agent, &c);
assert!(env3.is_empty());
}
#[test]
fn apply_post_install_without_config_block_is_a_noop() {
let guard = tempfile::tempdir().unwrap();
let dir = guard.path().to_path_buf();
std::mem::forget(guard);
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
)
.unwrap();
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
let app = crate::app::App::from_cli(cli).unwrap();
let agent = agent_with_config(
AgentConfig {
env_map: BTreeMap::new(),
files: vec![],
provider_default: None,
},
None,
);
// No config block at all → Ok, nothing written.
let mut bare = agent.clone();
bare.config = None;
assert!(apply_post_install(&app, &bare, &None).is_ok());
// Config block but no files → Ok, nothing written.
assert!(apply_post_install(&app, &agent, &None).is_ok());
}
#[test]
fn provider_pref_prefers_the_declared_provider() {
let agent = agent_with_config(
AgentConfig {
env_map: BTreeMap::new(),
files: vec![],
provider_default: Some("openai".to_string()),
},
Some("anthropic".to_string()),
);
assert_eq!(provider_pref(&agent), Some("anthropic"));
let agent2 = agent_with_config(
AgentConfig {
env_map: BTreeMap::new(),
files: vec![],
provider_default: Some("openai".to_string()),
},
None,
);
assert_eq!(provider_pref(&agent2), Some("openai"));
}
}