diff --git a/Cargo.lock b/Cargo.lock index ce1d004..3c17d88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1101,7 +1101,10 @@ version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ + "byteorder", + "linux-keyutils", "log", + "windows-sys 0.60.2", "zeroize", ] @@ -1138,6 +1141,16 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2693,7 +2706,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2702,7 +2715,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "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", ] [[package]] @@ -2720,14 +2742,31 @@ 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", + "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" +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", ] [[package]] @@ -2736,48 +2775,96 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winsafe" version = "0.0.19" diff --git a/Cargo.toml b/Cargo.toml index 2daedc9..ddcf128 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ tempfile = "3" tiny_http = "0.12" ureq = { version = "2", default-features = false, features = ["tls"] } wait-timeout = "0.2" -keyring = "3" +keyring = { version = "3", features = ["windows-native", "linux-native"] } which = "7" zip = "0.6" crossterm = "0.29" diff --git a/ROADMAP.md b/ROADMAP.md index ae2c26a..66adc0e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -498,7 +498,7 @@ S'appuie sur #36 (secrets), #40 (profils), #71 (modèle), #66 (sync) — livrés | # | Issue | Effort | Dépend de | |---|---|---|---| | [#88](https://git.dracodev.net/Projets/agent-manager/issues/88) | ✅ Registre de providers + commandes `am providers` (settings.providers, default_provider) | M | — | -| [#89](https://git.dracodev.net/Projets/agent-manager/issues/89) | Secrets partagés par provider (namespace keyring + résolution `@secret` fallback) | S | #88 | +| [#89](https://git.dracodev.net/Projets/agent-manager/issues/89) | ✅ Secrets partagés par provider (namespace keyring + résolution `@secret` fallback) | S | #88 | | [#90](https://git.dracodev.net/Projets/agent-manager/issues/90) | AgentDef provider/model + flags `--provider/--model/--no-config` à l'install | M | #88 | | [#91](https://git.dracodev.net/Projets/agent-manager/issues/91) | Configuration post-install : bloc `config:` par agent (env_map + fichiers) | L | #89, #90 | | [#92](https://git.dracodev.net/Projets/agent-manager/issues/92) | `am run/start --provider` : override au lancement (providers cloud inclus) | M | #90 | diff --git a/man/am-providers.1 b/man/am-providers.1 index 418e3ff..7de8674 100644 --- a/man/am-providers.1 +++ b/man/am-providers.1 @@ -25,6 +25,12 @@ Register a provider, or update an existing one providers\-remove(1) Remove a provider from the registry .TP +providers\-set\-token(1) +Store the provider API token in the OS keyring (shared by every agent of this provider through the @secret cascade, issue #89) +.TP +providers\-token(1) +Check whether a provider token exists (the value is never shown) +.TP providers\-default(1) Set the default provider .TP diff --git a/man/am-secret.1 b/man/am-secret.1 index 2eed080..c5dbfac 100644 --- a/man/am-secret.1 +++ b/man/am-secret.1 @@ -14,7 +14,7 @@ Print help .SH SUBCOMMANDS .TP secret\-set(1) -Store a secret for an agent +Store a secret for an agent (or a provider, issue #89) .TP secret\-unset(1) Remove a secret diff --git a/src/cli.rs b/src/cli.rs index 8b333bb..fc6dbaa 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -672,6 +672,20 @@ pub enum ProvidersCmd { /// Provider name name: String, }, + /// Store the provider API token in the OS keyring (shared by every + /// agent of this provider through the @secret cascade, issue #89) + SetToken { + /// Provider name + name: String, + /// Token value (never logged) + #[arg(long, value_name = "VALUE")] + value: String, + }, + /// Check whether a provider token exists (the value is never shown) + Token { + /// Provider name + name: String, + }, /// Set the default provider Default { /// Provider name @@ -741,13 +755,16 @@ pub enum ScheduleCmd { } #[derive(Subcommand, Debug, Clone)] pub enum SecretCmd { - /// Store a secret for an agent + /// Store a secret for an agent (or a provider, issue #89) Set { /// Secret name (the environment variable name) name: String, - /// Agent it belongs to + /// Agent it belongs to (required unless --provider is given) #[arg(long, value_name = "AGENT")] - agent: String, + agent: Option, + /// Provider it belongs to (shared by every agent of this provider) + #[arg(long, value_name = "PROVIDER")] + provider: Option, /// Secret value (prefer --value over shell history; never logged) #[arg(long, value_name = "VALUE")] value: String, @@ -756,9 +773,12 @@ pub enum SecretCmd { Unset { /// Secret name name: String, - /// Agent it belongs to + /// Agent it belongs to (required unless --provider is given) #[arg(long, value_name = "AGENT")] - agent: String, + agent: Option, + /// Provider it belongs to + #[arg(long, value_name = "PROVIDER")] + provider: Option, }, /// List stored secret names (values are never shown) List, diff --git a/src/commands/mod.rs b/src/commands/mod.rs index d32fb43..671c9e6 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -308,7 +308,12 @@ pub fn resolve_exec( let mut env = agent.env.clone(); env.extend(extra_env.clone()); let warnings = - crate::secrets::resolve_env_secrets(&crate::secrets::store(), &agent.name, &mut env); + crate::secrets::resolve_env_secrets( + &crate::secrets::store(), + &agent.name, + crate::providers::default_name(&app.config), + &mut env, + ); for w in warnings { app.log.warn(&w); } diff --git a/src/commands/providers_cmd.rs b/src/commands/providers_cmd.rs index 5f6cf41..6340421 100644 --- a/src/commands/providers_cmd.rs +++ b/src/commands/providers_cmd.rs @@ -10,6 +10,7 @@ use crate::events::{Event, EventKind}; use crate::output::print_json; use crate::providers; use anyhow::{anyhow, bail, Result}; +use crate::secrets::SecretStore; use std::path::Path; pub fn run(app: &App, sub: Option<&ProvidersCmd>) -> Result { @@ -24,6 +25,8 @@ pub fn run(app: &App, sub: Option<&ProvidersCmd>) -> Result { models, }) => add(app, name, base_url, model.as_deref(), models.as_deref()), Some(ProvidersCmd::Remove { name }) => remove(app, name), + Some(ProvidersCmd::SetToken { name, value }) => set_token(app, name, value), + Some(ProvidersCmd::Token { name }) => check_token(app, name), Some(ProvidersCmd::Default { name }) => set_default(app, name), } } @@ -105,9 +108,59 @@ fn provider_json(name: &str, def: &ProviderDef, is_default: bool) -> serde_json: } // --------------------------------------------------------------------------- -// add / remove / default +// add / remove / default / set-token / token // --------------------------------------------------------------------------- +/// `am providers set-token --value ` — store the provider API token +/// in the OS keyring under providers//api_key (issue #89). The value +/// never leaves the keyring; every agent of this provider resolves it +/// through the @secret cascade at start/run. +fn set_token(app: &App, name: &str, value: &str) -> Result { + if providers::get(&app.config, name).is_none() { + bail!(crate::tr_fmt!("provider '{}' inconnu", name)); + } + if app.dry_run() { + app.log.dry(&format!("would store the token of provider {name} in the OS keyring")); + return Ok(0); + } + let key = crate::secrets::key_for_provider_token(name); + crate::secrets::store().set(&key, value)?; + app.emit(&Event::now(EventKind::Provider).with_args(vec![ + "action=set-token".to_string(), + format!("provider={name}"), + ])); + app.log.success(&crate::tr_fmt!("token stocké pour le provider '{}'", name)); + app.log.info("shared by every agent of this provider via --env NAME=@secret"); + Ok(0) +} + +/// `am providers token ` — check that a token exists; the value is +/// never displayed. --json emits {"provider": ..., "token_present": bool}. +fn check_token(app: &App, name: &str) -> Result { + if providers::get(&app.config, name).is_none() { + bail!(crate::tr_fmt!("provider '{}' inconnu", name)); + } + let key = crate::secrets::key_for_provider_token(name); + let present = crate::secrets::store().get(&key).ok().flatten().is_some(); + if app.json() { + print_json(&serde_json::json!({ + "provider": name, + "token_present": present, + })); + return Ok(0); + } + if present { + app.log.success(&crate::tr_fmt!("token présent pour le provider '{}'", name)); + } else { + app.log.info(&crate::tr_fmt!( + "aucun token pour le provider '{}' — voir: am providers set-token {}", + name, + name + )); + } + Ok(0) +} + fn add(app: &App, name: &str, base_url: &str, model: Option<&str>, models: Option<&str>) -> Result { if !providers::is_valid_name(name) { bail!( diff --git a/src/commands/secret_cmd.rs b/src/commands/secret_cmd.rs index 7908b3c..15b9461 100644 --- a/src/commands/secret_cmd.rs +++ b/src/commands/secret_cmd.rs @@ -1,34 +1,65 @@ //! secret: manage secrets in the OS keyring (values never shown or logged). +//! Secrets are scoped to an agent (`agent/NAME`, issue #36) or to a provider +//! (`providers//`, issue #89) — the provider namespace is +//! shared by every agent of that provider. use super::*; use crate::cli::SecretCmd; use crate::output::print_json; use crate::secrets::{self, SecretStore}; +/// Resolve the keyring key and the display scope for a secret name. +/// Exactly one of (agent, provider) must be given. +fn scope_key( + name: &str, + agent: Option<&str>, + provider: Option<&str>, +) -> Result<(String, String)> { + match (provider, agent) { + (Some(p), _) => Ok((secrets::key_for_provider(p, name), format!("provider {p}"))), + (None, Some(a)) => Ok((secrets::key_for(a, name), format!("agent {a}"))), + (None, None) => bail!( + "{}", + crate::i18n::tr( + "usage: secret set --agent --value | secret set --provider --value " + ) + ), + } +} + pub fn run(app: &App, sub: &SecretCmd) -> Result { match sub { - SecretCmd::Set { name, agent, value } => { + SecretCmd::Set { + name, + agent, + provider, + value, + } => { + let (key, scope) = scope_key(name, agent.as_deref(), provider.as_deref())?; if app.dry_run() { - app.log.dry(format!("would store secret {name} for {agent} in the OS keyring")); + app.log.dry(format!("would store secret {name} for {scope} in the OS keyring")); return Ok(0); } - let key = secrets::key_for(agent, name); secrets::store().set(&key, value)?; - app.log.success(&format!("secret {name} stored for {agent}")); + app.log.success(&crate::tr_fmt!("secret '{}' stocké pour {}", name, scope)); app.log.info("use it with --env NAME=@secret (injected at start/run)"); Ok(0) } - SecretCmd::Unset { name, agent } => { - let key = secrets::key_for(agent, name); + SecretCmd::Unset { + name, + agent, + provider, + } => { + let (key, scope) = scope_key(name, agent.as_deref(), provider.as_deref())?; if app.dry_run() { app.log.dry(format!("would remove secret {key}")); return Ok(0); } let removed = secrets::store().remove(&key)?; if removed { - app.log.success(&format!("secret {name} removed for {agent}")); + app.log.success(&crate::tr_fmt!("secret '{}' supprimé pour {}", name, scope)); } else { - app.log.info(&format!("secret {name} does not exist for {agent}")); + app.log.info(&crate::tr_fmt!("secret '{}' inexistant pour {}", name, scope)); } Ok(0) } @@ -42,6 +73,7 @@ pub fn run(app: &App, sub: &SecretCmd) -> Result { app.log.info("no secrets stored — see: am secret set NAME --agent AGENT --value ..."); return Ok(0); } + // Both namespaces appear here: agent/NAME and providers/

/. let mut table = crate::output::Table::new(vec!["SECRET"]); for k in all { if k != "__index__" { diff --git a/src/commands/tip_cmd.rs b/src/commands/tip_cmd.rs index cc09785..85f05e4 100644 --- a/src/commands/tip_cmd.rs +++ b/src/commands/tip_cmd.rs @@ -324,10 +324,10 @@ pub static SECTIONS: &[TipSection] = &[ example: "secret set API_KEY --agent pi --value sk-…", }, TipEntry { - usage: "providers add --base-url ", - about: "registre centralisé des providers LLM (modèles, modèle par défaut)", + usage: "providers add --base-url · set-token --value ", + about: "registre des providers LLM (modèles, défaut) + token partagé dans le keyring", options: &[], - example: "providers add deepseek --base-url https://api.deepseek.com --model deepseek-chat", + example: "providers set-token deepseek --value sk-…", }, TipEntry { usage: "favorite · note · tag", diff --git a/src/help.rs b/src/help.rs index a61a897..b5a63fa 100644 --- a/src/help.rs +++ b/src/help.rs @@ -664,17 +664,22 @@ pub static HELP_SPECS: &[HelpSpec] = &[ name: "secret", category: "Commands", usage: "secret {flags} ", - about: "Manage secrets in the OS keyring (values never shown or logged).", + about: "Manage secrets in the OS keyring (values never shown or logged). Secrets are scoped to an agent (agent/NAME) or to a provider (providers/

/, shared by every agent of that provider, issue #89).", search_terms: &["keyring", "token", "password"], flags: &[], subcommands: &[ - ("set", "Store a secret for an agent"), + ("set", "Store a secret for an agent or a provider"), ("unset", "Remove a secret"), ], - parameters: &[], + parameters: &[ + HelpParam { name: "--agent", typ: "string", desc: "Agent the secret belongs to" }, + HelpParam { name: "--provider", typ: "string", desc: "Provider the secret belongs to (issue #89)" }, + HelpParam { name: "--value", typ: "string", desc: "Secret value (never logged)" }, + ], io: None, examples: &[ HelpExample { desc: "Store a token.", code: "secret set OPENAI_KEY --agent claude-code --value sk-..." }, + HelpExample { desc: "Store a provider token.", code: "secret set api_key --provider openai --value sk-... (or: providers set-token openai)" }, HelpExample { desc: "Use it at launch.", code: "start claude-code --env OPENAI_KEY=@secret" }, ], }, @@ -682,12 +687,13 @@ pub static HELP_SPECS: &[HelpSpec] = &[ name: "providers", category: "Commands", usage: "providers {flags} ", - about: "Manage the LLM provider registry: base URLs, models, the default model and the default provider (issue #88). Tokens are never stored here — they live in the OS keyring (issue #89).", - search_terms: &["provider", "token", "registry", "llm", "model", "api"], + about: "Manage the LLM provider registry: base URLs, models, the default model and the default provider (issue #88). Tokens are never stored here — they live in the OS keyring under providers//api_key (issue #89).", + search_terms: &["provider", "token", "registry", "llm", "model", "api", "keyring"], flags: &[ HelpFlag { short: "", long: "--base-url", value: "URL", desc: "Base URL of the provider API (add)" }, HelpFlag { short: "", long: "--model", value: "MODEL", desc: "Model used by default for this provider (add)" }, HelpFlag { short: "", long: "--models", value: "MODELS", desc: "Comma-separated model list (add)" }, + HelpFlag { short: "", long: "--value", value: "VALUE", desc: "Token value (set-token; never logged)" }, ], subcommands: &[ ("list", "List the registered providers (default starred)"), @@ -695,12 +701,15 @@ pub static HELP_SPECS: &[HelpSpec] = &[ ("add", "Register or update a provider"), ("remove", "Remove a provider"), ("default", "Set the default provider"), + ("set-token", "Store the provider token in the OS keyring (issue #89)"), + ("token", "Check whether a provider token exists (value never shown)"), ], parameters: &[], io: None, examples: &[ HelpExample { desc: "List the providers.", code: "providers" }, HelpExample { desc: "Register DeepSeek.", code: "providers add deepseek --base-url https://api.deepseek.com --model deepseek-chat --models deepseek-chat,deepseek-reasoner" }, + HelpExample { desc: "Store its shared token.", code: "providers set-token deepseek --value sk-..." }, HelpExample { desc: "Make it the default.", code: "providers default deepseek" }, HelpExample { desc: "Machine-readable list.", code: "providers --json" }, ], diff --git a/src/i18n.rs b/src/i18n.rs index 4f3eb81..8242458 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -172,6 +172,13 @@ pub const CATALOG: &[(&str, &str)] = &[ ("le provider par défaut '{}' ne peut pas être supprimé — changez d'abord le provider par défaut", "the default provider '{}' cannot be removed — change the default provider first"), ("provider par défaut : {}", "default provider: {}"), ("usage: providers list | show | add --base-url | remove | default ", "usage: providers list | show | add --base-url | remove | default "), + ("token stocké pour le provider '{}'", "token stored for provider '{}'"), + ("token présent pour le provider '{}'", "token present for provider '{}'"), + ("aucun token pour le provider '{}' — voir: am providers set-token {}", "no token for provider '{}' — see: am providers set-token {}"), + ("secret '{}' stocké pour {}", "secret '{}' stored for {}"), + ("secret '{}' supprimé pour {}", "secret '{}' removed for {}"), + ("secret '{}' inexistant pour {}", "secret '{}' does not exist for {}"), + ("usage: secret set --agent --value | secret set --provider --value ", "usage: secret set --agent --value | secret set --provider --value "), ("{} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage", "{} locked entrie(s) — deletion scheduled for the next reboot"), ("{} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am", "{} locked entrie(s) — the deferred process will delete them after am exits"), ("vouliez-vous dire", "did you mean"), diff --git a/src/repl.rs b/src/repl.rs index 51bf903..b714e38 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -243,7 +243,7 @@ impl AmCompleter { config_sub: vec!["show", "path", "edit", "validate", "add"], profile_sub: vec!["list", "show"], catalog_sub: vec!["update", "add", "list"], - providers_sub: vec!["list", "show", "add", "remove", "default"], + providers_sub: vec!["list", "show", "add", "remove", "default", "set-token", "token"], shells: vec!["bash", "zsh", "fish", "powershell", "elvish"], gateway_shells: vec![ "list", "bash", "zsh", "fish", "powershell", "pwsh", "cmd", "sh", "nu", @@ -1467,16 +1467,18 @@ fn handle_line( "secret" => match rest.first().map(|s| s.as_str()) { Some("set") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Set { name: rest[1].clone(), - agent: opt_value("--agent").unwrap_or_default(), + agent: opt_value("--agent"), + provider: opt_value("--provider"), value: opt_value("--value").unwrap_or_default(), }), Some("unset") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Unset { name: rest[1].clone(), - agent: opt_value("--agent").unwrap_or_default(), + agent: opt_value("--agent"), + provider: opt_value("--provider"), }), Some("list") | None => Command::Secret(crate::cli::SecretCmd::List), _ => { - app.log.error(crate::i18n::tr("usage: secret set NAME --agent A --value V | secret unset NAME --agent A | secret list")); + app.log.error(crate::i18n::tr("usage: secret set NAME --agent A --value V | secret set NAME --provider P --value V | secret unset NAME --agent A | secret list")); return Ok(false); } }, @@ -1521,6 +1523,17 @@ fn handle_line( name: rest[1].clone(), }), }), + Some("set-token") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs { + sub: Some(crate::cli::ProvidersCmd::SetToken { + name: rest[1].clone(), + value: opt_value("--value").unwrap_or_default(), + }), + }), + Some("token") if rest.len() >= 2 => Command::Providers(crate::cli::ProviderArgs { + sub: Some(crate::cli::ProvidersCmd::Token { + name: rest[1].clone(), + }), + }), Some("list") | None => Command::Providers(crate::cli::ProviderArgs { sub: Some(crate::cli::ProvidersCmd::List), }), diff --git a/src/secrets.rs b/src/secrets.rs index bc05fb6..8466b0b 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -41,11 +41,20 @@ impl SecretStore for KeyringStore { fn remove(&self, key: &str) -> Result { let entry = keyring::Entry::new(SERVICE, key)?; - match entry.delete_credential() { - Ok(()) => Ok(true), - Err(keyring::Error::NoEntry) => Ok(false), - Err(e) => Err(anyhow!("keyring: {e}")), + let existed = match entry.delete_credential() { + Ok(()) => true, + Err(keyring::Error::NoEntry) => false, + Err(e) => return Err(anyhow!("keyring: {e}")), + }; + // Always purge the index: it may hold stale names (e.g. a credential + // already gone, or leftovers from an older version of remove()). + let mut idx = self.list().unwrap_or_default(); + if idx.iter().any(|k| k == key) { + idx.retain(|k| k != key); + let index = keyring::Entry::new(SERVICE, INDEX_KEY)?; + let _ = index.set_password(&idx.join(",")); } + Ok(existed) } fn list(&self) -> Result> { @@ -68,12 +77,28 @@ pub fn key_for(agent: &str, name: &str) -> String { format!("{agent}/{name}") } +/// Key for a provider-scoped secret: "providers/openai/ORG_ID" (issue #89). +pub fn key_for_provider(provider: &str, var: &str) -> String { + format!("providers/{provider}/{var}") +} + +/// Canonical key of a provider token: "providers/openai/api_key" +/// (issue #89) — set with `am providers set-token openai`. +pub fn key_for_provider_token(provider: &str) -> String { + key_for_provider(provider, "api_key") +} + /// Resolve "@secret" placeholders in an env map: a value equal to -/// "@secret" pulls the secret named by the variable name for this agent. -/// Returns human warnings (names only — values never leak). +/// "@secret" pulls the secret named by the variable name for this agent, +/// falling back to the provider-scoped secrets of `provider` (issue #89): +/// 1. `agent/` (agent-scoped, historically the only one) +/// 2. `providers//` (provider-scoped secondary keys) +/// 3. `providers//api_key` (the provider's canonical token) +/// Missing secrets produce warnings with names only — values never leak. pub fn resolve_env_secrets( store: &dyn SecretStore, agent: &str, + provider: Option<&str>, env: &mut BTreeMap, ) -> Vec { let mut warnings = Vec::new(); @@ -83,15 +108,29 @@ pub fn resolve_env_secrets( if !placeholder { continue; } - let key = key_for(agent, &k); - match store.get(&key) { - Ok(Some(v)) => { + let mut value = store.get(&key_for(agent, &k)).ok().flatten(); + if value.is_none() { + if let Some(p) = provider { + if value.is_none() { + value = store.get(&key_for_provider(p, &k)).ok().flatten(); + } + if value.is_none() { + value = store.get(&key_for_provider_token(p)).ok().flatten(); + } + } + } + match value { + Some(v) => { env.insert(k.clone(), v); } - Ok(None) => warnings.push(format!( - "no secret for {k} — use: am secret set {k} --agent {agent} --value ..." - )), - Err(e) => warnings.push(format!("cannot read secret {k}: {e:#}")), + None => warnings.push(match provider { + Some(p) => format!( + "no secret for {k} — use: am secret set {k} --agent {agent} --value ... or am providers set-token {p}" + ), + None => format!( + "no secret for {k} — use: am secret set {k} --agent {agent} --value ..." + ), + }), } } warnings @@ -141,11 +180,63 @@ mod tests { env.insert("TOKEN".to_string(), "@secret".to_string()); env.insert("PLAIN".to_string(), "kept".to_string()); env.insert("MISSING".to_string(), "@secret".to_string()); - let warnings = resolve_env_secrets(&s, "aider", &mut env); + let warnings = resolve_env_secrets(&s, "aider", None, &mut env); assert_eq!(env.get("TOKEN").map(String::as_str), Some("secret-value")); assert_eq!(env.get("PLAIN").map(String::as_str), Some("kept")); assert_eq!(env.get("MISSING").map(String::as_str), Some("@secret")); assert_eq!(warnings.len(), 1, "warning sans valeur secrete: {warnings:?}"); assert!(!warnings[0].contains("secret-value")); } + + #[test] + fn cascade_agent_then_provider_var_then_provider_token() { + let s = MockStore::default(); + // Étape 2 : variable scoped provider (clé secondaire). + s.set(&key_for_provider("openai", "ORG_ID"), "org-7").unwrap(); + let mut env = BTreeMap::new(); + env.insert("ORG_ID".to_string(), "@secret".to_string()); + let warnings = resolve_env_secrets(&s, "aider", Some("openai"), &mut env); + assert_eq!(env.get("ORG_ID").map(String::as_str), Some("org-7")); + assert!(warnings.is_empty(), "no warning: {warnings:?}"); + + // Étape 3 : token canonique du provider (api_key) pour une variable + // d'API classique, sans secret par-agent. + s.set(&key_for_provider_token("openai"), "sk-provider").unwrap(); + let mut env = BTreeMap::new(); + env.insert("OPENAI_API_KEY".to_string(), "@secret".to_string()); + let warnings = resolve_env_secrets(&s, "aider", Some("openai"), &mut env); + assert_eq!(env.get("OPENAI_API_KEY").map(String::as_str), Some("sk-provider")); + assert!(warnings.is_empty(), "no warning: {warnings:?}"); + + // Étape 1 prioritaire : le secret par-agent écrase le token provider. + s.set("aider/OPENAI_API_KEY", "sk-agent").unwrap(); + let mut env = BTreeMap::new(); + env.insert("OPENAI_API_KEY".to_string(), "@secret".to_string()); + let warnings = resolve_env_secrets(&s, "aider", Some("openai"), &mut env); + assert_eq!(env.get("OPENAI_API_KEY").map(String::as_str), Some("sk-agent")); + assert!(warnings.is_empty(), "no warning: {warnings:?}"); + + // Aucun secret connu (agent ni provider, y compris le token canonique + // du provider) : warning qui mentionne la variable et le provider, + // jamais la valeur. + let mut env = BTreeMap::new(); + env.insert("ANTHROPIC_API_KEY".to_string(), "@secret".to_string()); + let warnings = resolve_env_secrets(&s, "aider", Some("deepseek"), &mut env); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("ANTHROPIC_API_KEY")); + assert!(warnings[0].contains("set-token deepseek")); + assert!(!warnings[0].contains("sk-")); + } + + #[test] + fn provider_keys_never_hold_agent_values() { + assert_eq!(key_for("cc", "TOKEN"), "cc/TOKEN"); + assert_eq!(key_for_provider("openai", "TOKEN"), "providers/openai/TOKEN"); + assert_eq!( + key_for_provider_token("deepseek"), + "providers/deepseek/api_key" + ); + // Les deux namespaces restent distincts. + assert_ne!(key_for("openai", "api_key"), key_for_provider_token("openai")); + } } diff --git a/src/sync.rs b/src/sync.rs index 3c40fc9..51c099b 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -31,6 +31,7 @@ backups/ .env .env.* secrets*.json +providers/ id_rsa* "#; @@ -196,6 +197,28 @@ mod tests { assert_eq!(again.matches("# managed by agent-manager").count(), 1); } + #[test] + fn managed_gitignore_excludes_secret_and_provider_files() { + // Issue #89: provider tokens (like agent secrets) must never reach + // the sync bundle. The gitignore rules are the guard, since secrets + // live in the OS keyring and are never written next to the state. + let dir = tempfile::tempdir().unwrap(); + ensure_gitignore(dir.path()).unwrap(); + // check-ignore needs a repository on Windows (--no-index is broken + // on this git build), so init one like the other sync tests. + git(dir.path(), &["init", "-q"]).unwrap(); + for (path, ignored) in [ + ("secrets.json", true), + ("providers/tokens.json", true), + ("providers/openai.api_key", true), + ("state.json", false), + ("events-202608.jsonl", false), + ] { + let ok = git(dir.path(), &["check-ignore", path]).is_ok(); + assert_eq!(ok, ignored, "check-ignore {path}"); + } + } + #[test] fn sync_skips_without_repo_setting() { let dir = tempfile::tempdir().unwrap();