- src/sandbox.rs : profile_of + enforce (basename/stem allowlist, cwd starts_with périmètre, proxy env si network:false), EventKind::Sandbox - enforcement au run et au start (start_one, --parallel, restart) ; AgentDef.sandbox + doc config.yaml - 6 tests : défaut non sandboxé, refus + journalisation, stem .exe, périmètre cwd, env réseau, bypass --no-sandbox
760 lines
28 KiB
Rust
760 lines
28 KiB
Rust
//! start, stop, restart and run: process management commands.
|
|
|
|
use super::*;
|
|
use crate::cli::StartArgs;
|
|
use crate::events::{self, Event, EventKind};
|
|
use crate::process;
|
|
use crate::sessions;
|
|
use anyhow::{bail, Context, Result};
|
|
use std::collections::BTreeMap;
|
|
use std::ffi::OsString;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
|
|
/// Resolve the start target: the explicit agent/alias/group when given,
|
|
/// otherwise the default agent of the project containing cwd (issue #30).
|
|
pub fn resolve_start_target(app: &App, cwd: &std::path::Path, agent: Option<&str>) -> Result<String> {
|
|
if let Some(a) = agent.filter(|s| !s.is_empty()) {
|
|
return Ok(a.to_string());
|
|
}
|
|
crate::projects::default_agent_for_dir(app, cwd).ok_or_else(|| {
|
|
anyhow!(
|
|
"no agent given — set default_agent in this project's profile ('am init' does it) or pass an agent name"
|
|
)
|
|
})
|
|
}
|
|
|
|
pub fn start(app: &App, opts: &StartArgs) -> Result<i32> {
|
|
start_with_cwd(app, opts, None)
|
|
}
|
|
|
|
/// Apply the environment profile (issue #40): the explicit --profile wins;
|
|
/// otherwise the current project's default profile applies when launching
|
|
/// the project's default agent.
|
|
fn apply_profile(
|
|
app: &App,
|
|
explicit: Option<&str>,
|
|
project_default_agent: bool,
|
|
extra_args: &[String],
|
|
extra_env: &BTreeMap<String, String>,
|
|
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
|
|
let mut profile = explicit;
|
|
if profile.is_none() && project_default_agent {
|
|
let current = std::env::current_dir().unwrap_or_default();
|
|
if let Some((_, pp)) = crate::projects::profile_for(app, ¤t) {
|
|
profile = pp.profile.as_deref();
|
|
}
|
|
}
|
|
crate::commands::profile_cmd::apply(app, profile, extra_args, extra_env)
|
|
}
|
|
|
|
/// Start with an explicit working directory (used by sessions resume).
|
|
pub fn start_with_cwd(app: &App, opts: &StartArgs, cwd: Option<&std::path::Path>) -> Result<i32> {
|
|
let current = std::env::current_dir().unwrap_or_default();
|
|
let explicit = opts.agent.as_deref().filter(|s| !s.is_empty());
|
|
// A profile may declare its own default agent (issue #40).
|
|
let profile_default = profile_cmd::default_agent(app, opts.profile.as_deref())?;
|
|
let target = match (explicit, profile_default) {
|
|
(Some(a), _) => a.to_string(),
|
|
(None, Some(a)) => a.to_string(),
|
|
(None, None) => resolve_start_target(app, ¤t, None)?,
|
|
};
|
|
let extra_env = parse_env_list(&opts.env)?;
|
|
let extra_args = parse_extra_args(&opts.args);
|
|
let project_default_agent = explicit.is_none() && profile_default.is_none();
|
|
let (extra_args, extra_env) = apply_profile(
|
|
app,
|
|
opts.profile.as_deref(),
|
|
project_default_agent,
|
|
&extra_args,
|
|
&extra_env,
|
|
)?;
|
|
// --model (issue #71): resolve against the local runtimes, then
|
|
// transmit through the agent's declared channel (env var or flag).
|
|
// --provider (issue #92): resolve from the LLM provider registry.
|
|
let mut extra_args = extra_args;
|
|
let mut extra_env = extra_env;
|
|
if let Some(p) = opts.provider.as_deref() {
|
|
let agent = require_agent(app, &target)?;
|
|
let (a, e) = apply_provider(app, agent, Some(p), opts.model.as_deref())?
|
|
.expect("a provider flag was given: apply_provider always resolves or errors");
|
|
extra_args.extend(a);
|
|
extra_env.extend(e);
|
|
} else if let Some(m) = opts.model.as_deref() {
|
|
let agent = require_agent(app, &target)?;
|
|
match apply_model(app, agent, m) {
|
|
Ok((a, e)) => {
|
|
extra_args.extend(a);
|
|
extra_env.extend(e);
|
|
}
|
|
Err(local_err) => match apply_provider(app, agent, None, Some(m))? {
|
|
Some((a, e)) => {
|
|
extra_args.extend(a);
|
|
extra_env.extend(e);
|
|
}
|
|
None => return Err(local_err),
|
|
},
|
|
}
|
|
}
|
|
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
|
|
let members = app.catalog.group_members(group);
|
|
if members.is_empty() {
|
|
bail!("unknown group '{group}'");
|
|
}
|
|
// Issue #57: --parallel launches every member back-to-back without
|
|
// waiting (the OS spawns them concurrently); the default waits each
|
|
// health check before the next member.
|
|
for m in members {
|
|
start_one(app, m, &extra_args, &extra_env, opts.notify, true, opts.no_sandbox, cwd)?;
|
|
if !opts.parallel {
|
|
// Issue #57: block until healthy before the next member.
|
|
crate::automation::wait_health(&m.name, m.healthcheck.as_ref(), &app.log)?;
|
|
}
|
|
}
|
|
return Ok(0);
|
|
}
|
|
let agent = require_agent(app, &target)?;
|
|
start_one(
|
|
app,
|
|
agent,
|
|
&extra_args,
|
|
&extra_env,
|
|
opts.notify,
|
|
opts.background,
|
|
opts.no_sandbox,
|
|
cwd,
|
|
)
|
|
}
|
|
|
|
fn start_one(
|
|
app: &App,
|
|
agent: &AgentDef,
|
|
extra_args: &[String],
|
|
extra_env: &BTreeMap<String, String>,
|
|
notify: bool,
|
|
background: bool,
|
|
no_sandbox: bool,
|
|
cwd: Option<&std::path::Path>,
|
|
) -> Result<i32> {
|
|
let exec = resolve_exec(app, agent, extra_args, extra_env)?;
|
|
let bin = PathBuf::from(&exec.program);
|
|
let is_managed = app.state.get(&agent.name).ok().flatten().is_some();
|
|
let ctx = crate::context::detect(&std::env::current_dir().unwrap_or_default());
|
|
let current = std::env::current_dir().unwrap_or_default();
|
|
crate::hooks::run_hooks(app, "on_start", cwd.unwrap_or(¤t));
|
|
if background {
|
|
// Issue #79: sandbox enforcement before spawning.
|
|
let mut env = exec.env.clone();
|
|
crate::sandbox::enforce(app, agent, &exec.program, &mut env, no_sandbox)?;
|
|
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &env, cwd)?;
|
|
if app.dry_run() {
|
|
return Ok(0);
|
|
}
|
|
if is_managed {
|
|
app.state
|
|
.update_pid(&agent.name, Some(pid), Some(crate::installers::now_rfc3339()))?;
|
|
} else {
|
|
app.log.warn(&format!(
|
|
"agent '{}' is external (not installed by agent-manager); PID {pid} is not recorded",
|
|
agent.name
|
|
));
|
|
}
|
|
app.emit(
|
|
&Event::now(EventKind::Start)
|
|
.with_agent(agent.name.clone())
|
|
.with_pid(pid)
|
|
.with_session(crate::repl::session_id())
|
|
.with_cwd(cwd_string())
|
|
.with_project(ctx.root.clone())
|
|
.with_args(exec.args.clone())
|
|
.with_env_keys(events::env_keys(&exec.env)),
|
|
);
|
|
if is_managed {
|
|
let _ = app.state.touch(&agent.name);
|
|
}
|
|
let log = process::agent_log_path(app, &agent.name);
|
|
let _ = sessions::start_agent(
|
|
app,
|
|
&agent.name,
|
|
pid,
|
|
cwd_string(),
|
|
ctx.root.clone(),
|
|
exec.args.clone(),
|
|
events::env_keys(&exec.env),
|
|
Some(log.display().to_string()),
|
|
);
|
|
app.log.success(&format!(
|
|
"{} started (pid {pid}); log: {}",
|
|
agent.title(),
|
|
log.display()
|
|
));
|
|
if notify {
|
|
process::desktop_notify("agent-manager", &format!("{} started", agent.title()));
|
|
}
|
|
Ok(0)
|
|
} else {
|
|
if app.dry_run() {
|
|
app.log.dry(format!(
|
|
"would run {} {} (foreground)",
|
|
exec.program,
|
|
exec.args.join(" ")
|
|
));
|
|
return Ok(0);
|
|
}
|
|
let started = std::time::Instant::now();
|
|
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
|
let mut full_args = prefix;
|
|
full_args.extend(exec.args.iter().cloned());
|
|
let mut cmd = Command::new(&prog);
|
|
cmd.args(&full_args).envs(&exec.env);
|
|
if let Some(dir) = cwd {
|
|
cmd.current_dir(dir);
|
|
}
|
|
let status = cmd
|
|
.status()
|
|
.with_context(|| format!("failed to run {}", exec.program))?;
|
|
let code = status.code().unwrap_or(1);
|
|
app.emit(
|
|
&Event::now(EventKind::Run)
|
|
.with_agent(agent.name.clone())
|
|
.with_cwd(cwd_string())
|
|
.with_project(ctx.root.clone())
|
|
.with_args(exec.args.clone())
|
|
.with_env_keys(events::env_keys(&exec.env))
|
|
.with_exit_code(code)
|
|
.with_duration(started.elapsed().as_secs()),
|
|
);
|
|
let _ = app.state.touch(&agent.name);
|
|
Ok(code)
|
|
}
|
|
}
|
|
|
|
pub fn stop(app: &App, target: &str, force: bool, timeout: Option<u64>) -> Result<i32> {
|
|
let timeout = timeout.unwrap_or_else(|| app.config.settings.stop_timeout());
|
|
if let Some(group) = crate::catalog::Catalog::parse_group_selector(target) {
|
|
let members = app.catalog.group_members(group);
|
|
if members.is_empty() {
|
|
bail!("unknown group '{group}'");
|
|
}
|
|
for m in members.iter().rev() {
|
|
stop_one(app, m, force, timeout)?;
|
|
}
|
|
return Ok(0);
|
|
}
|
|
let agent = require_agent(app, target)?;
|
|
stop_one(app, agent, force, timeout)
|
|
}
|
|
|
|
fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i32> {
|
|
let Some(entry) = app.state.get(&agent.name).ok().flatten() else {
|
|
app.log.info(&format!(
|
|
"agent '{}' is not managed by agent-manager; nothing to stop",
|
|
agent.name
|
|
));
|
|
return Ok(0);
|
|
};
|
|
let Some(pid) = entry.pid else {
|
|
app.log.info(&format!("agent '{}' is not running", agent.name));
|
|
return Ok(0);
|
|
};
|
|
if app.dry_run() {
|
|
app.log.dry(format!(
|
|
"would stop pid {pid} ({}{})",
|
|
agent.name,
|
|
if force { "force" } else { "graceful" }
|
|
));
|
|
return Ok(0);
|
|
}
|
|
if !process::is_running(pid) {
|
|
app.log.warn(&format!(
|
|
"stale PID {pid} for '{}' (process no longer running); cleaning up",
|
|
agent.name
|
|
));
|
|
app.emit(
|
|
&Event::now(EventKind::Stop)
|
|
.with_agent(agent.name.clone())
|
|
.with_pid(pid)
|
|
.with_reason("stale"),
|
|
);
|
|
let _ = sessions::finish_agent(app, &agent.name, Some(pid), 0, true);
|
|
app.state.update_pid(&agent.name, None, None)?;
|
|
let current = std::env::current_dir().unwrap_or_default();
|
|
crate::hooks::run_hooks(app, "on_stop", ¤t);
|
|
return Ok(0);
|
|
}
|
|
app.log.info(&format!("stopping '{}' (pid {pid})", agent.name));
|
|
let stopped = process::stop_pid(pid, force, timeout);
|
|
let duration = seconds_since(entry.started_at.as_deref());
|
|
let mut ev = Event::now(EventKind::Stop)
|
|
.with_agent(agent.name.clone())
|
|
.with_pid(pid)
|
|
.with_reason(if force { "force" } else { "signal" })
|
|
.with_exit_code(if stopped { 0 } else { 1 });
|
|
if let Some(d) = duration {
|
|
ev = ev.with_duration(d);
|
|
}
|
|
app.emit(&ev);
|
|
let _ = sessions::finish_agent(app, &agent.name, Some(pid), if stopped { 0 } else { 1 }, stopped);
|
|
app.state.update_pid(&agent.name, None, None)?;
|
|
let current = std::env::current_dir().unwrap_or_default();
|
|
crate::hooks::run_hooks(app, "on_stop", ¤t);
|
|
if stopped {
|
|
app.log.success(&format!("{} stopped", agent.title()));
|
|
Ok(0)
|
|
} else {
|
|
app.log.error(&format!("failed to stop pid {pid}"));
|
|
Ok(1)
|
|
}
|
|
}
|
|
|
|
pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -> Result<i32> {
|
|
let timeout = timeout.unwrap_or_else(|| app.config.settings.stop_timeout());
|
|
let cwd = std::env::current_dir().unwrap_or_default();
|
|
let explicit = opts.agent.as_deref().filter(|s| !s.is_empty());
|
|
let profile_default = profile_cmd::default_agent(app, opts.profile.as_deref())?;
|
|
let target = match (explicit, profile_default) {
|
|
(Some(a), _) => a.to_string(),
|
|
(None, Some(a)) => a.to_string(),
|
|
(None, None) => resolve_start_target(app, &cwd, None)?,
|
|
};
|
|
let extra_env = parse_env_list(&opts.env)?;
|
|
let extra_args = parse_extra_args(&opts.args);
|
|
let project_default_agent = explicit.is_none() && profile_default.is_none();
|
|
let (extra_args, extra_env) = apply_profile(
|
|
app,
|
|
opts.profile.as_deref(),
|
|
project_default_agent,
|
|
&extra_args,
|
|
&extra_env,
|
|
)?;
|
|
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&target) {
|
|
let members = app.catalog.group_members(group);
|
|
if members.is_empty() {
|
|
bail!("unknown group '{group}'");
|
|
}
|
|
for m in members.iter().rev() {
|
|
stop_one(app, m, force, timeout)?;
|
|
}
|
|
for m in members {
|
|
start_one(
|
|
app,
|
|
m,
|
|
&extra_args,
|
|
&extra_env,
|
|
opts.notify,
|
|
true,
|
|
opts.no_sandbox,
|
|
None,
|
|
)?;
|
|
}
|
|
return Ok(0);
|
|
}
|
|
let agent = require_agent(app, &target)?;
|
|
stop_one(app, agent, force, timeout)?;
|
|
start_one(
|
|
app,
|
|
agent,
|
|
&extra_args,
|
|
&extra_env,
|
|
opts.notify,
|
|
opts.background,
|
|
opts.no_sandbox,
|
|
None,
|
|
)
|
|
}
|
|
|
|
/// run: execute the agent command directly, no process management.
|
|
pub fn run(
|
|
app: &App,
|
|
target: &str,
|
|
model: Option<&str>,
|
|
provider: Option<&str>,
|
|
container: bool,
|
|
no_sandbox: bool,
|
|
extra: &[OsString],
|
|
) -> Result<i32> {
|
|
let agent = require_agent(app, target)?;
|
|
// Issue #58: --container runs the agent inside its container profile.
|
|
if container {
|
|
return run_container(app, agent, extra);
|
|
}
|
|
let mut extra_args: Vec<String> = extra
|
|
.iter()
|
|
.map(|o| o.to_string_lossy().to_string())
|
|
.collect();
|
|
let mut extra_env: BTreeMap<String, String> = BTreeMap::new();
|
|
// Issue #92: --provider resolves from the LLM provider registry (cloud);
|
|
// --model alone keeps the local-runtimes-first behavior (issue #71) with
|
|
// a cloud fallback when the model is unknown locally.
|
|
if let Some(p) = provider {
|
|
let (a, e) = apply_provider(app, agent, Some(p), model)?
|
|
.expect("a provider flag was given: apply_provider always resolves or errors");
|
|
extra_args.extend(a);
|
|
extra_env.extend(e);
|
|
} else if let Some(m) = model {
|
|
match apply_model(app, agent, m) {
|
|
Ok((a, e)) => {
|
|
extra_args.extend(a);
|
|
extra_env.extend(e);
|
|
}
|
|
Err(local_err) => match apply_provider(app, agent, None, Some(m))? {
|
|
Some((a, e)) => {
|
|
extra_args.extend(a);
|
|
extra_env.extend(e);
|
|
}
|
|
None => return Err(local_err),
|
|
},
|
|
}
|
|
}
|
|
let mut exec = resolve_exec(app, agent, &extra_args, &extra_env)?;
|
|
if app.dry_run() {
|
|
app.log.dry(format!(
|
|
"would run {} {}",
|
|
exec.program,
|
|
exec.args.join(" ")
|
|
));
|
|
return Ok(0);
|
|
}
|
|
let started = std::time::Instant::now();
|
|
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
|
|
let mut full_args = prefix;
|
|
full_args.extend(exec.args.iter().cloned());
|
|
// Issue #79: sandbox enforcement (command allowlist, cwd perimeter,
|
|
// network policy) — refused attempts are journalized for audit.
|
|
crate::sandbox::enforce(app, agent, &prog, &mut exec.env, no_sandbox)?;
|
|
let status = Command::new(&prog)
|
|
.args(&full_args)
|
|
.envs(&exec.env)
|
|
.status()
|
|
.with_context(|| format!("failed to run {}", exec.program))?;
|
|
let code = status.code().unwrap_or(1);
|
|
app.emit(
|
|
&Event::now(EventKind::Run)
|
|
.with_agent(agent.name.clone())
|
|
.with_cwd(cwd_string())
|
|
.with_args(exec.args.clone())
|
|
.with_env_keys(events::env_keys(&exec.env))
|
|
.with_exit_code(code)
|
|
.with_duration(started.elapsed().as_secs()),
|
|
);
|
|
let _ = app.state.touch(&agent.name);
|
|
Ok(code)
|
|
}
|
|
|
|
/// Issue #58: run the agent inside its container profile. Detects the
|
|
/// runtime (podman > docker), mounts state/log dirs and the profile
|
|
/// volumes/network/env, then execs the container in the foreground.
|
|
fn run_container(app: &App, agent: &crate::config::AgentDef, extra: &[OsString]) -> Result<i32> {
|
|
let Some(spec) = &agent.container else {
|
|
bail!(
|
|
"agent '{}' has no container profile — add 'container: {{image: ...}}' to its definition",
|
|
agent.name
|
|
);
|
|
};
|
|
let Some(runtime) = crate::automation::detect_runtime() else {
|
|
bail!(
|
|
"no container runtime found (docker or podman) — install one or drop --container"
|
|
);
|
|
};
|
|
let state_dir = app.paths.state_file.parent().unwrap_or(std::path::Path::new("."));
|
|
let log_dir = &app.paths.log_dir;
|
|
let mut cmd = crate::automation::container_command(
|
|
&runtime,
|
|
&agent.name,
|
|
spec,
|
|
state_dir,
|
|
log_dir,
|
|
)?;
|
|
// The agent's run command + extra args become the container command.
|
|
let exec = resolve_exec(app, agent, &[], &BTreeMap::new())?;
|
|
cmd.push(exec.program);
|
|
cmd.extend(exec.args.iter().cloned());
|
|
cmd.extend(extra.iter().map(|o| o.to_string_lossy().to_string()));
|
|
if app.dry_run() {
|
|
app.log.dry(&format!("would run: {}", cmd.join(" ")));
|
|
return Ok(0);
|
|
}
|
|
app.log.info(&format!(
|
|
"running '{}' in a container ({runtime})",
|
|
agent.name
|
|
));
|
|
let status = Command::new(&cmd[0])
|
|
.args(&cmd[1..])
|
|
.status()
|
|
.with_context(|| format!("failed to run {}", cmd.join(" ")))?;
|
|
let code = status.code().unwrap_or(1);
|
|
app.emit(
|
|
&Event::now(EventKind::Run)
|
|
.with_agent(agent.name.clone())
|
|
.with_reason(format!("container ({runtime})"))
|
|
.with_args(cmd.clone())
|
|
.with_exit_code(code),
|
|
);
|
|
let _ = app.state.touch(&agent.name);
|
|
Ok(code)
|
|
}
|
|
|
|
/// Current directory as a string (None when unavailable).
|
|
fn cwd_string() -> Option<String> {
|
|
std::env::current_dir().ok().map(|p| p.display().to_string())
|
|
}
|
|
|
|
/// Resolve --model against the local runtimes and build the transmission
|
|
/// channel for the agent (issue #71): the agent's `model_env` variable wins,
|
|
/// then its `model_arg` flag, then the generic AM_MODEL variable. An unknown
|
|
/// model is an explicit error listing the available runtimes.
|
|
pub fn apply_model(
|
|
app: &App,
|
|
agent: &AgentDef,
|
|
model: &str,
|
|
) -> Result<(Vec<String>, BTreeMap<String, String>)> {
|
|
let inventory = crate::models::detect(app);
|
|
if inventory.find(model).is_none() {
|
|
let known: Vec<String> = inventory.models.iter().map(|m| m.name.clone()).collect();
|
|
let hint = if known.is_empty() {
|
|
"no model runtime detected — run 'am models' to check your ollama, llama.cpp or LM Studio setup".to_string()
|
|
} else {
|
|
format!("available models: {} — run 'am models' for details", known.join(", "))
|
|
};
|
|
bail!("model '{model}' not found on any local runtime ({hint})");
|
|
}
|
|
let mut args = Vec::new();
|
|
let mut env = BTreeMap::new();
|
|
if let Some(var) = &agent.model_env {
|
|
env.insert(var.clone(), model.to_string());
|
|
app.log.verbose(&format!(
|
|
"model '{model}' transmitted to {} via {var}",
|
|
agent.name
|
|
));
|
|
} else if let Some(flag) = &agent.model_arg {
|
|
args.push(flag.clone());
|
|
args.push(model.to_string());
|
|
app.log.verbose(&format!(
|
|
"model '{model}' transmitted to {} via {flag}",
|
|
agent.name
|
|
));
|
|
} else {
|
|
// Generic fallback: many local agents read AM_MODEL; the config can
|
|
// pin a specific channel (model_env / model_arg) instead.
|
|
env.insert("AM_MODEL".to_string(), model.to_string());
|
|
app.log.info(&format!(
|
|
"agent '{}' declares no model_env/model_arg — exported AM_MODEL={model}; set model_env or model_arg in the config to use a specific channel",
|
|
agent.name
|
|
));
|
|
}
|
|
Ok((args, env))
|
|
}
|
|
|
|
/// Issue #92: resolve `--provider`/`--model` against the LLM provider
|
|
/// registry (cloud) and transmit through the agent's declared channels:
|
|
/// `model_env` / `model_arg`, or the `config.env_map` slots (issue #91).
|
|
/// The token travels as the `@secret` reference resolved by
|
|
/// resolve_env_secrets — never on the command line. Returns None when no
|
|
/// provider is configured at all (the caller falls back to the
|
|
/// local-runtime path).
|
|
fn apply_provider(
|
|
app: &App,
|
|
agent: &AgentDef,
|
|
provider: Option<&str>,
|
|
model: Option<&str>,
|
|
) -> Result<Option<(Vec<String>, BTreeMap<String, String>)>> {
|
|
let Some((pname, _def, resolved_model)) = crate::providers::resolve_for(
|
|
&app.config,
|
|
crate::agent_config::provider_pref(agent),
|
|
agent.model.as_deref(),
|
|
provider,
|
|
model,
|
|
) else {
|
|
if provider.is_some() {
|
|
let known = crate::providers::names(&app.config);
|
|
let hint = if known.is_empty() {
|
|
"aucun (am providers add <nom> --base-url <url>)".to_string()
|
|
} else {
|
|
known.join(", ")
|
|
};
|
|
bail!(crate::tr_fmt!(
|
|
"provider '{}' inconnu — providers enregistrés: {}",
|
|
provider.unwrap_or(""),
|
|
hint
|
|
));
|
|
}
|
|
return Ok(None);
|
|
};
|
|
let mut args: Vec<String> = Vec::new();
|
|
let mut env: BTreeMap<String, String> = BTreeMap::new();
|
|
// env_map resolved against THIS provider (api_key stays @secret).
|
|
crate::agent_config::apply_env_map_resolved(
|
|
&mut env,
|
|
agent,
|
|
&app.config,
|
|
Some(pname),
|
|
resolved_model,
|
|
);
|
|
// The model through the agent's declared channel (model_env > model_arg);
|
|
// otherwise the env_map "model" slot already carried it.
|
|
if let Some(m) = resolved_model {
|
|
if let Some(var) = &agent.model_env {
|
|
env.insert(var.clone(), m.to_string());
|
|
} else if let Some(flag) = &agent.model_arg {
|
|
args.push(flag.clone());
|
|
args.push(m.to_string());
|
|
}
|
|
}
|
|
app.log.info(&crate::tr_fmt!(
|
|
"provider {} (modèle {}) au lancement de {}",
|
|
pname,
|
|
resolved_model.unwrap_or("-"),
|
|
agent.name
|
|
));
|
|
Ok(Some((args, env)))
|
|
}
|
|
|
|
/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable).
|
|
fn seconds_since(ts: Option<&str>) -> Option<u64> {
|
|
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;
|
|
let now = chrono::Utc::now();
|
|
let secs = now
|
|
.signed_duration_since(t.with_timezone(&chrono::Utc))
|
|
.num_seconds();
|
|
Some(secs.max(0) as u64)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::{AgentConfig, AgentDef, Config};
|
|
use clap::Parser;
|
|
|
|
fn test_app(providers_yaml: &str) -> App {
|
|
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,
|
|
format!(
|
|
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{providers_yaml}agents: []\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
|
crate::app::App::from_cli(cli).unwrap()
|
|
}
|
|
|
|
fn agent(model_env: bool, model_arg: bool) -> AgentDef {
|
|
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());
|
|
AgentDef {
|
|
name: "demo".to_string(),
|
|
display_name: None,
|
|
description: None,
|
|
category: None,
|
|
website: None,
|
|
install: None,
|
|
dependencies: vec![],
|
|
run: Some("demo".to_string()),
|
|
args: vec![],
|
|
env: BTreeMap::new(),
|
|
version: None,
|
|
pin_version: None,
|
|
model_env: model_env.then(|| "MY_MODEL".to_string()),
|
|
model_arg: model_arg.then(|| "--model".to_string()),
|
|
provider: None,
|
|
model: None,
|
|
config: Some(AgentConfig {
|
|
env_map,
|
|
files: vec![],
|
|
provider_default: None,
|
|
}),
|
|
sandbox: None,
|
|
tags: vec![],
|
|
installable: false,
|
|
note: None,
|
|
hidden: false,
|
|
platforms: vec![],
|
|
healthcheck: None,
|
|
container: None,
|
|
cost_model: None,
|
|
}
|
|
}
|
|
|
|
const OPENAI: &str = " 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";
|
|
|
|
#[test]
|
|
fn apply_provider_resolves_and_never_leaks_the_token() {
|
|
let app = test_app(OPENAI);
|
|
let (args, env) = apply_provider(&app, &agent(false, false), Some("openai"), Some("gpt-5.2"))
|
|
.unwrap()
|
|
.unwrap();
|
|
// The token slot is the @secret reference — never a value.
|
|
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");
|
|
assert!(!env.values().any(|v| v.contains("sk-")), "{env:?}");
|
|
assert!(args.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn apply_provider_uses_the_declared_model_channel() {
|
|
let app = test_app(OPENAI);
|
|
// model_env wins over the env_map slot.
|
|
let (args, env) = apply_provider(&app, &agent(true, false), Some("openai"), None)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
|
|
assert!(args.is_empty());
|
|
// model_arg is pushed on the command line.
|
|
let (args, _) = apply_provider(&app, &agent(false, true), Some("openai"), Some("gpt-5.2"))
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(args, vec!["--model".to_string(), "gpt-5.2".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_provider_unknown_is_an_error_listing_the_registry() {
|
|
let app = test_app(OPENAI);
|
|
let err = apply_provider(&app, &agent(false, false), Some("nope"), None).unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(msg.contains("nope"), "{msg}");
|
|
assert!(msg.contains("openai"), "registry list missing: {msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn apply_provider_falls_back_to_the_embedded_default_provider() {
|
|
// The embedded default catalog ships providers (anthropic★ since
|
|
// issue #90): even an empty user config resolves through the cloud.
|
|
let app = test_app("");
|
|
let (_, env) = apply_provider(&app, &agent(false, false), None, Some("x"))
|
|
.unwrap()
|
|
.expect("the embedded default provider resolves");
|
|
// Model "x" is unknown: the env_map model slot is skipped, but the
|
|
// provider slot is wired.
|
|
assert_eq!(env.get("MY_URL").unwrap(), "https://api.anthropic.com/v1");
|
|
assert_eq!(env.get("MY_KEY").unwrap(), "@secret");
|
|
}
|
|
|
|
#[test]
|
|
fn model_flag_falls_back_to_the_cloud_registry() {
|
|
let app = test_app(OPENAI);
|
|
// apply_model bails (no local runtime in the test env) → the caller
|
|
// falls back to apply_provider with the default provider.
|
|
let local = apply_model(&app, &agent(false, false), "gpt-5.2").unwrap_err();
|
|
assert!(local.to_string().contains("not found on any local runtime"));
|
|
let (_, env) = apply_provider(&app, &agent(false, false), None, Some("gpt-5.2"))
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(env.get("MY_MODEL").unwrap(), "gpt-5.2");
|
|
assert_eq!(env.get("MY_URL").unwrap(), "https://api.openai.com/v1");
|
|
}
|
|
|
|
#[test]
|
|
fn config_parses_the_provider_registry_and_agents() {
|
|
let _cfg: Config = serde_yaml::from_str(&format!(
|
|
"version: \"1.0\"\nsettings:\n{OPENAI}agents: []\n"
|
|
))
|
|
.unwrap();
|
|
}
|
|
}
|