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
519 lines
19 KiB
Rust
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"));
|
|
}
|
|
}
|