Merge pull request 'secret : trousseau OS + injection @secret (issue #36)' (#86) from feat/secrets into main

This commit was merged in pull request #86.
This commit is contained in:
2026-08-17 09:09:16 -04:00
9 changed files with 293 additions and 2 deletions
Generated
+12 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.2.14"
version = "0.3.0"
dependencies = [
"anyhow",
"chrono",
@@ -29,6 +29,7 @@ dependencies = [
"clap_complete",
"colored",
"flate2",
"keyring",
"rustyline",
"semver",
"serde",
@@ -715,6 +716,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"log",
"zeroize",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
+1
View File
@@ -32,6 +32,7 @@ tar = "0.4"
tempfile = "3"
ureq = { version = "2", default-features = false, features = ["tls"] }
wait-timeout = "0.2"
keyring = "3"
which = "7"
zip = "0.6"
+29
View File
@@ -108,6 +108,9 @@ pub enum Command {
/// Manage command aliases (cc -> claude-code)
#[command(subcommand)]
Alias(AliasCmd),
/// Manage secrets in the OS keyring (never in plaintext config)
#[command(subcommand)]
Secret(SecretCmd),
/// Start an agent (foreground by default, or detached with --background)
Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
@@ -369,6 +372,32 @@ pub struct StartArgs {
pub notify: bool,
}
/// Secret management subcommands.
#[derive(Subcommand, Debug, Clone)]
pub enum SecretCmd {
/// Store a secret for an agent
Set {
/// Secret name (the environment variable name)
name: String,
/// Agent it belongs to
#[arg(long, value_name = "AGENT")]
agent: String,
/// Secret value (prefer --value over shell history; never logged)
#[arg(long, value_name = "VALUE")]
value: String,
},
/// Remove a secret
Unset {
/// Secret name
name: String,
/// Agent it belongs to
#[arg(long, value_name = "AGENT")]
agent: String,
},
/// List stored secret names (values are never shown)
List,
}
/// Alias management subcommands.
#[derive(Subcommand, Debug, Clone)]
pub enum AliasCmd {
+7
View File
@@ -17,6 +17,7 @@ pub mod open_cmd;
pub mod projects_cmd;
pub mod run_cmd;
pub mod search_cmd;
pub mod secret_cmd;
pub mod self_uninstall;
pub mod sessions_cmd;
pub mod stats_cmd;
@@ -65,6 +66,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
interval,
} => watch_cmd::run(app, agent, *restart, *notify, *interval),
Command::Alias(sub) => alias_cmd::run(app, sub),
Command::Secret(sub) => secret_cmd::run(app, sub),
Command::Start(a) => run_cmd::start(app, a),
Command::Stop {
agent,
@@ -202,6 +204,11 @@ pub fn resolve_exec(
args.extend(extra_args.iter().cloned());
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);
for w in warnings {
app.log.warn(&w);
}
let (program, args) = route_cmd_script(program, args);
Ok(ResolvedExec { program, args, env })
}
+55
View File
@@ -0,0 +1,55 @@
//! secret: manage secrets in the OS keyring (values never shown or logged).
use super::*;
use crate::cli::SecretCmd;
use crate::output::print_json;
use crate::secrets::{self, SecretStore};
pub fn run(app: &App, sub: &SecretCmd) -> Result<i32> {
match sub {
SecretCmd::Set { name, agent, value } => {
if app.dry_run() {
app.log.dry(format!("would store secret {name} for {agent} 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.info("use it with --env NAME=@secret (injected at start/run)");
Ok(0)
}
SecretCmd::Unset { name, agent } => {
let key = secrets::key_for(agent, name);
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}"));
} else {
app.log.info(&format!("secret {name} does not exist for {agent}"));
}
Ok(0)
}
SecretCmd::List => {
let all = secrets::store().list().unwrap_or_default();
if app.json() {
print_json(&all);
return Ok(0);
}
if all.is_empty() {
app.log.info("no secrets stored — see: am secret set NAME --agent AGENT --value ...");
return Ok(0);
}
let mut table = crate::output::Table::new(vec!["SECRET"]);
for k in all {
if k != "__index__" {
table.row(vec![k]);
}
}
print!("{}", table.render());
Ok(0)
}
}
}
+18
View File
@@ -385,6 +385,24 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "List every alias.", code: "alias list" },
],
},
HelpSpec {
name: "secret",
category: "Commands",
usage: "secret {flags} <set|unset|list>",
about: "Manage secrets in the OS keyring (values never shown or logged).",
search_terms: &["keyring", "token", "password"],
flags: &[],
subcommands: &[
("set", "Store a secret for an agent"),
("unset", "Remove a secret"),
],
parameters: &[],
io: None,
examples: &[
HelpExample { desc: "Store a token.", code: "secret set OPENAI_KEY --agent claude-code --value sk-..." },
HelpExample { desc: "Use it at launch.", code: "start claude-code --env OPENAI_KEY=@secret" },
],
},
HelpSpec {
name: "history",
category: "Commands",
+1
View File
@@ -33,6 +33,7 @@ pub mod ps;
pub mod process;
pub mod repl;
pub mod runner;
pub mod secrets;
pub mod sessions;
pub mod shell;
pub mod state;
+19 -1
View File
@@ -71,6 +71,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("history", "search command history"),
("init", "generate a project config"),
("alias", "manage aliases"),
("secret", "manage secrets"),
("open", "open the install dir"),
("watch", "supervise an agent"),
("search", "search the catalog"),
@@ -149,7 +150,7 @@ impl AmCompleter {
Self {
theme: std::cell::Cell::new(crate::theme::default_theme()),
commands: vec![
"list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "open", "watch", "search", "info", "install", "uninstall", "update",
"list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "secret", "open", "watch", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
@@ -1239,6 +1240,22 @@ fn handle_line(
notify: flag("--notify"),
interval: opt_value("--interval").and_then(|v| v.parse().ok()).unwrap_or(5),
},
"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(),
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(),
}),
Some("list") | None => Command::Secret(crate::cli::SecretCmd::List),
_ => {
app.log.error("usage: secret set NAME --agent A --value V | secret unset NAME --agent A | secret list");
return Ok(false);
}
},
"alias" => match rest.first().map(|s| s.as_str()) {
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
name: rest[1].clone(),
@@ -1417,6 +1434,7 @@ fn is_am_command(word: &str) -> bool {
| "history"
| "init"
| "alias"
| "secret"
| "open"
| "watch"
| "search"
+151
View File
@@ -0,0 +1,151 @@
//! Secrets: OS keyring storage (never plaintext in config, logs or env_keys).
use anyhow::{anyhow, Result};
use std::collections::BTreeMap;
pub trait SecretStore {
fn set(&self, key: &str, value: &str) -> Result<()>;
fn get(&self, key: &str) -> Result<Option<String>>;
fn remove(&self, key: &str) -> Result<bool>;
fn list(&self) -> Result<Vec<String>>;
}
const SERVICE: &str = "agent-manager";
const INDEX_KEY: &str = "__index__";
/// OS keyring backend (Windows Credential Manager, macOS Keychain, Linux
/// Secret Service when available).
pub struct KeyringStore;
impl SecretStore for KeyringStore {
fn set(&self, key: &str, value: &str) -> Result<()> {
let entry = keyring::Entry::new(SERVICE, key)?;
entry.set_password(value).map_err(|e| anyhow!("keyring: {e}"))?;
let mut idx = self.list().unwrap_or_default();
if !idx.iter().any(|k| k == key) {
idx.push(key.to_string());
}
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
let _ = index.set_password(&idx.join(","));
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
let entry = keyring::Entry::new(SERVICE, key)?;
match entry.get_password() {
Ok(v) => Ok(Some(v)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(anyhow!("keyring: {e}")),
}
}
fn remove(&self, key: &str) -> Result<bool> {
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}")),
}
}
fn list(&self) -> Result<Vec<String>> {
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
match index.get_password() {
Ok(v) => Ok(v.split(',').filter(|s| !s.is_empty()).map(String::from).collect()),
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
Err(e) => Err(anyhow!("keyring: {e}")),
}
}
}
/// The production store.
pub fn store() -> KeyringStore {
KeyringStore
}
/// Key for an agent-scoped secret name: "claude-code/OPENAI_KEY".
pub fn key_for(agent: &str, name: &str) -> String {
format!("{agent}/{name}")
}
/// 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).
pub fn resolve_env_secrets(
store: &dyn SecretStore,
agent: &str,
env: &mut BTreeMap<String, String>,
) -> Vec<String> {
let mut warnings = Vec::new();
let keys: Vec<String> = env.keys().cloned().collect();
for k in keys {
let placeholder = env.get(&k).map(|v| v == "@secret").unwrap_or(false);
if !placeholder {
continue;
}
let key = key_for(agent, &k);
match store.get(&key) {
Ok(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:#}")),
}
}
warnings
}
/// In-memory store for tests.
#[derive(Default)]
pub struct MockStore {
pub map: std::sync::Mutex<BTreeMap<String, String>>,
}
impl SecretStore for MockStore {
fn set(&self, key: &str, value: &str) -> Result<()> {
self.map.lock().unwrap().insert(key.to_string(), value.to_string());
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.map.lock().unwrap().get(key).cloned())
}
fn remove(&self, key: &str) -> Result<bool> {
Ok(self.map.lock().unwrap().remove(key).is_some())
}
fn list(&self) -> Result<Vec<String>> {
Ok(self.map.lock().unwrap().keys().cloned().collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mock_roundtrip() {
let s = MockStore::default();
s.set("claude-code/OPENAI_KEY", "sk-123").unwrap();
assert_eq!(s.get("claude-code/OPENAI_KEY").unwrap().as_deref(), Some("sk-123"));
assert_eq!(s.list().unwrap(), vec!["claude-code/OPENAI_KEY".to_string()]);
assert!(s.remove("claude-code/OPENAI_KEY").unwrap());
assert_eq!(s.get("claude-code/OPENAI_KEY").unwrap(), None);
}
#[test]
fn resolves_placeholders_and_warns() {
let s = MockStore::default();
s.set("aider/TOKEN", "secret-value").unwrap();
let mut env = BTreeMap::new();
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);
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"));
}
}