feat: v0.7.0 — secrets partagés par provider : am providers set-token/token, namespace keyring providers/<nom>, résolution @secret en cascade agent → provider (closes #89)

Corrige au passage deux bugs du keyring (#36) : features natifs manquants du crate keyring (set() no-op silencieux, rien ne persistait) + index __index__ jamais purgé au remove (list fantômes).
This commit is contained in:
2026-08-19 16:09:34 -04:00
parent d38cd7352f
commit 7312e6ceab
15 changed files with 400 additions and 54 deletions
+6 -1
View File
@@ -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);
}
+54 -1
View File
@@ -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<i32> {
@@ -24,6 +25,8 @@ pub fn run(app: &App, sub: Option<&ProvidersCmd>) -> Result<i32> {
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 <nom> --value <v>` — store the provider API token
/// in the OS keyring under providers/<nom>/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<i32> {
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 <nom>` — check that a token exists; the value is
/// never displayed. --json emits {"provider": ..., "token_present": bool}.
fn check_token(app: &App, name: &str) -> Result<i32> {
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<i32> {
if !providers::is_valid_name(name) {
bail!(
+40 -8
View File
@@ -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/<provider>/<VAR>`, 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 <nom> --agent <agent> --value <valeur> | secret set <nom> --provider <provider> --value <valeur>"
)
),
}
}
pub fn run(app: &App, sub: &SecretCmd) -> Result<i32> {
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<i32> {
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/<p>/<VAR>.
let mut table = crate::output::Table::new(vec!["SECRET"]);
for k in all {
if k != "__index__" {
+3 -3
View File
@@ -324,10 +324,10 @@ pub static SECTIONS: &[TipSection] = &[
example: "secret set API_KEY --agent pi --value sk-…",
},
TipEntry {
usage: "providers add <nom> --base-url <url>",
about: "registre centralisé des providers LLM (modèles, modèle par défaut)",
usage: "providers add <nom> --base-url <url> · set-token <nom> --value <v>",
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",