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:
Generated
+97
-10
@@ -1101,7 +1101,10 @@ version = "3.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"linux-keyutils",
|
||||||
"log",
|
"log",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1138,6 +1141,16 @@ dependencies = [
|
|||||||
"bitflags 2.13.1",
|
"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]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -2693,7 +2706,7 @@ version = "0.52.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2702,7 +2715,16 @@ version = "0.59.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -2720,14 +2742,31 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows_aarch64_gnullvm",
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
"windows_aarch64_msvc",
|
"windows_aarch64_msvc 0.52.6",
|
||||||
"windows_i686_gnu",
|
"windows_i686_gnu 0.52.6",
|
||||||
"windows_i686_gnullvm",
|
"windows_i686_gnullvm 0.52.6",
|
||||||
"windows_i686_msvc",
|
"windows_i686_msvc 0.52.6",
|
||||||
"windows_x86_64_gnu",
|
"windows_x86_64_gnu 0.52.6",
|
||||||
"windows_x86_64_gnullvm",
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
"windows_x86_64_msvc",
|
"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]]
|
[[package]]
|
||||||
@@ -2736,48 +2775,96 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "windows_i686_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
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]]
|
[[package]]
|
||||||
name = "winsafe"
|
name = "winsafe"
|
||||||
version = "0.0.19"
|
version = "0.0.19"
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ tempfile = "3"
|
|||||||
tiny_http = "0.12"
|
tiny_http = "0.12"
|
||||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||||
wait-timeout = "0.2"
|
wait-timeout = "0.2"
|
||||||
keyring = "3"
|
keyring = { version = "3", features = ["windows-native", "linux-native"] }
|
||||||
which = "7"
|
which = "7"
|
||||||
zip = "0.6"
|
zip = "0.6"
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
|
|||||||
+1
-1
@@ -498,7 +498,7 @@ S'appuie sur #36 (secrets), #40 (profils), #71 (modèle), #66 (sync) — livrés
|
|||||||
| # | Issue | Effort | Dépend de |
|
| # | 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 | — |
|
| [#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 |
|
| [#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 |
|
| [#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 |
|
| [#92](https://git.dracodev.net/Projets/agent-manager/issues/92) | `am run/start --provider` : override au lancement (providers cloud inclus) | M | #90 |
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ Register a provider, or update an existing one
|
|||||||
providers\-remove(1)
|
providers\-remove(1)
|
||||||
Remove a provider from the registry
|
Remove a provider from the registry
|
||||||
.TP
|
.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)
|
providers\-default(1)
|
||||||
Set the default provider
|
Set the default provider
|
||||||
.TP
|
.TP
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ Print help
|
|||||||
.SH SUBCOMMANDS
|
.SH SUBCOMMANDS
|
||||||
.TP
|
.TP
|
||||||
secret\-set(1)
|
secret\-set(1)
|
||||||
Store a secret for an agent
|
Store a secret for an agent (or a provider, issue #89)
|
||||||
.TP
|
.TP
|
||||||
secret\-unset(1)
|
secret\-unset(1)
|
||||||
Remove a secret
|
Remove a secret
|
||||||
|
|||||||
+25
-5
@@ -672,6 +672,20 @@ pub enum ProvidersCmd {
|
|||||||
/// Provider name
|
/// Provider name
|
||||||
name: String,
|
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
|
/// Set the default provider
|
||||||
Default {
|
Default {
|
||||||
/// Provider name
|
/// Provider name
|
||||||
@@ -741,13 +755,16 @@ pub enum ScheduleCmd {
|
|||||||
}
|
}
|
||||||
#[derive(Subcommand, Debug, Clone)]
|
#[derive(Subcommand, Debug, Clone)]
|
||||||
pub enum SecretCmd {
|
pub enum SecretCmd {
|
||||||
/// Store a secret for an agent
|
/// Store a secret for an agent (or a provider, issue #89)
|
||||||
Set {
|
Set {
|
||||||
/// Secret name (the environment variable name)
|
/// Secret name (the environment variable name)
|
||||||
name: String,
|
name: String,
|
||||||
/// Agent it belongs to
|
/// Agent it belongs to (required unless --provider is given)
|
||||||
#[arg(long, value_name = "AGENT")]
|
#[arg(long, value_name = "AGENT")]
|
||||||
agent: String,
|
agent: Option<String>,
|
||||||
|
/// Provider it belongs to (shared by every agent of this provider)
|
||||||
|
#[arg(long, value_name = "PROVIDER")]
|
||||||
|
provider: Option<String>,
|
||||||
/// Secret value (prefer --value over shell history; never logged)
|
/// Secret value (prefer --value over shell history; never logged)
|
||||||
#[arg(long, value_name = "VALUE")]
|
#[arg(long, value_name = "VALUE")]
|
||||||
value: String,
|
value: String,
|
||||||
@@ -756,9 +773,12 @@ pub enum SecretCmd {
|
|||||||
Unset {
|
Unset {
|
||||||
/// Secret name
|
/// Secret name
|
||||||
name: String,
|
name: String,
|
||||||
/// Agent it belongs to
|
/// Agent it belongs to (required unless --provider is given)
|
||||||
#[arg(long, value_name = "AGENT")]
|
#[arg(long, value_name = "AGENT")]
|
||||||
agent: String,
|
agent: Option<String>,
|
||||||
|
/// Provider it belongs to
|
||||||
|
#[arg(long, value_name = "PROVIDER")]
|
||||||
|
provider: Option<String>,
|
||||||
},
|
},
|
||||||
/// List stored secret names (values are never shown)
|
/// List stored secret names (values are never shown)
|
||||||
List,
|
List,
|
||||||
|
|||||||
+6
-1
@@ -308,7 +308,12 @@ pub fn resolve_exec(
|
|||||||
let mut env = agent.env.clone();
|
let mut env = agent.env.clone();
|
||||||
env.extend(extra_env.clone());
|
env.extend(extra_env.clone());
|
||||||
let warnings =
|
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 {
|
for w in warnings {
|
||||||
app.log.warn(&w);
|
app.log.warn(&w);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use crate::events::{Event, EventKind};
|
|||||||
use crate::output::print_json;
|
use crate::output::print_json;
|
||||||
use crate::providers;
|
use crate::providers;
|
||||||
use anyhow::{anyhow, bail, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
|
use crate::secrets::SecretStore;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub fn run(app: &App, sub: Option<&ProvidersCmd>) -> Result<i32> {
|
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,
|
models,
|
||||||
}) => add(app, name, base_url, model.as_deref(), models.as_deref()),
|
}) => add(app, name, base_url, model.as_deref(), models.as_deref()),
|
||||||
Some(ProvidersCmd::Remove { name }) => remove(app, name),
|
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),
|
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> {
|
fn add(app: &App, name: &str, base_url: &str, model: Option<&str>, models: Option<&str>) -> Result<i32> {
|
||||||
if !providers::is_valid_name(name) {
|
if !providers::is_valid_name(name) {
|
||||||
bail!(
|
bail!(
|
||||||
|
|||||||
@@ -1,34 +1,65 @@
|
|||||||
//! secret: manage secrets in the OS keyring (values never shown or logged).
|
//! 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 super::*;
|
||||||
use crate::cli::SecretCmd;
|
use crate::cli::SecretCmd;
|
||||||
use crate::output::print_json;
|
use crate::output::print_json;
|
||||||
use crate::secrets::{self, SecretStore};
|
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> {
|
pub fn run(app: &App, sub: &SecretCmd) -> Result<i32> {
|
||||||
match sub {
|
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() {
|
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);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let key = secrets::key_for(agent, name);
|
|
||||||
secrets::store().set(&key, value)?;
|
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)");
|
app.log.info("use it with --env NAME=@secret (injected at start/run)");
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
SecretCmd::Unset { name, agent } => {
|
SecretCmd::Unset {
|
||||||
let key = secrets::key_for(agent, name);
|
name,
|
||||||
|
agent,
|
||||||
|
provider,
|
||||||
|
} => {
|
||||||
|
let (key, scope) = scope_key(name, agent.as_deref(), provider.as_deref())?;
|
||||||
if app.dry_run() {
|
if app.dry_run() {
|
||||||
app.log.dry(format!("would remove secret {key}"));
|
app.log.dry(format!("would remove secret {key}"));
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let removed = secrets::store().remove(&key)?;
|
let removed = secrets::store().remove(&key)?;
|
||||||
if removed {
|
if removed {
|
||||||
app.log.success(&format!("secret {name} removed for {agent}"));
|
app.log.success(&crate::tr_fmt!("secret '{}' supprimé pour {}", name, scope));
|
||||||
} else {
|
} 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)
|
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 ...");
|
app.log.info("no secrets stored — see: am secret set NAME --agent AGENT --value ...");
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
// Both namespaces appear here: agent/NAME and providers/<p>/<VAR>.
|
||||||
let mut table = crate::output::Table::new(vec!["SECRET"]);
|
let mut table = crate::output::Table::new(vec!["SECRET"]);
|
||||||
for k in all {
|
for k in all {
|
||||||
if k != "__index__" {
|
if k != "__index__" {
|
||||||
|
|||||||
@@ -324,10 +324,10 @@ pub static SECTIONS: &[TipSection] = &[
|
|||||||
example: "secret set API_KEY --agent pi --value sk-…",
|
example: "secret set API_KEY --agent pi --value sk-…",
|
||||||
},
|
},
|
||||||
TipEntry {
|
TipEntry {
|
||||||
usage: "providers add <nom> --base-url <url>",
|
usage: "providers add <nom> --base-url <url> · set-token <nom> --value <v>",
|
||||||
about: "registre centralisé des providers LLM (modèles, modèle par défaut)",
|
about: "registre des providers LLM (modèles, défaut) + token partagé dans le keyring",
|
||||||
options: &[],
|
options: &[],
|
||||||
example: "providers add deepseek --base-url https://api.deepseek.com --model deepseek-chat",
|
example: "providers set-token deepseek --value sk-…",
|
||||||
},
|
},
|
||||||
TipEntry {
|
TipEntry {
|
||||||
usage: "favorite · note · tag",
|
usage: "favorite · note · tag",
|
||||||
|
|||||||
+14
-5
@@ -664,17 +664,22 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
|||||||
name: "secret",
|
name: "secret",
|
||||||
category: "Commands",
|
category: "Commands",
|
||||||
usage: "secret {flags} <set|unset|list>",
|
usage: "secret {flags} <set|unset|list>",
|
||||||
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/<p>/<VAR>, shared by every agent of that provider, issue #89).",
|
||||||
search_terms: &["keyring", "token", "password"],
|
search_terms: &["keyring", "token", "password"],
|
||||||
flags: &[],
|
flags: &[],
|
||||||
subcommands: &[
|
subcommands: &[
|
||||||
("set", "Store a secret for an agent"),
|
("set", "Store a secret for an agent or a provider"),
|
||||||
("unset", "Remove a secret"),
|
("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,
|
io: None,
|
||||||
examples: &[
|
examples: &[
|
||||||
HelpExample { desc: "Store a token.", code: "secret set OPENAI_KEY --agent claude-code --value sk-..." },
|
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" },
|
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",
|
name: "providers",
|
||||||
category: "Commands",
|
category: "Commands",
|
||||||
usage: "providers {flags} <list|show|add|remove|default>",
|
usage: "providers {flags} <list|show|add|remove|default>",
|
||||||
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).",
|
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/<name>/api_key (issue #89).",
|
||||||
search_terms: &["provider", "token", "registry", "llm", "model", "api"],
|
search_terms: &["provider", "token", "registry", "llm", "model", "api", "keyring"],
|
||||||
flags: &[
|
flags: &[
|
||||||
HelpFlag { short: "", long: "--base-url", value: "URL", desc: "Base URL of the provider API (add)" },
|
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: "--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: "--models", value: "MODELS", desc: "Comma-separated model list (add)" },
|
||||||
|
HelpFlag { short: "", long: "--value", value: "VALUE", desc: "Token value (set-token; never logged)" },
|
||||||
],
|
],
|
||||||
subcommands: &[
|
subcommands: &[
|
||||||
("list", "List the registered providers (default starred)"),
|
("list", "List the registered providers (default starred)"),
|
||||||
@@ -695,12 +701,15 @@ pub static HELP_SPECS: &[HelpSpec] = &[
|
|||||||
("add", "Register or update a provider"),
|
("add", "Register or update a provider"),
|
||||||
("remove", "Remove a provider"),
|
("remove", "Remove a provider"),
|
||||||
("default", "Set the default 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: &[],
|
parameters: &[],
|
||||||
io: None,
|
io: None,
|
||||||
examples: &[
|
examples: &[
|
||||||
HelpExample { desc: "List the providers.", code: "providers" },
|
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: "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: "Make it the default.", code: "providers default deepseek" },
|
||||||
HelpExample { desc: "Machine-readable list.", code: "providers --json" },
|
HelpExample { desc: "Machine-readable list.", code: "providers --json" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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"),
|
("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: {}"),
|
("provider par défaut : {}", "default provider: {}"),
|
||||||
("usage: providers list | show <nom> | add <nom> --base-url <url> | remove <nom> | default <nom>", "usage: providers list | show <name> | add <name> --base-url <url> | remove <name> | default <name>"),
|
("usage: providers list | show <nom> | add <nom> --base-url <url> | remove <nom> | default <nom>", "usage: providers list | show <name> | add <name> --base-url <url> | remove <name> | default <name>"),
|
||||||
|
("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 <nom> --agent <agent> --value <valeur> | secret set <nom> --provider <provider> --value <valeur>", "usage: secret set <name> --agent <agent> --value <value> | secret set <name> --provider <provider> --value <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) — 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"),
|
("{} 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"),
|
("vouliez-vous dire", "did you mean"),
|
||||||
|
|||||||
+17
-4
@@ -243,7 +243,7 @@ impl AmCompleter {
|
|||||||
config_sub: vec!["show", "path", "edit", "validate", "add"],
|
config_sub: vec!["show", "path", "edit", "validate", "add"],
|
||||||
profile_sub: vec!["list", "show"],
|
profile_sub: vec!["list", "show"],
|
||||||
catalog_sub: vec!["update", "add", "list"],
|
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"],
|
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
|
||||||
gateway_shells: vec![
|
gateway_shells: vec![
|
||||||
"list", "bash", "zsh", "fish", "powershell", "pwsh", "cmd", "sh", "nu",
|
"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()) {
|
"secret" => match rest.first().map(|s| s.as_str()) {
|
||||||
Some("set") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Set {
|
Some("set") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Set {
|
||||||
name: rest[1].clone(),
|
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(),
|
value: opt_value("--value").unwrap_or_default(),
|
||||||
}),
|
}),
|
||||||
Some("unset") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Unset {
|
Some("unset") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Unset {
|
||||||
name: rest[1].clone(),
|
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),
|
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);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1521,6 +1523,17 @@ fn handle_line(
|
|||||||
name: rest[1].clone(),
|
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 {
|
Some("list") | None => Command::Providers(crate::cli::ProviderArgs {
|
||||||
sub: Some(crate::cli::ProvidersCmd::List),
|
sub: Some(crate::cli::ProvidersCmd::List),
|
||||||
}),
|
}),
|
||||||
|
|||||||
+104
-13
@@ -41,11 +41,20 @@ impl SecretStore for KeyringStore {
|
|||||||
|
|
||||||
fn remove(&self, key: &str) -> Result<bool> {
|
fn remove(&self, key: &str) -> Result<bool> {
|
||||||
let entry = keyring::Entry::new(SERVICE, key)?;
|
let entry = keyring::Entry::new(SERVICE, key)?;
|
||||||
match entry.delete_credential() {
|
let existed = match entry.delete_credential() {
|
||||||
Ok(()) => Ok(true),
|
Ok(()) => true,
|
||||||
Err(keyring::Error::NoEntry) => Ok(false),
|
Err(keyring::Error::NoEntry) => false,
|
||||||
Err(e) => Err(anyhow!("keyring: {e}")),
|
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<Vec<String>> {
|
fn list(&self) -> Result<Vec<String>> {
|
||||||
@@ -68,12 +77,28 @@ pub fn key_for(agent: &str, name: &str) -> String {
|
|||||||
format!("{agent}/{name}")
|
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
|
/// Resolve "@secret" placeholders in an env map: a value equal to
|
||||||
/// "@secret" pulls the secret named by the variable name for this agent.
|
/// "@secret" pulls the secret named by the variable name for this agent,
|
||||||
/// Returns human warnings (names only — values never leak).
|
/// falling back to the provider-scoped secrets of `provider` (issue #89):
|
||||||
|
/// 1. `agent/<VAR>` (agent-scoped, historically the only one)
|
||||||
|
/// 2. `providers/<provider>/<VAR>` (provider-scoped secondary keys)
|
||||||
|
/// 3. `providers/<provider>/api_key` (the provider's canonical token)
|
||||||
|
/// Missing secrets produce warnings with names only — values never leak.
|
||||||
pub fn resolve_env_secrets(
|
pub fn resolve_env_secrets(
|
||||||
store: &dyn SecretStore,
|
store: &dyn SecretStore,
|
||||||
agent: &str,
|
agent: &str,
|
||||||
|
provider: Option<&str>,
|
||||||
env: &mut BTreeMap<String, String>,
|
env: &mut BTreeMap<String, String>,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
@@ -83,15 +108,29 @@ pub fn resolve_env_secrets(
|
|||||||
if !placeholder {
|
if !placeholder {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let key = key_for(agent, &k);
|
let mut value = store.get(&key_for(agent, &k)).ok().flatten();
|
||||||
match store.get(&key) {
|
if value.is_none() {
|
||||||
Ok(Some(v)) => {
|
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);
|
env.insert(k.clone(), v);
|
||||||
}
|
}
|
||||||
Ok(None) => warnings.push(format!(
|
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 ..."
|
"no secret for {k} — use: am secret set {k} --agent {agent} --value ..."
|
||||||
)),
|
),
|
||||||
Err(e) => warnings.push(format!("cannot read secret {k}: {e:#}")),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
warnings
|
warnings
|
||||||
@@ -141,11 +180,63 @@ mod tests {
|
|||||||
env.insert("TOKEN".to_string(), "@secret".to_string());
|
env.insert("TOKEN".to_string(), "@secret".to_string());
|
||||||
env.insert("PLAIN".to_string(), "kept".to_string());
|
env.insert("PLAIN".to_string(), "kept".to_string());
|
||||||
env.insert("MISSING".to_string(), "@secret".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("TOKEN").map(String::as_str), Some("secret-value"));
|
||||||
assert_eq!(env.get("PLAIN").map(String::as_str), Some("kept"));
|
assert_eq!(env.get("PLAIN").map(String::as_str), Some("kept"));
|
||||||
assert_eq!(env.get("MISSING").map(String::as_str), Some("@secret"));
|
assert_eq!(env.get("MISSING").map(String::as_str), Some("@secret"));
|
||||||
assert_eq!(warnings.len(), 1, "warning sans valeur secrete: {warnings:?}");
|
assert_eq!(warnings.len(), 1, "warning sans valeur secrete: {warnings:?}");
|
||||||
assert!(!warnings[0].contains("secret-value"));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -31,6 +31,7 @@ backups/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
secrets*.json
|
secrets*.json
|
||||||
|
providers/
|
||||||
id_rsa*
|
id_rsa*
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
@@ -196,6 +197,28 @@ mod tests {
|
|||||||
assert_eq!(again.matches("# managed by agent-manager").count(), 1);
|
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]
|
#[test]
|
||||||
fn sync_skips_without_repo_setting() {
|
fn sync_skips_without_repo_setting() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user