From 2e904e485031bd1c94b6152f6cee0638e08b9f3e Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:21:32 +0000 Subject: [PATCH 01/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 7 +- .../operations/ops_doctor/checks_api_keys.rs | 2 +- .../src/services/operations/ops_secret.rs | 12 +- crates/orchestrator-core/src/lib.rs | 2 +- .../src/secret_device_store.rs | 319 ++++++++++++++++-- .../orchestrator-core/src/secret_keysource.rs | 120 ++++++- .../orchestrator-daemon-runtime/src/quotas.rs | 2 +- docs/architecture/secret-backends.md | 16 +- docs/reference/secrets.md | 2 +- 9 files changed, 444 insertions(+), 38 deletions(-) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index b67a95ad..092bf09d 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -52,11 +52,16 @@ pub struct ServerResolution { /// Build a keychain-backed [`SecretStore`] for `project_root`, mirroring the /// `animus secret` surface so OAuth tokens share the project's keychain /// scope. +/// +/// Consults both the global `~/.animus/config.json` and the project-level +/// `.animus/config.json` for the `secrets` configuration block so that +/// per-project key-source overrides (e.g. `key_source = user-key`) are +/// honored by `mcp auth --complete` and every other OAuth code path. pub fn build_secret_store(project_root: &Path) -> Result, ServerResolutionError> { let scoped_root = scoped_state_root(project_root) .ok_or_else(|| ServerResolutionError::NoScopedRoot(project_root.display().to_string()))?; let scope = resolve_keychain_scope(project_root, &scoped_root); - Ok(Arc::from(orchestrator_core::build_secret_store(&scope, scoped_root))) + Ok(Arc::from(orchestrator_core::build_secret_store_for_project(&scope, scoped_root, project_root))) } /// Pick the keychain service-scope string from the adopted scoped state diff --git a/crates/orchestrator-cli/src/services/operations/ops_doctor/checks_api_keys.rs b/crates/orchestrator-cli/src/services/operations/ops_doctor/checks_api_keys.rs index 9f3f6976..96d8cf14 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_doctor/checks_api_keys.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_doctor/checks_api_keys.rs @@ -73,7 +73,7 @@ fn keychain_store(project_root: &Path) -> Option> { .and_then(|s| s.to_str()) .map(|s| s.to_string()) .unwrap_or_else(|| repository_scope_for_path(project_root)); - Some(orchestrator_core::build_secret_store(&scope, scoped_root)) + Some(orchestrator_core::build_secret_store_for_project(&scope, scoped_root, project_root)) } /// True when `name` resolves to a non-empty value in the project secret store. diff --git a/crates/orchestrator-cli/src/services/operations/ops_secret.rs b/crates/orchestrator-cli/src/services/operations/ops_secret.rs index 86e0b6ba..1e814ffb 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_secret.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_secret.rs @@ -46,8 +46,10 @@ pub(crate) async fn handle_secret( } /// Copy every secret between backends (keyring <-> device store). Builds both -/// ends explicitly via `build_backend` (independent of the configured default), -/// verifies each copy, and only clears the source when `--remove-source` is set. +/// ends explicitly via `build_backend_for_project` (independent of the configured +/// default), verifies each copy, and only clears the source when `--remove-source` +/// is set. Using the project-aware builder ensures `key_source`/`key_file` from +/// `.animus/config.json` are honored when the device backend is the source or target. fn handle_migrate( args: SecretMigrateArgs, project_root: &Path, @@ -61,8 +63,8 @@ fn handle_migrate( "keyring" => ("device", "keyring"), other => return Err(anyhow!("unknown migrate target '{other}' (expected: device or keyring)")), }; - let source = orchestrator_core::build_backend(&scope, scoped_root.to_path_buf(), source_name); - let target = orchestrator_core::build_backend(&scope, scoped_root.to_path_buf(), target_name); + let source = orchestrator_core::build_backend_for_project(&scope, scoped_root.to_path_buf(), source_name, project_root); + let target = orchestrator_core::build_backend_for_project(&scope, scoped_root.to_path_buf(), target_name, project_root); let MigrationOutcome { migrated, remove_failures } = migrate_secrets(source.as_ref(), target.as_ref(), args.remove_source)?; @@ -286,7 +288,7 @@ fn build_store(project_root: &Path) -> Result> { let scoped_root = scoped_state_root(project_root) .ok_or_else(|| anyhow!("could not resolve scoped state root for project at {}", project_root.display()))?; let scope = resolve_keychain_scope(project_root, &scoped_root); - Ok(orchestrator_core::build_secret_store(&scope, scoped_root)) + Ok(orchestrator_core::build_secret_store_for_project(&scope, scoped_root, project_root)) } /// Pick the keychain service-scope string from the adopted scoped state diff --git a/crates/orchestrator-core/src/lib.rs b/crates/orchestrator-core/src/lib.rs index 4a1ca3d0..6615eebb 100644 --- a/crates/orchestrator-core/src/lib.rs +++ b/crates/orchestrator-core/src/lib.rs @@ -96,7 +96,7 @@ pub use runtime_contract::{ cli_tool_executable, cli_tool_read_only_flag, cli_tool_response_schema_flag, CliCapabilities, CliSessionResumeMode, CliSessionResumePlan, }; -pub use secret_device_store::{build_backend, build_secret_store, DeviceEncryptedSecretStore}; +pub use secret_device_store::{build_backend, build_backend_for_project, build_secret_store, build_secret_store_for_project, DeviceEncryptedSecretStore}; pub use secret_keysource::{KeySource, KeySourceConfig, KeySourceKind}; pub use secret_store::{ enforce_injection_cap, index_path as secrets_index_path, keychain_service_name, diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index e86e26f6..20df5fb2 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -319,27 +319,33 @@ fn restrict_dir(_path: &Path) {} /// Build the configured [`SecretStore`] for a repo scope. Reads the global /// `secrets` config to choose the backend. Conservative default: the OS keyring /// (existing installs are unchanged). Uses the device-encrypted store when -/// `backend = device`, or when an encrypted store already exists for this scope -/// (a migrated install keeps using it). This is the single seam the rest of the -/// codebase constructs through, replacing direct `KeyringSecretStore::new`. +/// `backend = device`, when an encrypted store already exists for this scope +/// (a migrated install keeps using it), or when a server key source is +/// configured/injected (headless install — avoids the keyring-unavailable error). +/// This is the single seam the rest of the codebase constructs through. pub fn build_secret_store(repo_scope: &str, scoped_root: impl Into) -> Box { - let scoped_root = scoped_root.into(); let cfg = protocol::Config::load_global_if_exists().and_then(|c| c.secrets).unwrap_or_default(); - let resolved = match cfg.backend.as_deref().unwrap_or("auto") { - "device" => "device", - "keyring" | "env" => "keyring", - // auto: keep using the device store once one exists (post-migration), - // otherwise stay on the keyring so existing secrets are never stranded. - _ => { - let device = DeviceEncryptedSecretStore::new(scoped_root.clone(), key_source_config(&cfg)); - if device.path().exists() { - "device" - } else { - "keyring" - } - } - }; - build_backend(repo_scope, scoped_root, resolved) + build_with_cfg(repo_scope, scoped_root.into(), &cfg) +} + +/// Build the configured [`SecretStore`], consulting both the global config +/// (`~/.animus/config.json`) and the project-level `.animus/config.json`. The +/// project config's `secrets` block takes precedence over the global config's, +/// so per-project deployments can override the key source without touching the +/// global config. +/// +/// Use this instead of [`build_secret_store`] in surfaces that have access to +/// the project root (e.g. `mcp auth --complete`) so that writing `key_source` +/// into the project-level config is honored end-to-end. +pub fn build_secret_store_for_project( + repo_scope: &str, + scoped_root: impl Into, + project_root: &Path, +) -> Box { + let global = protocol::Config::load_global_if_exists().and_then(|c| c.secrets).unwrap_or_default(); + let project = load_project_secrets_config(project_root); + let cfg = merge_secrets_config(global, project); + build_with_cfg(repo_scope, scoped_root.into(), &cfg) } /// Build a SPECIFIC backend by name (`"device"` or anything else → keyring), @@ -355,6 +361,96 @@ pub fn build_backend(repo_scope: &str, scoped_root: impl Into, backend: } } +/// Same as [`build_backend`] but also loads the project-level `.animus/config.json` +/// so `key_source`/`key_file` set in the project config are honored when explicitly +/// building the device backend (e.g. for `animus secret migrate`). The project +/// config's `secrets` block wins field-by-field over the global config. +pub fn build_backend_for_project( + repo_scope: &str, + scoped_root: impl Into, + backend: &str, + project_root: &Path, +) -> Box { + let scoped_root = scoped_root.into(); + if backend == "device" { + let global = protocol::Config::load_global_if_exists().and_then(|c| c.secrets).unwrap_or_default(); + let project = load_project_secrets_config(project_root); + let cfg = merge_secrets_config(global, project); + Box::new(DeviceEncryptedSecretStore::new(scoped_root, key_source_config(&cfg))) + } else { + Box::new(crate::secret_store::KeyringSecretStore::new(repo_scope, scoped_root)) + } +} + +/// Core builder: choose backend from `cfg` and construct the store. +fn build_with_cfg(repo_scope: &str, scoped_root: PathBuf, cfg: &protocol::SecretsConfig) -> Box { + let backend = resolve_auto_backend(cfg, &scoped_root); + if backend == "device" { + Box::new(DeviceEncryptedSecretStore::new(scoped_root, key_source_config(cfg))) + } else { + Box::new(crate::secret_store::KeyringSecretStore::new(repo_scope, scoped_root)) + } +} + +/// Resolve which storage backend to use given a [`protocol::SecretsConfig`]. +/// +/// `auto` rules (applied in order): +/// 1. An operator-configured or env-injected server key source (`user-key` / +/// `passphrase` / `ANIMUS_SECRET_KEY` / `ANIMUS_SECRET_PASSPHRASE`) → +/// `device`. The operator has signaled they want device-encrypted storage; +/// on headless hosts this avoids the OS-keyring-unavailable hard error. +/// 2. A device-encrypted store already exists for this scope → `device`. +/// Post-migration installs continue using the device store. +/// 3. Fall back to `keyring` (existing desktop installs are unchanged). +fn resolve_auto_backend(cfg: &protocol::SecretsConfig, scoped_root: &Path) -> &'static str { + match cfg.backend.as_deref().unwrap_or("auto") { + "device" => "device", + "keyring" | "env" => "keyring", + _ => { + if has_server_key_configured(cfg) { + return "device"; + } + let device = DeviceEncryptedSecretStore::new(scoped_root.to_path_buf(), key_source_config(cfg)); + if device.path().exists() { "device" } else { "keyring" } + } + } +} + +/// True when a server-appropriate key source is available: explicitly configured +/// via `key_source`, a `key_file` path (honored by `auto` and `user-key`), or +/// injected via the corresponding env var. +fn has_server_key_configured(cfg: &protocol::SecretsConfig) -> bool { + use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; + matches!(cfg.key_source.as_deref(), Some("user-key") | Some("user_key") | Some("passphrase")) + || cfg.key_file.is_some() + || std::env::var(ENV_USER_KEY).is_ok() + || std::env::var(ENV_PASSPHRASE).is_ok() +} + +/// Read the project-level `.animus/config.json` and return its `secrets` block. +/// Returns `None` when the file is absent or unparseable (side-effect-free). +fn load_project_secrets_config(project_root: &Path) -> Option { + let path = project_root.join(".animus").join("config.json"); + if !path.exists() { + return None; + } + let content = std::fs::read_to_string(&path).ok()?; + serde_json::from_str::(&content).ok()?.secrets +} + +/// Merge two [`protocol::SecretsConfig`] values; `project` wins field-by-field. +fn merge_secrets_config( + global: protocol::SecretsConfig, + project: Option, +) -> protocol::SecretsConfig { + let Some(proj) = project else { return global }; + protocol::SecretsConfig { + backend: proj.backend.or(global.backend), + key_source: proj.key_source.or(global.key_source), + key_file: proj.key_file.or(global.key_file), + } +} + fn key_source_config(cfg: &protocol::SecretsConfig) -> KeySourceConfig { let kind = cfg .key_source @@ -377,6 +473,7 @@ fn key_source_config(cfg: &protocol::SecretsConfig) -> KeySourceConfig { mod tests { use super::*; use crate::secret_keysource::{KeySourceConfig, KeySourceKind}; + use crate::secret_keysource::tests::env_lock; // A user-key store backed by a per-test key FILE, so tests need no shared // process env and never race each other. @@ -392,6 +489,9 @@ mod tests { #[test] fn round_trip_set_get_list_delete() { + // UserKeySource::resolve checks ANIMUS_SECRET_KEY first; hold env_lock + // so tests that mutate the var cannot race this key-file-based test. + let _guard = env_lock().lock().unwrap(); let tmp = tempfile::tempdir().unwrap(); let s = store(tmp.path()); assert_eq!(s.get("API_KEY").unwrap(), None); @@ -409,6 +509,7 @@ mod tests { #[test] fn file_is_not_plaintext() { + let _guard = env_lock().lock().unwrap(); let tmp = tempfile::tempdir().unwrap(); let s = store(tmp.path()); s.set("API_KEY", "PLAINTEXT_NEEDLE").unwrap(); @@ -418,6 +519,7 @@ mod tests { #[test] fn tamper_fails_closed() { + let _guard = env_lock().lock().unwrap(); let tmp = tempfile::tempdir().unwrap(); let s = store(tmp.path()); s.set("API_KEY", "v").unwrap(); @@ -430,10 +532,189 @@ mod tests { #[test] fn wrong_device_key_cannot_decrypt() { + let _guard = env_lock().lock().unwrap(); let tmp = tempfile::tempdir().unwrap(); store_with_key(tmp.path(), "right.key", [3u8; KEY_LEN]).set("API_KEY", "v").unwrap(); // Simulate the file moved to a machine with a different key. let wrong = store_with_key(tmp.path(), "wrong.key", [9u8; KEY_LEN]); assert!(wrong.get("API_KEY").is_err(), "a different device/user key must not decrypt the store"); } + + // --- build_secret_store_for_project / merge / resolve_auto_backend --- + + fn write_project_secrets_config(project_root: &Path, key_source: Option<&str>, key_file: Option<&str>) { + let animus_dir = project_root.join(".animus"); + std::fs::create_dir_all(&animus_dir).unwrap(); + let cfg = serde_json::json!({ + "secrets": { + "key_source": key_source, + "key_file": key_file, + "backend": "device" + } + }); + std::fs::write(animus_dir.join("config.json"), serde_json::to_string_pretty(&cfg).unwrap()).unwrap(); + } + + #[test] + fn merge_secrets_config_project_wins_field_by_field() { + let global = protocol::SecretsConfig { + backend: Some("keyring".to_string()), + key_source: Some("device-id".to_string()), + key_file: Some("/global/key".to_string()), + }; + let project = Some(protocol::SecretsConfig { + backend: None, + key_source: Some("user-key".to_string()), + key_file: None, + }); + let merged = merge_secrets_config(global, project); + // project key_source wins; global backend/key_file kept where project has None + assert_eq!(merged.key_source.as_deref(), Some("user-key")); + assert_eq!(merged.backend.as_deref(), Some("keyring")); + assert_eq!(merged.key_file.as_deref(), Some("/global/key")); + } + + #[test] + fn merge_secrets_config_no_project_returns_global() { + let global = protocol::SecretsConfig { + backend: Some("device".to_string()), + key_source: Some("user-key".to_string()), + key_file: Some("/k".to_string()), + }; + let merged = merge_secrets_config(global.clone(), None); + assert_eq!(merged, global); + } + + #[test] + fn has_server_key_configured_env_user_key() { + use crate::secret_keysource::ENV_USER_KEY; + let cfg = protocol::SecretsConfig::default(); + let _guard = env_lock().lock().unwrap(); + let prev = std::env::var(ENV_USER_KEY).ok(); + // Use a valid 32-byte hex key so other tests do not see an invalid value + // if this key somehow outlives its lock window. + std::env::set_var(ENV_USER_KEY, hex::encode([0xEEu8; KEY_LEN])); + let result = has_server_key_configured(&cfg); + match &prev { + Some(v) => std::env::set_var(ENV_USER_KEY, v), + None => std::env::remove_var(ENV_USER_KEY), + } + assert!(result, "has_server_key_configured must be true when ANIMUS_SECRET_KEY is set"); + } + + #[test] + fn has_server_key_configured_via_config_key_source() { + let cfg = protocol::SecretsConfig { key_source: Some("user-key".to_string()), ..Default::default() }; + assert!(has_server_key_configured(&cfg)); + let cfg2 = protocol::SecretsConfig { key_source: Some("passphrase".to_string()), ..Default::default() }; + assert!(has_server_key_configured(&cfg2)); + let cfg3 = protocol::SecretsConfig { key_source: Some("device-id".to_string()), ..Default::default() }; + use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; + use crate::secret_keysource::tests::env_lock; + let _guard = env_lock().lock().unwrap(); + let prev_key = std::env::var(ENV_USER_KEY).ok(); + let prev_pass = std::env::var(ENV_PASSPHRASE).ok(); + std::env::remove_var(ENV_USER_KEY); + std::env::remove_var(ENV_PASSPHRASE); + let result = has_server_key_configured(&cfg3); + if let Some(v) = prev_key { std::env::set_var(ENV_USER_KEY, v) } + if let Some(v) = prev_pass { std::env::set_var(ENV_PASSPHRASE, v) } + assert!(!result, "device-id key source must not count as a server key"); + } + + #[test] + fn has_server_key_configured_with_key_file() { + use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; + let cfg = protocol::SecretsConfig { key_file: Some("/srv/animus/secret.key".to_string()), ..Default::default() }; + // Remove env vars so only key_file drives the result. + let _guard = env_lock().lock().unwrap(); + let prev_key = std::env::var(ENV_USER_KEY).ok(); + let prev_pass = std::env::var(ENV_PASSPHRASE).ok(); + std::env::remove_var(ENV_USER_KEY); + std::env::remove_var(ENV_PASSPHRASE); + let result = has_server_key_configured(&cfg); + if let Some(v) = prev_key { std::env::set_var(ENV_USER_KEY, v) } + if let Some(v) = prev_pass { std::env::set_var(ENV_PASSPHRASE, v) } + assert!(result, "key_file in secrets config must count as a server key source"); + } + + #[test] + fn build_secret_store_for_project_reads_project_config() { + crate::test_env::stable_test_home(); + let _guard = env_lock().lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let project_dir = tmp.path().join("project"); + std::fs::create_dir_all(&project_dir).unwrap(); + let key = [0xABu8; KEY_LEN]; + let key_file = tmp.path().join("server.key"); + std::fs::write(&key_file, hex::encode(key)).unwrap(); + write_project_secrets_config(&project_dir, Some("user-key"), Some(key_file.to_str().unwrap())); + let scope = "test-project-scope"; + let scoped_root = tmp.path().join("state"); + std::fs::create_dir_all(&scoped_root).unwrap(); + // Ensure ANIMUS_SECRET_KEY is not set so the key file is used. + use crate::secret_keysource::ENV_USER_KEY; + let prev = std::env::var(ENV_USER_KEY).ok(); + std::env::remove_var(ENV_USER_KEY); + let store = build_secret_store_for_project(scope, scoped_root, &project_dir); + let set_result = store.set("FOO", "bar"); + if let Some(v) = prev { std::env::set_var(ENV_USER_KEY, v) } + set_result.expect("project-config-sourced store must accept writes"); + assert_eq!(store.get("FOO").unwrap().as_deref(), Some("bar")); + } + + #[test] + fn resolve_auto_backend_uses_device_when_server_key_in_cfg() { + let cfg = protocol::SecretsConfig { + backend: None, + key_source: Some("user-key".to_string()), + key_file: None, + }; + let dir = tempfile::tempdir().unwrap(); + // No pre-existing device store — but server key is configured. + assert_eq!(resolve_auto_backend(&cfg, dir.path()), "device"); + } + + #[test] + fn resolve_auto_backend_falls_back_to_keyring_without_server_key() { + use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; + use crate::secret_keysource::tests::env_lock; + let _guard = env_lock().lock().unwrap(); + let prev_key = std::env::var(ENV_USER_KEY).ok(); + let prev_pass = std::env::var(ENV_PASSPHRASE).ok(); + std::env::remove_var(ENV_USER_KEY); + std::env::remove_var(ENV_PASSPHRASE); + let cfg = protocol::SecretsConfig::default(); + let dir = tempfile::tempdir().unwrap(); + let result = resolve_auto_backend(&cfg, dir.path()); + if let Some(v) = prev_key { std::env::set_var(ENV_USER_KEY, v) } + if let Some(v) = prev_pass { std::env::set_var(ENV_PASSPHRASE, v) } + assert_eq!(result, "keyring", "auto without a server key and no existing store must fall back to keyring"); + } + + #[test] + fn build_backend_for_project_honors_project_key_source() { + crate::test_env::stable_test_home(); + let _guard = env_lock().lock().unwrap(); + use crate::secret_keysource::ENV_USER_KEY; + let tmp = tempfile::tempdir().unwrap(); + let project_dir = tmp.path().join("project"); + std::fs::create_dir_all(&project_dir).unwrap(); + let key = [0xCDu8; KEY_LEN]; + let key_file = tmp.path().join("migrate.key"); + std::fs::write(&key_file, hex::encode(key)).unwrap(); + // Write project config with user-key source and a key_file. + write_project_secrets_config(&project_dir, Some("user-key"), Some(key_file.to_str().unwrap())); + let scope = "test-migrate-scope"; + let scoped_root = tmp.path().join("state"); + std::fs::create_dir_all(&scoped_root).unwrap(); + // Remove env var so only the key_file drives the device store key. + let prev = std::env::var(ENV_USER_KEY).ok(); + std::env::remove_var(ENV_USER_KEY); + let store = build_backend_for_project(scope, scoped_root, "device", &project_dir); + let set_result = store.set("MIGRATE_KEY", "value"); + if let Some(v) = prev { std::env::set_var(ENV_USER_KEY, v) } + set_result.expect("build_backend_for_project must honor project key_file for the device backend"); + assert_eq!(store.get("MIGRATE_KEY").unwrap().as_deref(), Some("value")); + } } diff --git a/crates/orchestrator-core/src/secret_keysource.rs b/crates/orchestrator-core/src/secret_keysource.rs index f502a140..8eb39bff 100644 --- a/crates/orchestrator-core/src/secret_keysource.rs +++ b/crates/orchestrator-core/src/secret_keysource.rs @@ -306,20 +306,59 @@ pub fn resolve_key_source(config: &KeySourceConfig, salt: &[u8]) -> Result Ok(Box::new(DeviceIdKeySource::resolve(salt)?)), - KeySourceKind::Auto => resolve_auto(salt), + KeySourceKind::Auto => resolve_auto(config, salt), } } /// `auto`: prefer an OS hardware-backed key, fall back to `device-id`. Hardware /// providers (Secure Enclave / DPAPI / TPM) are wired in per platform; until a -/// platform's provider lands, `auto` resolves to `device-id` there. -fn resolve_auto(salt: &[u8]) -> Result> { +/// platform's provider lands, `auto` resolves per the following priority: +/// +/// 1. `ANIMUS_SECRET_KEY` env var → `user-key` (runtime-injected key; highest priority) +/// 2. `key_file` from `config` → `user-key` (operator-configured file; headless-safe) +/// 3. `ANIMUS_SECRET_PASSPHRASE` env var → `passphrase` (Argon2id KDF; headless-safe) +/// 4. `device-id` (fallback; interactive hosts only — binding, not on-device-secret-safe) +/// +/// Steps 1–3 let headless/server deployments work without setting +/// `secret_key_source` explicitly: they just supply the key material (via env +/// or file) and `auto` does the right thing. This avoids the keyring-unavailable +/// hard error and prevents the device-id redeploy wipe caused by a new machine-id. +/// +/// The priority here MUST mirror `has_server_key_configured` in +/// `secret_device_store` — that function picks the `device` backend for the +/// same set of conditions; if a condition triggers backend=device but this +/// function falls through to `device-id`, the store will be sealed with the +/// wrong key and reads will fail. +fn resolve_auto(config: &KeySourceConfig, salt: &[u8]) -> Result> { + // Prefer operator-supplied key: env var wins over key_file so runtime + // injection (e.g. Docker secrets via envFrom) takes precedence over a + // file configured in the project/global config. If only key_file is set, + // UserKeySource::resolve will still try the env first then the file. + if std::env::var(ENV_USER_KEY).is_ok() || config.key_file.is_some() { + return Ok(Box::new(UserKeySource::resolve(config.key_file.as_deref())?)); + } + // Passphrase env var: also a headless-safe server source. The backend + // selector (`has_server_key_configured`) already counts this as a + // "server key" and picks the device backend; resolve to the matching + // key source here so the two paths stay in sync. + if std::env::var(ENV_PASSPHRASE).is_ok() { + return Ok(Box::new(PassphraseKeySource::resolve(None, salt)?)); + } Ok(Box::new(DeviceIdKeySource::resolve(salt)?)) } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; + use std::sync::{Mutex, OnceLock}; + + /// Serialize all tests that mutate process-wide env vars so they cannot + /// race each other. Any test that calls `set_var`/`remove_var` must hold + /// this lock for the duration of the mutation + observation window. + pub(crate) fn env_lock() -> &'static Mutex<()> { + static ENV_LOCK: OnceLock> = OnceLock::new(); + ENV_LOCK.get_or_init(|| Mutex::new(())) + } #[test] fn key_source_kind_parse_round_trips() { @@ -352,6 +391,79 @@ mod tests { assert_ne!(*a1.key().unwrap(), *b.key().unwrap(), "different salt must derive a different key"); } + #[test] + fn resolve_auto_uses_user_key_when_env_is_set() { + use base64::Engine; + let raw = [0x42u8; KEY_LEN]; + let b64 = base64::engine::general_purpose::STANDARD.encode(raw); + let _guard = env_lock().lock().unwrap(); + let prev = std::env::var(ENV_USER_KEY).ok(); + std::env::set_var(ENV_USER_KEY, &b64); + let salt = [0u8; 16]; + let result = resolve_auto(&KeySourceConfig::default(), &salt); + match &prev { + Some(v) => std::env::set_var(ENV_USER_KEY, v), + None => std::env::remove_var(ENV_USER_KEY), + } + let src = result.expect("resolve_auto with ANIMUS_SECRET_KEY set should succeed"); + assert_eq!(src.id(), "user-key", "auto must resolve to user-key when ANIMUS_SECRET_KEY is set"); + assert_eq!(*src.key().unwrap(), raw); + } + + #[test] + fn resolve_auto_uses_user_key_when_key_file_configured() { + let raw = [0x55u8; KEY_LEN]; + let tmp = tempfile::tempdir().unwrap(); + let key_file = tmp.path().join("server.key"); + std::fs::write(&key_file, hex::encode(raw)).unwrap(); + let _guard = env_lock().lock().unwrap(); + let prev = std::env::var(ENV_USER_KEY).ok(); + std::env::remove_var(ENV_USER_KEY); + let config = KeySourceConfig { kind_override: None, key_file: Some(key_file), passphrase: None }; + let salt = [0u8; 16]; + let result = resolve_auto(&config, &salt); + match &prev { + Some(v) => std::env::set_var(ENV_USER_KEY, v), + None => std::env::remove_var(ENV_USER_KEY), + } + let src = result.expect("resolve_auto with key_file configured should succeed"); + assert_eq!(src.id(), "user-key", "auto must resolve to user-key when key_file is configured"); + assert_eq!(*src.key().unwrap(), raw); + } + + #[test] + fn resolve_auto_uses_passphrase_when_passphrase_env_is_set() { + let _guard = env_lock().lock().unwrap(); + let prev_key = std::env::var(ENV_USER_KEY).ok(); + let prev_pass = std::env::var(ENV_PASSPHRASE).ok(); + std::env::remove_var(ENV_USER_KEY); + std::env::set_var(ENV_PASSPHRASE, "headless-passphrase"); + let salt = [0xAAu8; 16]; + let result = resolve_auto(&KeySourceConfig::default(), &salt); + match &prev_key { Some(v) => std::env::set_var(ENV_USER_KEY, v), None => std::env::remove_var(ENV_USER_KEY) } + match &prev_pass { Some(v) => std::env::set_var(ENV_PASSPHRASE, v), None => std::env::remove_var(ENV_PASSPHRASE) } + let src = result.expect("resolve_auto with ANIMUS_SECRET_PASSPHRASE set should succeed"); + assert_eq!(src.id(), "passphrase", "auto must resolve to passphrase when ANIMUS_SECRET_PASSPHRASE is set"); + } + + #[test] + fn resolve_auto_user_key_wins_over_passphrase() { + use base64::Engine; + let raw = [0x99u8; KEY_LEN]; + let b64 = base64::engine::general_purpose::STANDARD.encode(raw); + let _guard = env_lock().lock().unwrap(); + let prev_key = std::env::var(ENV_USER_KEY).ok(); + let prev_pass = std::env::var(ENV_PASSPHRASE).ok(); + std::env::set_var(ENV_USER_KEY, &b64); + std::env::set_var(ENV_PASSPHRASE, "also-set"); + let salt = [0u8; 16]; + let result = resolve_auto(&KeySourceConfig::default(), &salt); + match &prev_key { Some(v) => std::env::set_var(ENV_USER_KEY, v), None => std::env::remove_var(ENV_USER_KEY) } + match &prev_pass { Some(v) => std::env::set_var(ENV_PASSPHRASE, v), None => std::env::remove_var(ENV_PASSPHRASE) } + let src = result.expect("resolve_auto with both env vars set should succeed"); + assert_eq!(src.id(), "user-key", "user-key env must take priority over passphrase env"); + } + #[test] fn device_id_is_deterministic_and_binds_to_machine_material() { let salt = [9u8; 16]; diff --git a/crates/orchestrator-daemon-runtime/src/quotas.rs b/crates/orchestrator-daemon-runtime/src/quotas.rs index 10a65ed3..a27e3f1a 100644 --- a/crates/orchestrator-daemon-runtime/src/quotas.rs +++ b/crates/orchestrator-daemon-runtime/src/quotas.rs @@ -253,7 +253,7 @@ pub fn install_keychain_secret_provider_for(project_root: &std::path::Path) -> b return false; }; let scope = scope_label_for_scoped_root(project_root, &scoped_root); - let store = orchestrator_core::build_secret_store(&scope, scoped_root); + let store = orchestrator_core::build_secret_store_for_project(&scope, scoped_root, project_root); orchestrator_plugin_host::install_secret_snapshot_provider(std::sync::Arc::new( KeychainSecretSnapshotProvider::new(store), )) diff --git a/docs/architecture/secret-backends.md b/docs/architecture/secret-backends.md index cee19989..d0c837e5 100644 --- a/docs/architecture/secret-backends.md +++ b/docs/architecture/secret-backends.md @@ -64,12 +64,18 @@ trait KeySource { fn key(&self) -> Result>; fn id(&self) -> Selected by config `secret_key_source`: -- `auto` (default): resolves to `device-id` today. The hardware key sources - (Secure Enclave / DPAPI / TPM) are deferred — see "Platform support" below — - so `auto` does not currently reach for an OS-hardware key. +- `auto` (default): resolves in priority order — (1) `ANIMUS_SECRET_KEY` env var + → `user-key`; (2) `key_file` configured in the `secrets` block → `user-key`; + (3) `ANIMUS_SECRET_PASSPHRASE` env var → `passphrase`; (4) `device-id` fallback + (interactive hosts). Steps 1–3 make headless/server deployments work without + setting `key_source` explicitly: supplying the key via env or file is enough, and + `auto` selects the right source automatically. This avoids the + keyring-unavailable hard error and prevents the device-id redeploy wipe caused by + a new machine-id on container rebuild. The hardware key sources (Secure Enclave / + DPAPI / TPM) are deferred — see "Platform support" below. - `user-key`: operator-supplied 32-byte key from `ANIMUS_SECRET_KEY` - (hex or base64) or a `secret_key_file` path. For headless/server with a - deploy-injected key (systemd `LoadCredential`, mounted secret, external KMS). + (hex or base64) or a `key_file` path. For headless/server with a deploy-injected + key (systemd `LoadCredential`, mounted secret, external KMS). - `passphrase`: `Argon2id(passphrase, salt)`. The passphrase arrives via `ANIMUS_SECRET_PASSPHRASE` for both the CLI and the daemon — env-driven and script-safe, with no TTY-only path that would break under automation. In diff --git a/docs/reference/secrets.md b/docs/reference/secrets.md index e873e9f9..9fcb6b83 100644 --- a/docs/reference/secrets.md +++ b/docs/reference/secrets.md @@ -80,7 +80,7 @@ Select per machine in the **global** config (`~/.animus/config.json`, or `$ANIMU - **`device-id`** — `HKDF(machine-id + per-install salt)`. The machine id never travels with the file, so an off-device copy can't decrypt. Cross-platform, no prompt. Default fallback. - **`user-key`** — an operator-supplied 32-byte key from `ANIMUS_SECRET_KEY` (hex/base64) or `key_file`. For headless/server with a deploy-injected key (systemd `LoadCredential`, mounted secret, external KMS). - **`passphrase`** — `Argon2id` over a passphrase read from `ANIMUS_SECRET_PASSPHRASE`. Env-driven for both the CLI and the daemon (so the mode is script-safe and behaves identically everywhere); in exposure terms a non-interactive passphrase is equivalent to `user-key`. The store errors with that variable name when it is unset. -- **`auto`** — resolves to `device-id` today. The OS hardware-backed sources (Secure Enclave / DPAPI / TPM) are deferred (see the architecture doc for the per-platform reasons), so `auto` is currently equivalent to `device-id`. +- **`auto`** — resolves in priority order: (1) `ANIMUS_SECRET_KEY` env var → `user-key`; (2) `key_file` configured in the `secrets` block → `user-key`; (3) `ANIMUS_SECRET_PASSPHRASE` env var → `passphrase`; (4) `device-id` fallback. Steps 1–3 let headless/server deployments work without setting `key_source` explicitly — just supply the key material via env or file and `auto` selects the right source. The OS hardware-backed sources (Secure Enclave / DPAPI / TPM) are deferred. ### Moving between backends From a00f8ea008b1ce487592a3193cc2570ca6d72cc1 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:57:32 +0000 Subject: [PATCH 02/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/orchestrator-core/src/secret_device_store.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index 20df5fb2..9fdb2856 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -548,8 +548,7 @@ mod tests { let cfg = serde_json::json!({ "secrets": { "key_source": key_source, - "key_file": key_file, - "backend": "device" + "key_file": key_file } }); std::fs::write(animus_dir.join("config.json"), serde_json::to_string_pretty(&cfg).unwrap()).unwrap(); @@ -648,6 +647,9 @@ mod tests { let key = [0xABu8; KEY_LEN]; let key_file = tmp.path().join("server.key"); std::fs::write(&key_file, hex::encode(key)).unwrap(); + // Do not force `backend = device`: this exercises the production + // headless path where configured server key material makes `auto` + // select the device-encrypted store. write_project_secrets_config(&project_dir, Some("user-key"), Some(key_file.to_str().unwrap())); let scope = "test-project-scope"; let scoped_root = tmp.path().join("state"); From 0ff240f791d25d1704d8afe6071dffb44b405335 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:06:17 +0000 Subject: [PATCH 03/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 47 ++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index 092bf09d..c1f6adca 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -60,8 +60,13 @@ pub struct ServerResolution { pub fn build_secret_store(project_root: &Path) -> Result, ServerResolutionError> { let scoped_root = scoped_state_root(project_root) .ok_or_else(|| ServerResolutionError::NoScopedRoot(project_root.display().to_string()))?; + Ok(build_secret_store_at(project_root, scoped_root)) +} + +fn build_secret_store_at(project_root: &Path, scoped_root: impl Into) -> Arc { + let scoped_root = scoped_root.into(); let scope = resolve_keychain_scope(project_root, &scoped_root); - Ok(Arc::from(orchestrator_core::build_secret_store_for_project(&scope, scoped_root, project_root))) + Arc::from(orchestrator_core::build_secret_store_for_project(&scope, scoped_root, project_root)) } /// Pick the keychain service-scope string from the adopted scoped state @@ -193,3 +198,43 @@ fn finalize( }), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn oauth_secret_store_honors_project_key_file_without_env_key() { + let tmp = tempfile::tempdir().unwrap(); + let project_root = tmp.path().join("project"); + let animus_dir = project_root.join(".animus"); + std::fs::create_dir_all(&animus_dir).unwrap(); + + let key_file = tmp.path().join("server.key"); + std::fs::write(&key_file, "a5".repeat(32)).unwrap(); + let config = serde_json::json!({ + "secrets": { + "key_source": "user-key", + "key_file": key_file + } + }); + std::fs::write(animus_dir.join("config.json"), serde_json::to_vec(&config).unwrap()).unwrap(); + + let previous_key = std::env::var("ANIMUS_SECRET_KEY").ok(); + std::env::remove_var("ANIMUS_SECRET_KEY"); + let store = build_secret_store_at(&project_root, tmp.path().join("state")); + let result = store.set("oauth:test", "token"); + let stored = result.and_then(|()| store.get("oauth:test")); + match previous_key { + Some(value) => std::env::set_var("ANIMUS_SECRET_KEY", value), + None => std::env::remove_var("ANIMUS_SECRET_KEY"), + } + + assert_eq!( + stored + .expect("OAuth secret operations must use the project-configured key file") + .as_deref(), + Some("token") + ); + } +} From 666962126a83be9adfbdb89eb42c7b5a488d124a Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:13:12 +0000 Subject: [PATCH 04/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 29 +++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index c1f6adca..030b909a 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -203,6 +203,28 @@ fn finalize( mod tests { use super::*; + struct EnvVarGuard { + name: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn remove(name: &'static str) -> Self { + let previous = std::env::var(name).ok(); + std::env::remove_var(name); + Self { name, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(self.name, value), + None => std::env::remove_var(self.name), + } + } + } + #[test] fn oauth_secret_store_honors_project_key_file_without_env_key() { let tmp = tempfile::tempdir().unwrap(); @@ -220,15 +242,10 @@ mod tests { }); std::fs::write(animus_dir.join("config.json"), serde_json::to_vec(&config).unwrap()).unwrap(); - let previous_key = std::env::var("ANIMUS_SECRET_KEY").ok(); - std::env::remove_var("ANIMUS_SECRET_KEY"); + let _key_guard = EnvVarGuard::remove("ANIMUS_SECRET_KEY"); let store = build_secret_store_at(&project_root, tmp.path().join("state")); let result = store.set("oauth:test", "token"); let stored = result.and_then(|()| store.get("oauth:test")); - match previous_key { - Some(value) => std::env::set_var("ANIMUS_SECRET_KEY", value), - None => std::env::remove_var("ANIMUS_SECRET_KEY"), - } assert_eq!( stored From 3d5f1aad77f1061528c5d873292b0f1b3bdad71f Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:21:20 +0000 Subject: [PATCH 05/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 23 ++++--- .../src/services/operations/ops_secret.rs | 6 +- crates/orchestrator-core/src/lib.rs | 5 +- .../src/secret_device_store.rs | 60 ++++++++++++------- .../orchestrator-core/src/secret_keysource.rs | 20 +++++-- 5 files changed, 76 insertions(+), 38 deletions(-) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index 030b909a..373b1926 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -225,8 +225,7 @@ mod tests { } } - #[test] - fn oauth_secret_store_honors_project_key_file_without_env_key() { + fn assert_oauth_store_uses_project_key_file(key_source: &str) { let tmp = tempfile::tempdir().unwrap(); let project_root = tmp.path().join("project"); let animus_dir = project_root.join(".animus"); @@ -236,7 +235,7 @@ mod tests { std::fs::write(&key_file, "a5".repeat(32)).unwrap(); let config = serde_json::json!({ "secrets": { - "key_source": "user-key", + "key_source": key_source, "key_file": key_file } }); @@ -244,14 +243,22 @@ mod tests { let _key_guard = EnvVarGuard::remove("ANIMUS_SECRET_KEY"); let store = build_secret_store_at(&project_root, tmp.path().join("state")); - let result = store.set("oauth:test", "token"); - let stored = result.and_then(|()| store.get("oauth:test")); + let result = store.set("oauth_test", "token"); + let stored = result.and_then(|()| store.get("oauth_test")); assert_eq!( - stored - .expect("OAuth secret operations must use the project-configured key file") - .as_deref(), + stored.expect("OAuth secret operations must use the project-configured key file").as_deref(), Some("token") ); } + + #[test] + fn oauth_secret_store_honors_project_user_key_without_env_key() { + assert_oauth_store_uses_project_key_file("user-key"); + } + + #[test] + fn oauth_secret_store_auto_uses_project_key_file_without_env_key() { + assert_oauth_store_uses_project_key_file("auto"); + } } diff --git a/crates/orchestrator-cli/src/services/operations/ops_secret.rs b/crates/orchestrator-cli/src/services/operations/ops_secret.rs index 1e814ffb..e057c4b5 100644 --- a/crates/orchestrator-cli/src/services/operations/ops_secret.rs +++ b/crates/orchestrator-cli/src/services/operations/ops_secret.rs @@ -63,8 +63,10 @@ fn handle_migrate( "keyring" => ("device", "keyring"), other => return Err(anyhow!("unknown migrate target '{other}' (expected: device or keyring)")), }; - let source = orchestrator_core::build_backend_for_project(&scope, scoped_root.to_path_buf(), source_name, project_root); - let target = orchestrator_core::build_backend_for_project(&scope, scoped_root.to_path_buf(), target_name, project_root); + let source = + orchestrator_core::build_backend_for_project(&scope, scoped_root.to_path_buf(), source_name, project_root); + let target = + orchestrator_core::build_backend_for_project(&scope, scoped_root.to_path_buf(), target_name, project_root); let MigrationOutcome { migrated, remove_failures } = migrate_secrets(source.as_ref(), target.as_ref(), args.remove_source)?; diff --git a/crates/orchestrator-core/src/lib.rs b/crates/orchestrator-core/src/lib.rs index 6615eebb..0db8e143 100644 --- a/crates/orchestrator-core/src/lib.rs +++ b/crates/orchestrator-core/src/lib.rs @@ -96,7 +96,10 @@ pub use runtime_contract::{ cli_tool_executable, cli_tool_read_only_flag, cli_tool_response_schema_flag, CliCapabilities, CliSessionResumeMode, CliSessionResumePlan, }; -pub use secret_device_store::{build_backend, build_backend_for_project, build_secret_store, build_secret_store_for_project, DeviceEncryptedSecretStore}; +pub use secret_device_store::{ + build_backend, build_backend_for_project, build_secret_store, build_secret_store_for_project, + DeviceEncryptedSecretStore, +}; pub use secret_keysource::{KeySource, KeySourceConfig, KeySourceKind}; pub use secret_store::{ enforce_injection_cap, index_path as secrets_index_path, keychain_service_name, diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index 9fdb2856..1e4962cf 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -411,7 +411,11 @@ fn resolve_auto_backend(cfg: &protocol::SecretsConfig, scoped_root: &Path) -> &' return "device"; } let device = DeviceEncryptedSecretStore::new(scoped_root.to_path_buf(), key_source_config(cfg)); - if device.path().exists() { "device" } else { "keyring" } + if device.path().exists() { + "device" + } else { + "keyring" + } } } } @@ -472,8 +476,8 @@ fn key_source_config(cfg: &protocol::SecretsConfig) -> KeySourceConfig { #[cfg(test)] mod tests { use super::*; - use crate::secret_keysource::{KeySourceConfig, KeySourceKind}; use crate::secret_keysource::tests::env_lock; + use crate::secret_keysource::{KeySourceConfig, KeySourceKind}; // A user-key store backed by a per-test key FILE, so tests need no shared // process env and never race each other. @@ -561,11 +565,8 @@ mod tests { key_source: Some("device-id".to_string()), key_file: Some("/global/key".to_string()), }; - let project = Some(protocol::SecretsConfig { - backend: None, - key_source: Some("user-key".to_string()), - key_file: None, - }); + let project = + Some(protocol::SecretsConfig { backend: None, key_source: Some("user-key".to_string()), key_file: None }); let merged = merge_secrets_config(global, project); // project key_source wins; global backend/key_file kept where project has None assert_eq!(merged.key_source.as_deref(), Some("user-key")); @@ -608,23 +609,28 @@ mod tests { let cfg2 = protocol::SecretsConfig { key_source: Some("passphrase".to_string()), ..Default::default() }; assert!(has_server_key_configured(&cfg2)); let cfg3 = protocol::SecretsConfig { key_source: Some("device-id".to_string()), ..Default::default() }; - use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; use crate::secret_keysource::tests::env_lock; + use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; let _guard = env_lock().lock().unwrap(); let prev_key = std::env::var(ENV_USER_KEY).ok(); let prev_pass = std::env::var(ENV_PASSPHRASE).ok(); std::env::remove_var(ENV_USER_KEY); std::env::remove_var(ENV_PASSPHRASE); let result = has_server_key_configured(&cfg3); - if let Some(v) = prev_key { std::env::set_var(ENV_USER_KEY, v) } - if let Some(v) = prev_pass { std::env::set_var(ENV_PASSPHRASE, v) } + if let Some(v) = prev_key { + std::env::set_var(ENV_USER_KEY, v) + } + if let Some(v) = prev_pass { + std::env::set_var(ENV_PASSPHRASE, v) + } assert!(!result, "device-id key source must not count as a server key"); } #[test] fn has_server_key_configured_with_key_file() { use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; - let cfg = protocol::SecretsConfig { key_file: Some("/srv/animus/secret.key".to_string()), ..Default::default() }; + let cfg = + protocol::SecretsConfig { key_file: Some("/srv/animus/secret.key".to_string()), ..Default::default() }; // Remove env vars so only key_file drives the result. let _guard = env_lock().lock().unwrap(); let prev_key = std::env::var(ENV_USER_KEY).ok(); @@ -632,8 +638,12 @@ mod tests { std::env::remove_var(ENV_USER_KEY); std::env::remove_var(ENV_PASSPHRASE); let result = has_server_key_configured(&cfg); - if let Some(v) = prev_key { std::env::set_var(ENV_USER_KEY, v) } - if let Some(v) = prev_pass { std::env::set_var(ENV_PASSPHRASE, v) } + if let Some(v) = prev_key { + std::env::set_var(ENV_USER_KEY, v) + } + if let Some(v) = prev_pass { + std::env::set_var(ENV_PASSPHRASE, v) + } assert!(result, "key_file in secrets config must count as a server key source"); } @@ -660,18 +670,16 @@ mod tests { std::env::remove_var(ENV_USER_KEY); let store = build_secret_store_for_project(scope, scoped_root, &project_dir); let set_result = store.set("FOO", "bar"); - if let Some(v) = prev { std::env::set_var(ENV_USER_KEY, v) } + if let Some(v) = prev { + std::env::set_var(ENV_USER_KEY, v) + } set_result.expect("project-config-sourced store must accept writes"); assert_eq!(store.get("FOO").unwrap().as_deref(), Some("bar")); } #[test] fn resolve_auto_backend_uses_device_when_server_key_in_cfg() { - let cfg = protocol::SecretsConfig { - backend: None, - key_source: Some("user-key".to_string()), - key_file: None, - }; + let cfg = protocol::SecretsConfig { backend: None, key_source: Some("user-key".to_string()), key_file: None }; let dir = tempfile::tempdir().unwrap(); // No pre-existing device store — but server key is configured. assert_eq!(resolve_auto_backend(&cfg, dir.path()), "device"); @@ -679,8 +687,8 @@ mod tests { #[test] fn resolve_auto_backend_falls_back_to_keyring_without_server_key() { - use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; use crate::secret_keysource::tests::env_lock; + use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; let _guard = env_lock().lock().unwrap(); let prev_key = std::env::var(ENV_USER_KEY).ok(); let prev_pass = std::env::var(ENV_PASSPHRASE).ok(); @@ -689,8 +697,12 @@ mod tests { let cfg = protocol::SecretsConfig::default(); let dir = tempfile::tempdir().unwrap(); let result = resolve_auto_backend(&cfg, dir.path()); - if let Some(v) = prev_key { std::env::set_var(ENV_USER_KEY, v) } - if let Some(v) = prev_pass { std::env::set_var(ENV_PASSPHRASE, v) } + if let Some(v) = prev_key { + std::env::set_var(ENV_USER_KEY, v) + } + if let Some(v) = prev_pass { + std::env::set_var(ENV_PASSPHRASE, v) + } assert_eq!(result, "keyring", "auto without a server key and no existing store must fall back to keyring"); } @@ -715,7 +727,9 @@ mod tests { std::env::remove_var(ENV_USER_KEY); let store = build_backend_for_project(scope, scoped_root, "device", &project_dir); let set_result = store.set("MIGRATE_KEY", "value"); - if let Some(v) = prev { std::env::set_var(ENV_USER_KEY, v) } + if let Some(v) = prev { + std::env::set_var(ENV_USER_KEY, v) + } set_result.expect("build_backend_for_project must honor project key_file for the device backend"); assert_eq!(store.get("MIGRATE_KEY").unwrap().as_deref(), Some("value")); } diff --git a/crates/orchestrator-core/src/secret_keysource.rs b/crates/orchestrator-core/src/secret_keysource.rs index 8eb39bff..e3d9bcd4 100644 --- a/crates/orchestrator-core/src/secret_keysource.rs +++ b/crates/orchestrator-core/src/secret_keysource.rs @@ -440,8 +440,14 @@ pub(crate) mod tests { std::env::set_var(ENV_PASSPHRASE, "headless-passphrase"); let salt = [0xAAu8; 16]; let result = resolve_auto(&KeySourceConfig::default(), &salt); - match &prev_key { Some(v) => std::env::set_var(ENV_USER_KEY, v), None => std::env::remove_var(ENV_USER_KEY) } - match &prev_pass { Some(v) => std::env::set_var(ENV_PASSPHRASE, v), None => std::env::remove_var(ENV_PASSPHRASE) } + match &prev_key { + Some(v) => std::env::set_var(ENV_USER_KEY, v), + None => std::env::remove_var(ENV_USER_KEY), + } + match &prev_pass { + Some(v) => std::env::set_var(ENV_PASSPHRASE, v), + None => std::env::remove_var(ENV_PASSPHRASE), + } let src = result.expect("resolve_auto with ANIMUS_SECRET_PASSPHRASE set should succeed"); assert_eq!(src.id(), "passphrase", "auto must resolve to passphrase when ANIMUS_SECRET_PASSPHRASE is set"); } @@ -458,8 +464,14 @@ pub(crate) mod tests { std::env::set_var(ENV_PASSPHRASE, "also-set"); let salt = [0u8; 16]; let result = resolve_auto(&KeySourceConfig::default(), &salt); - match &prev_key { Some(v) => std::env::set_var(ENV_USER_KEY, v), None => std::env::remove_var(ENV_USER_KEY) } - match &prev_pass { Some(v) => std::env::set_var(ENV_PASSPHRASE, v), None => std::env::remove_var(ENV_PASSPHRASE) } + match &prev_key { + Some(v) => std::env::set_var(ENV_USER_KEY, v), + None => std::env::remove_var(ENV_USER_KEY), + } + match &prev_pass { + Some(v) => std::env::set_var(ENV_PASSPHRASE, v), + None => std::env::remove_var(ENV_PASSPHRASE), + } let src = result.expect("resolve_auto with both env vars set should succeed"); assert_eq!(src.id(), "user-key", "user-key env must take priority over passphrase env"); } From 6b3aac806d8f58ae80c3d3f6e75e4408146f94c7 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:28:03 +0000 Subject: [PATCH 06/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/orchestrator-core/src/secret_device_store.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index 1e4962cf..f8450530 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -424,8 +424,11 @@ fn resolve_auto_backend(cfg: &protocol::SecretsConfig, scoped_root: &Path) -> &' /// via `key_source`, a `key_file` path (honored by `auto` and `user-key`), or /// injected via the corresponding env var. fn has_server_key_configured(cfg: &protocol::SecretsConfig) -> bool { - use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; - matches!(cfg.key_source.as_deref(), Some("user-key") | Some("user_key") | Some("passphrase")) + use crate::secret_keysource::{KeySourceKind, ENV_PASSPHRASE, ENV_USER_KEY}; + cfg.key_source + .as_deref() + .and_then(|source| KeySourceKind::parse(source).ok()) + .is_some_and(|source| matches!(source, KeySourceKind::UserKey | KeySourceKind::Passphrase)) || cfg.key_file.is_some() || std::env::var(ENV_USER_KEY).is_ok() || std::env::var(ENV_PASSPHRASE).is_ok() @@ -606,6 +609,8 @@ mod tests { fn has_server_key_configured_via_config_key_source() { let cfg = protocol::SecretsConfig { key_source: Some("user-key".to_string()), ..Default::default() }; assert!(has_server_key_configured(&cfg)); + let cfg_alias = protocol::SecretsConfig { key_source: Some("userkey".to_string()), ..Default::default() }; + assert!(has_server_key_configured(&cfg_alias)); let cfg2 = protocol::SecretsConfig { key_source: Some("passphrase".to_string()), ..Default::default() }; assert!(has_server_key_configured(&cfg2)); let cfg3 = protocol::SecretsConfig { key_source: Some("device-id".to_string()), ..Default::default() }; From 29bf13debdc269c3ce30a1f727e992669a0ecffd Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:36:10 +0000 Subject: [PATCH 07/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- .../orchestrator-core/src/secret_keysource.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/orchestrator-core/src/secret_keysource.rs b/crates/orchestrator-core/src/secret_keysource.rs index e3d9bcd4..1c990fe8 100644 --- a/crates/orchestrator-core/src/secret_keysource.rs +++ b/crates/orchestrator-core/src/secret_keysource.rs @@ -431,6 +431,28 @@ pub(crate) mod tests { assert_eq!(*src.key().unwrap(), raw); } + #[test] + fn resolve_auto_prefers_env_user_key_over_configured_key_file() { + let env_key = [0x66u8; KEY_LEN]; + let file_key = [0x77u8; KEY_LEN]; + let tmp = tempfile::tempdir().unwrap(); + let key_file = tmp.path().join("server.key"); + std::fs::write(&key_file, hex::encode(file_key)).unwrap(); + let _guard = env_lock().lock().unwrap(); + let prev = std::env::var(ENV_USER_KEY).ok(); + std::env::set_var(ENV_USER_KEY, hex::encode(env_key)); + let config = KeySourceConfig { kind_override: None, key_file: Some(key_file), passphrase: None }; + let salt = [0u8; 16]; + let result = resolve_auto(&config, &salt); + match &prev { + Some(v) => std::env::set_var(ENV_USER_KEY, v), + None => std::env::remove_var(ENV_USER_KEY), + } + let src = result.expect("resolve_auto with both server key sources should succeed"); + assert_eq!(src.id(), "user-key"); + assert_eq!(*src.key().unwrap(), env_key, "ANIMUS_SECRET_KEY must override the configured key file"); + } + #[test] fn resolve_auto_uses_passphrase_when_passphrase_env_is_set() { let _guard = env_lock().lock().unwrap(); From 4dc762686bb9c3104d24d833103efa648d5694a9 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:27:27 +0000 Subject: [PATCH 08/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- .../orchestrator-core/src/secret_keysource.rs | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/orchestrator-core/src/secret_keysource.rs b/crates/orchestrator-core/src/secret_keysource.rs index 1c990fe8..13fe9840 100644 --- a/crates/orchestrator-core/src/secret_keysource.rs +++ b/crates/orchestrator-core/src/secret_keysource.rs @@ -316,7 +316,8 @@ pub fn resolve_key_source(config: &KeySourceConfig, salt: &[u8]) -> Result Result std::env::set_var(ENV_USER_KEY, v), + None => std::env::remove_var(ENV_USER_KEY), + } + match &prev_pass { + Some(v) => std::env::set_var(ENV_PASSPHRASE, v), + None => std::env::remove_var(ENV_PASSPHRASE), + } + let src = result.expect("resolve_auto with a configured passphrase should succeed"); + assert_eq!(src.id(), "passphrase"); + } + #[test] fn resolve_auto_user_key_wins_over_passphrase() { use base64::Engine; From c6a18f7d51c66a437df7907ad11bb99d34e65b55 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:34:48 +0000 Subject: [PATCH 09/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index 373b1926..3f59b619 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -202,6 +202,12 @@ fn finalize( #[cfg(test)] mod tests { use super::*; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static ENV_LOCK: OnceLock> = OnceLock::new(); + ENV_LOCK.get_or_init(|| Mutex::new(())) + } struct EnvVarGuard { name: &'static str, @@ -226,6 +232,10 @@ mod tests { } fn assert_oauth_store_uses_project_key_file(key_source: &str) { + // Secret-key environment variables are process-global. Keep removal, + // store construction, and restoration in one serialized window so + // these tests cannot borrow or overwrite another test's key. + let _env_guard = env_lock().lock().unwrap(); let tmp = tempfile::tempdir().unwrap(); let project_root = tmp.path().join("project"); let animus_dir = project_root.join(".animus"); From c70e3c69c28fb482f9734fce0579359f4cb7c69d Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:42:14 +0000 Subject: [PATCH 10/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- docs/reference/secrets.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/reference/secrets.md b/docs/reference/secrets.md index 9fcb6b83..9685dfdd 100644 --- a/docs/reference/secrets.md +++ b/docs/reference/secrets.md @@ -59,7 +59,10 @@ The index stores KEY names only — every actual value lives in the OS keychain. Two backends sit behind the same `animus secret` surface. The default is unchanged (OS keyring); the device-encrypted store is opt-in and exists for hosts where the keyring is awkward (a macOS binary whose signature changes re-prompts on every keychain access) or absent (a headless Linux server with no session keyring). -Select per machine in the **global** config (`~/.animus/config.json`, or `$ANIMUS_CONFIG_DIR`): +Configure this globally in `~/.animus/config.json` (or `$ANIMUS_CONFIG_DIR`), +or per project in `/.animus/config.json`. Project settings override +global settings field by field, and are honored by both `animus secret` and MCP +OAuth operations, including `animus mcp auth --complete`: ```jsonc { @@ -73,7 +76,10 @@ Select per machine in the **global** config (`~/.animus/config.json`, or `$ANIMU - **`keyring`** — the OS keychain (macOS Keychain / libsecret / Windows Credential Manager). Default. - **`device`** — secrets live AEAD-sealed (ChaCha20-Poly1305) in `~/.animus//secrets/secrets.enc.v1` (`0600`). A random master key seals the data and is itself wrapped under a **key source**. No keychain, no prompts, and the file is useless if copied off the device. -- **`auto`** — keeps existing keyring installs on the keyring (never strands secrets); uses the device store once one exists for the scope. +- **`auto`** — keeps existing keyring installs on the keyring (never strands + secrets); selects the device store when a server key is supplied through + `ANIMUS_SECRET_KEY`, `ANIMUS_SECRET_PASSPHRASE`, or `key_file`, and continues + using the device store once one exists for the scope. ### Key sources (what wraps the device store's master key) From 6fb1f29b6ee3eb2b9e62026b6ad6213a18c09fe7 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:55:42 +0000 Subject: [PATCH 11/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index 3f59b619..5bf82380 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -49,8 +49,8 @@ pub struct ServerResolution { pub broker_oauth: Option, } -/// Build a keychain-backed [`SecretStore`] for `project_root`, mirroring the -/// `animus secret` surface so OAuth tokens share the project's keychain +/// Build the configured [`SecretStore`] for `project_root`, mirroring the +/// `animus secret` surface so OAuth tokens share the project's secret-store /// scope. /// /// Consults both the global `~/.animus/config.json` and the project-level @@ -231,7 +231,7 @@ mod tests { } } - fn assert_oauth_store_uses_project_key_file(key_source: &str) { + fn assert_oauth_store_uses_project_key_file(key_source: Option<&str>) { // Secret-key environment variables are process-global. Keep removal, // store construction, and restoration in one serialized window so // these tests cannot borrow or overwrite another test's key. @@ -264,11 +264,16 @@ mod tests { #[test] fn oauth_secret_store_honors_project_user_key_without_env_key() { - assert_oauth_store_uses_project_key_file("user-key"); + assert_oauth_store_uses_project_key_file(Some("user-key")); } #[test] fn oauth_secret_store_auto_uses_project_key_file_without_env_key() { - assert_oauth_store_uses_project_key_file("auto"); + assert_oauth_store_uses_project_key_file(Some("auto")); + } + + #[test] + fn oauth_secret_store_default_auto_uses_project_key_file_without_env_key() { + assert_oauth_store_uses_project_key_file(None); } } From 654311a08d9f691e92e54415413935f834bc5279 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:03:07 +0000 Subject: [PATCH 12/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index 5bf82380..904d7076 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -253,8 +253,16 @@ mod tests { let _key_guard = EnvVarGuard::remove("ANIMUS_SECRET_KEY"); let store = build_secret_store_at(&project_root, tmp.path().join("state")); - let result = store.set("oauth_test", "token"); - let stored = result.and_then(|()| store.get("oauth_test")); + store + .set("oauth_test", "token") + .expect("OAuth secret write must use the project-configured key file"); + drop(store); + + // Rebuild the store as the separate `mcp auth --complete` invocation + // does. This verifies that config is consulted on every path, not just + // that one in-memory store can read back its own write. + let reopened = build_secret_store_at(&project_root, tmp.path().join("state")); + let stored = reopened.get("oauth_test"); assert_eq!( stored.expect("OAuth secret operations must use the project-configured key file").as_deref(), From c0874710d1b8623b15402a038de82ba3a30e5228 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:13:43 +0000 Subject: [PATCH 13/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- .../src/secret_device_store.rs | 24 ++++++++- .../orchestrator-core/src/secret_keysource.rs | 50 +++++++++++++++++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index f8450530..e65d436d 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -430,8 +430,8 @@ fn has_server_key_configured(cfg: &protocol::SecretsConfig) -> bool { .and_then(|source| KeySourceKind::parse(source).ok()) .is_some_and(|source| matches!(source, KeySourceKind::UserKey | KeySourceKind::Passphrase)) || cfg.key_file.is_some() - || std::env::var(ENV_USER_KEY).is_ok() - || std::env::var(ENV_PASSPHRASE).is_ok() + || std::env::var(ENV_USER_KEY).is_ok_and(|raw| !raw.trim().is_empty()) + || std::env::var(ENV_PASSPHRASE).is_ok_and(|raw| !raw.trim().is_empty()) } /// Read the project-level `.animus/config.json` and return its `secrets` block. @@ -652,6 +652,26 @@ mod tests { assert!(result, "key_file in secrets config must count as a server key source"); } + #[test] + fn empty_passphrase_env_is_not_a_server_key() { + use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; + let _guard = env_lock().lock().unwrap(); + let prev_key = std::env::var(ENV_USER_KEY).ok(); + let prev_pass = std::env::var(ENV_PASSPHRASE).ok(); + std::env::remove_var(ENV_USER_KEY); + std::env::set_var(ENV_PASSPHRASE, " "); + let result = has_server_key_configured(&protocol::SecretsConfig::default()); + match prev_key { + Some(v) => std::env::set_var(ENV_USER_KEY, v), + None => std::env::remove_var(ENV_USER_KEY), + } + match prev_pass { + Some(v) => std::env::set_var(ENV_PASSPHRASE, v), + None => std::env::remove_var(ENV_PASSPHRASE), + } + assert!(!result, "empty ANIMUS_SECRET_PASSPHRASE must be treated as unset"); + } + #[test] fn build_secret_store_for_project_reads_project_config() { crate::test_env::stable_test_home(); diff --git a/crates/orchestrator-core/src/secret_keysource.rs b/crates/orchestrator-core/src/secret_keysource.rs index 13fe9840..fc8fed32 100644 --- a/crates/orchestrator-core/src/secret_keysource.rs +++ b/crates/orchestrator-core/src/secret_keysource.rs @@ -99,7 +99,9 @@ impl UserKeySource { /// Resolve from the env var first, then the configured key file. pub fn resolve(key_file: Option<&Path>) -> Result { if let Ok(raw) = std::env::var(ENV_USER_KEY) { - return Ok(Self { key: parse_raw_key(raw.trim())? }); + if !raw.trim().is_empty() { + return Ok(Self { key: parse_raw_key(raw.trim())? }); + } } if let Some(path) = key_file { let raw = std::fs::read_to_string(path) @@ -335,13 +337,13 @@ fn resolve_auto(config: &KeySourceConfig, salt: &[u8]) -> Result std::env::set_var(ENV_USER_KEY, v), + None => std::env::remove_var(ENV_USER_KEY), + } + let src = result.expect("empty ANIMUS_SECRET_KEY must not mask a configured key file"); + assert_eq!(src.id(), "user-key"); + assert_eq!(*src.key().unwrap(), file_key); + } + #[test] fn resolve_auto_uses_passphrase_when_passphrase_env_is_set() { let _guard = env_lock().lock().unwrap(); @@ -477,6 +500,27 @@ pub(crate) mod tests { assert_eq!(src.id(), "passphrase", "auto must resolve to passphrase when ANIMUS_SECRET_PASSPHRASE is set"); } + #[test] + fn resolve_auto_ignores_empty_passphrase_env() { + let _guard = env_lock().lock().unwrap(); + let prev_key = std::env::var(ENV_USER_KEY).ok(); + let prev_pass = std::env::var(ENV_PASSPHRASE).ok(); + std::env::remove_var(ENV_USER_KEY); + std::env::set_var(ENV_PASSPHRASE, " "); + let salt = [0xACu8; 16]; + let result = resolve_auto(&KeySourceConfig::default(), &salt); + match &prev_key { + Some(v) => std::env::set_var(ENV_USER_KEY, v), + None => std::env::remove_var(ENV_USER_KEY), + } + match &prev_pass { + Some(v) => std::env::set_var(ENV_PASSPHRASE, v), + None => std::env::remove_var(ENV_PASSPHRASE), + } + let src = result.expect("empty ANIMUS_SECRET_PASSPHRASE must be treated as unset"); + assert_eq!(src.id(), "device-id"); + } + #[test] fn resolve_auto_uses_configured_passphrase_without_env() { let _guard = env_lock().lock().unwrap(); From 6c339dc76a78abf52794c792d031d606233e0a25 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:22:51 +0000 Subject: [PATCH 14/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/orchestrator-core/src/secret_keysource.rs | 12 ++++++++++-- docs/reference/secrets.md | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/orchestrator-core/src/secret_keysource.rs b/crates/orchestrator-core/src/secret_keysource.rs index fc8fed32..24c8588e 100644 --- a/crates/orchestrator-core/src/secret_keysource.rs +++ b/crates/orchestrator-core/src/secret_keysource.rs @@ -517,8 +517,16 @@ pub(crate) mod tests { Some(v) => std::env::set_var(ENV_PASSPHRASE, v), None => std::env::remove_var(ENV_PASSPHRASE), } - let src = result.expect("empty ANIMUS_SECRET_PASSPHRASE must be treated as unset"); - assert_eq!(src.id(), "device-id"); + match result { + Ok(src) => assert_eq!(src.id(), "device-id"), + Err(err) => { + let message = format!("{err:#}"); + assert!( + message.contains("machine id") || message.contains("machine-id"), + "empty passphrase must fall through to device-id, got: {message}" + ); + } + } } #[test] diff --git a/docs/reference/secrets.md b/docs/reference/secrets.md index 9685dfdd..49b9ef48 100644 --- a/docs/reference/secrets.md +++ b/docs/reference/secrets.md @@ -79,7 +79,9 @@ OAuth operations, including `animus mcp auth --complete`: - **`auto`** — keeps existing keyring installs on the keyring (never strands secrets); selects the device store when a server key is supplied through `ANIMUS_SECRET_KEY`, `ANIMUS_SECRET_PASSPHRASE`, or `key_file`, and continues - using the device store once one exists for the scope. + using the device store once one exists for the scope. Setting `key_file` + is sufficient; `key_source: "user-key"` is optional when `backend` and + `key_source` remain `auto`. ### Key sources (what wraps the device store's master key) From c19c0c30b1d3ffcb4d44c777ef8d061d74613c56 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:30:20 +0000 Subject: [PATCH 15/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 9 +++++---- docs/reference/secrets.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index 904d7076..0474659b 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -231,7 +231,7 @@ mod tests { } } - fn assert_oauth_store_uses_project_key_file(key_source: Option<&str>) { + fn assert_oauth_store_uses_project_key_file(key_source: Option<&str>, backend: Option<&str>) { // Secret-key environment variables are process-global. Keep removal, // store construction, and restoration in one serialized window so // these tests cannot borrow or overwrite another test's key. @@ -245,6 +245,7 @@ mod tests { std::fs::write(&key_file, "a5".repeat(32)).unwrap(); let config = serde_json::json!({ "secrets": { + "backend": backend, "key_source": key_source, "key_file": key_file } @@ -272,16 +273,16 @@ mod tests { #[test] fn oauth_secret_store_honors_project_user_key_without_env_key() { - assert_oauth_store_uses_project_key_file(Some("user-key")); + assert_oauth_store_uses_project_key_file(Some("user-key"), Some("device")); } #[test] fn oauth_secret_store_auto_uses_project_key_file_without_env_key() { - assert_oauth_store_uses_project_key_file(Some("auto")); + assert_oauth_store_uses_project_key_file(Some("auto"), Some("auto")); } #[test] fn oauth_secret_store_default_auto_uses_project_key_file_without_env_key() { - assert_oauth_store_uses_project_key_file(None); + assert_oauth_store_uses_project_key_file(None, None); } } diff --git a/docs/reference/secrets.md b/docs/reference/secrets.md index 49b9ef48..f9fad344 100644 --- a/docs/reference/secrets.md +++ b/docs/reference/secrets.md @@ -69,7 +69,7 @@ OAuth operations, including `animus mcp auth --complete`: "secrets": { "backend": "device", // auto (default) | keyring | device | env "key_source": "device-id", // auto | user-key | passphrase | device-id - "key_file": "/path/to/key" // only for key_source = user-key + "key_file": "/path/to/key" // used by key_source = auto or user-key } } ``` From 226bc9bdde3128b584fd939b0a342c87e59c135a Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:38:06 +0000 Subject: [PATCH 16/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- .../src/secret_device_store.rs | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index e65d436d..e474f477 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -425,11 +425,13 @@ fn resolve_auto_backend(cfg: &protocol::SecretsConfig, scoped_root: &Path) -> &' /// injected via the corresponding env var. fn has_server_key_configured(cfg: &protocol::SecretsConfig) -> bool { use crate::secret_keysource::{KeySourceKind, ENV_PASSPHRASE, ENV_USER_KEY}; - cfg.key_source + let configured_source = cfg + .key_source .as_deref() - .and_then(|source| KeySourceKind::parse(source).ok()) - .is_some_and(|source| matches!(source, KeySourceKind::UserKey | KeySourceKind::Passphrase)) - || cfg.key_file.is_some() + .and_then(|source| KeySourceKind::parse(source).ok()); + configured_source.is_some_and(|source| matches!(source, KeySourceKind::UserKey | KeySourceKind::Passphrase)) + || (cfg.key_file.is_some() + && matches!(configured_source, None | Some(KeySourceKind::Auto | KeySourceKind::UserKey))) || std::env::var(ENV_USER_KEY).is_ok_and(|raw| !raw.trim().is_empty()) || std::env::var(ENV_PASSPHRASE).is_ok_and(|raw| !raw.trim().is_empty()) } @@ -652,6 +654,31 @@ mod tests { assert!(result, "key_file in secrets config must count as a server key source"); } + #[test] + fn key_file_does_not_override_explicit_device_id_source() { + use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; + let cfg = protocol::SecretsConfig { + key_source: Some("device-id".to_string()), + key_file: Some("/srv/animus/secret.key".to_string()), + ..Default::default() + }; + let _guard = env_lock().lock().unwrap(); + let prev_key = std::env::var(ENV_USER_KEY).ok(); + let prev_pass = std::env::var(ENV_PASSPHRASE).ok(); + std::env::remove_var(ENV_USER_KEY); + std::env::remove_var(ENV_PASSPHRASE); + let result = has_server_key_configured(&cfg); + match prev_key { + Some(value) => std::env::set_var(ENV_USER_KEY, value), + None => std::env::remove_var(ENV_USER_KEY), + } + match prev_pass { + Some(value) => std::env::set_var(ENV_PASSPHRASE, value), + None => std::env::remove_var(ENV_PASSPHRASE), + } + assert!(!result, "key_file is ignored when device-id is explicitly selected"); + } + #[test] fn empty_passphrase_env_is_not_a_server_key() { use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; From f617e4cfd1e554d8ffdf5896a632dbc05f4ae737 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:45:18 +0000 Subject: [PATCH 17/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- docs/reference/secrets.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/reference/secrets.md b/docs/reference/secrets.md index f9fad344..7455c6ba 100644 --- a/docs/reference/secrets.md +++ b/docs/reference/secrets.md @@ -157,7 +157,9 @@ What the design does NOT defend against: ## Headless and CI -CI containers typically have no D-Bus session and no macOS Keychain. The recommended pattern is to keep using process env on the CI box: +CI containers and servers typically have no D-Bus session or macOS Keychain. +For ephemeral CI jobs, keep passing application credentials through the process +environment: ```sh LINEAR_API_TOKEN=$LINEAR_API_TOKEN \ @@ -165,7 +167,12 @@ OPENAI_API_KEY=$OPENAI_API_KEY \ animus daemon start ``` -The keychain layer is a no-op in this configuration — the index file is empty, the spawn-path merge is empty, and behavior is byte-identical to pre-v0.5.8. +For a long-running server that must persist Animus secrets or MCP OAuth tokens, +configure a durable `key_file` in the `secrets` block or inject the same +32-byte `ANIMUS_SECRET_KEY` on every start. With the default `auto` settings, +either source selects the device-encrypted backend without requiring a desktop +keyring. Back up the key separately from the encrypted store: losing or changing +it makes the stored secrets unrecoverable. ## Audit log @@ -210,7 +217,6 @@ The following are non-goals and will land (or not) in v0.6+: - Encrypted file backends (age, sops, similar) — only OS keychain for v0.5.8. - External secret brokers (1Password / HashiCorp Vault / Doppler) — defer until the plugin role for "secret broker" is designed. - Cross-machine sync — the keychain is local-only by design. -- Headless / Linux-no-D-Bus mode — for now CI uses process env directly. - TUI for managing secrets. [RBAC]: ./security.md From b084c7269a44b9159d23635aee43167fa1cbfa1b Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:53:51 +0000 Subject: [PATCH 18/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- docs/reference/secrets.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/reference/secrets.md b/docs/reference/secrets.md index 7455c6ba..eca00bce 100644 --- a/docs/reference/secrets.md +++ b/docs/reference/secrets.md @@ -174,6 +174,23 @@ either source selects the device-encrypted backend without requiring a desktop keyring. Back up the key separately from the encrypted store: losing or changing it makes the stored secrets unrecoverable. +For example, a project-level `.animus/config.json` can select a key file without +requiring `ANIMUS_SECRET_KEY` in the environment: + +```json +{ + "secrets": { + "key_source": "user-key", + "key_file": "/run/secrets/animus-secret-key" + } +} +``` + +The file must contain exactly 32 bytes encoded as 64 hexadecimal characters or +base64. `ANIMUS_SECRET_KEY` accepts the same formats and takes precedence over +`key_file` when both are present. Both the CLI secret commands and MCP OAuth +operations honor the project-level `secrets` block. + ## Audit log Every mutation (`set`, `rm`, `import-env`, `export-env`) writes a line to the per-scope audit log under `~/.animus//audit.jsonl`. Only KEY names land in the log — never values. The actor is the resolved Principal (see [RBAC]). Example record: From 9591ac81254f29eb8416120a68064d9c6efae510 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:04:43 +0000 Subject: [PATCH 19/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/orchestrator-core/src/secret_device_store.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index e474f477..d729c7c3 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -722,11 +722,12 @@ mod tests { std::env::remove_var(ENV_USER_KEY); let store = build_secret_store_for_project(scope, scoped_root, &project_dir); let set_result = store.set("FOO", "bar"); + let get_result = store.get("FOO"); if let Some(v) = prev { std::env::set_var(ENV_USER_KEY, v) } set_result.expect("project-config-sourced store must accept writes"); - assert_eq!(store.get("FOO").unwrap().as_deref(), Some("bar")); + assert_eq!(get_result.unwrap().as_deref(), Some("bar")); } #[test] @@ -779,10 +780,11 @@ mod tests { std::env::remove_var(ENV_USER_KEY); let store = build_backend_for_project(scope, scoped_root, "device", &project_dir); let set_result = store.set("MIGRATE_KEY", "value"); + let get_result = store.get("MIGRATE_KEY"); if let Some(v) = prev { std::env::set_var(ENV_USER_KEY, v) } set_result.expect("build_backend_for_project must honor project key_file for the device backend"); - assert_eq!(store.get("MIGRATE_KEY").unwrap().as_deref(), Some("value")); + assert_eq!(get_result.unwrap().as_deref(), Some("value")); } } From 85b7d98726284231e8abae5bbbd2147a935e9fd5 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:18:57 +0000 Subject: [PATCH 20/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- .../src/secret_device_store.rs | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index d729c7c3..e4ca59f3 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -430,7 +430,7 @@ fn has_server_key_configured(cfg: &protocol::SecretsConfig) -> bool { .as_deref() .and_then(|source| KeySourceKind::parse(source).ok()); configured_source.is_some_and(|source| matches!(source, KeySourceKind::UserKey | KeySourceKind::Passphrase)) - || (cfg.key_file.is_some() + || (cfg.key_file.as_deref().is_some_and(|path| !path.trim().is_empty()) && matches!(configured_source, None | Some(KeySourceKind::Auto | KeySourceKind::UserKey))) || std::env::var(ENV_USER_KEY).is_ok_and(|raw| !raw.trim().is_empty()) || std::env::var(ENV_PASSPHRASE).is_ok_and(|raw| !raw.trim().is_empty()) @@ -468,7 +468,10 @@ fn key_source_config(cfg: &protocol::SecretsConfig) -> KeySourceConfig { .unwrap_or(crate::secret_keysource::KeySourceKind::Auto); KeySourceConfig { kind_override: Some(kind), - key_file: cfg.key_file.as_ref().map(PathBuf::from), + // Treat an empty JSON string as absent. Otherwise `auto` selects the + // device backend and later attempts to read an empty path as a key + // file, obscuring the actionable "no key provided" error. + key_file: cfg.key_file.as_deref().filter(|path| !path.trim().is_empty()).map(PathBuf::from), // `passphrase` is env-driven for both the CLI and the daemon: the key // source reads ANIMUS_SECRET_PASSPHRASE at resolve time (and errors with // that instruction when unset), so there is no in-process passphrase to @@ -654,6 +657,31 @@ mod tests { assert!(result, "key_file in secrets config must count as a server key source"); } + #[test] + fn empty_key_file_is_not_a_server_key() { + use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; + let cfg = protocol::SecretsConfig { key_file: Some(" ".to_string()), ..Default::default() }; + let _guard = env_lock().lock().unwrap(); + let prev_key = std::env::var(ENV_USER_KEY).ok(); + let prev_pass = std::env::var(ENV_PASSPHRASE).ok(); + std::env::remove_var(ENV_USER_KEY); + std::env::remove_var(ENV_PASSPHRASE); + + let configured = has_server_key_configured(&cfg); + let resolved = key_source_config(&cfg); + + match prev_key { + Some(value) => std::env::set_var(ENV_USER_KEY, value), + None => std::env::remove_var(ENV_USER_KEY), + } + match prev_pass { + Some(value) => std::env::set_var(ENV_PASSPHRASE, value), + None => std::env::remove_var(ENV_PASSPHRASE), + } + assert!(!configured, "an empty key_file must not select the device backend"); + assert!(resolved.key_file.is_none(), "an empty key_file must be normalized to absent"); + } + #[test] fn key_file_does_not_override_explicit_device_id_source() { use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; From 7fbc9e9eabff0a9bab5412475b0fd418923c6f5a Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:24:05 +0000 Subject: [PATCH 21/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index 0474659b..39c84112 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -254,6 +254,11 @@ mod tests { let _key_guard = EnvVarGuard::remove("ANIMUS_SECRET_KEY"); let store = build_secret_store_at(&project_root, tmp.path().join("state")); + assert_eq!( + store.backend_label(), + "device-encrypted store", + "a project key_file must select the headless-safe device backend" + ); store .set("oauth_test", "token") .expect("OAuth secret write must use the project-configured key file"); @@ -263,6 +268,11 @@ mod tests { // does. This verifies that config is consulted on every path, not just // that one in-memory store can read back its own write. let reopened = build_secret_store_at(&project_root, tmp.path().join("state")); + assert_eq!( + reopened.backend_label(), + "device-encrypted store", + "OAuth completion must reselect the project-configured device backend" + ); let stored = reopened.get("oauth_test"); assert_eq!( From 7e95f4b1c8aea22f7484e3ac58f51d24a50308cf Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:31:24 +0000 Subject: [PATCH 22/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index 39c84112..03c0e21a 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -286,6 +286,11 @@ mod tests { assert_oauth_store_uses_project_key_file(Some("user-key"), Some("device")); } + #[test] + fn oauth_secret_store_auto_backend_honors_project_user_key_without_env_key() { + assert_oauth_store_uses_project_key_file(Some("user-key"), Some("auto")); + } + #[test] fn oauth_secret_store_auto_uses_project_key_file_without_env_key() { assert_oauth_store_uses_project_key_file(Some("auto"), Some("auto")); From 92c79926135284bd2ac7239e0d26454d074c354d Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:37:45 +0000 Subject: [PATCH 23/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- .../orchestrator-core/src/secret_keysource.rs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/orchestrator-core/src/secret_keysource.rs b/crates/orchestrator-core/src/secret_keysource.rs index 24c8588e..dd56d934 100644 --- a/crates/orchestrator-core/src/secret_keysource.rs +++ b/crates/orchestrator-core/src/secret_keysource.rs @@ -105,11 +105,11 @@ impl UserKeySource { } if let Some(path) = key_file { let raw = std::fs::read_to_string(path) - .with_context(|| format!("reading secret_key_file at {}", path.display()))?; + .with_context(|| format!("reading secrets.key_file at {}", path.display()))?; return Ok(Self { key: parse_raw_key(raw.trim())? }); } bail!( - "secret_key_source = user-key but no key provided: set {ENV_USER_KEY} (hex or base64, 32 bytes) or secret_key_file" + "secret_key_source = user-key but no key provided: set {ENV_USER_KEY} (hex or base64, 32 bytes) or secrets.key_file" ) } } @@ -385,6 +385,22 @@ pub(crate) mod tests { assert!(parse_raw_key("").is_err()); } + #[test] + fn user_key_error_names_the_public_config_field() { + let _guard = env_lock().lock().unwrap(); + let previous = std::env::var(ENV_USER_KEY).ok(); + std::env::remove_var(ENV_USER_KEY); + let error = match UserKeySource::resolve(None) { + Ok(_) => panic!("user-key resolution unexpectedly succeeded without key material"), + Err(error) => error.to_string(), + }; + match previous { + Some(value) => std::env::set_var(ENV_USER_KEY, value), + None => std::env::remove_var(ENV_USER_KEY), + } + assert!(error.contains("secrets.key_file"), "unexpected error: {error}"); + } + #[test] fn passphrase_is_deterministic_per_salt_and_varies_by_salt() { let salt_a = [1u8; 16]; From 641034d5a25fc5b3bfc81a556c180d1109add1ce Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:45:40 +0000 Subject: [PATCH 24/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- docs/architecture/secret-backends.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/architecture/secret-backends.md b/docs/architecture/secret-backends.md index d0c837e5..335cf9dc 100644 --- a/docs/architecture/secret-backends.md +++ b/docs/architecture/secret-backends.md @@ -62,7 +62,7 @@ AEAD-sealed JSON map `{key: value}`: trait KeySource { fn key(&self) -> Result>; fn id(&self) -> &str; } ``` -Selected by config `secret_key_source`: +Selected by config `secrets.key_source`: - `auto` (default): resolves in priority order — (1) `ANIMUS_SECRET_KEY` env var → `user-key`; (2) `key_file` configured in the `secrets` block → `user-key`; @@ -107,11 +107,13 @@ already cover every target: ## Config (protocol::Config) -- `secret_backend: auto | keyring | device | env` — `auto` keeps existing - keyring installs on keyring (don't silently strand secrets), uses `device` for - fresh installs / where keyring is absent. -- `secret_key_source: auto | user-key | passphrase | device-id`. -- `secret_key_file: `. +The settings live in the `secrets` object in global or project `config.json`: + +- `secrets.backend: auto | keyring | device | env` — `auto` keeps existing + keyring installs on keyring (don't silently strand secrets) and selects + `device` when server key material is configured. +- `secrets.key_source: auto | user-key | passphrase | device-id`. +- `secrets.key_file: `. `animus secret migrate` moves secrets between keyring and the device store (idempotent, non-destructive by default). `env` always wins (unchanged). From 37b81ce4f9bdeb397c3f4720ab1ac1eba817f38a Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:53:39 +0000 Subject: [PATCH 25/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- docs/architecture/secret-backends.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/architecture/secret-backends.md b/docs/architecture/secret-backends.md index 335cf9dc..0fa66f2f 100644 --- a/docs/architecture/secret-backends.md +++ b/docs/architecture/secret-backends.md @@ -32,8 +32,11 @@ everywhere, and binds the ciphertext to the device. `SecretStore` (set/get/delete/list_keys/snapshot_for_spawn) stays the seam. - New backend `DeviceEncryptedSecretStore: SecretStore`. -- A factory `build_secret_store(repo_scope, scoped_root) -> Box` - reads config and returns the configured backend. It replaces the ~5 direct +- The factories `build_secret_store(repo_scope, scoped_root)` and + `build_secret_store_for_project(repo_scope, scoped_root, project_root)` read + config and return the configured backend. Project-aware call sites use the + latter so the global and project `secrets` blocks are merged before backend + and key-source selection. They replace the ~5 direct `KeyringSecretStore::new(&scope, scoped_root)` construction sites (orchestrator-daemon-runtime/quotas.rs ×2, animus-mcp-oauth/config.rs, orchestrator-cli ops_secret.rs, ops_doctor/checks_api_keys.rs). From 27734aa66cf79a821c84c99a478dbb88b541b917 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:00:01 +0000 Subject: [PATCH 26/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 5 +++++ crates/orchestrator-core/src/secret_keysource.rs | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index 03c0e21a..0ef09253 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -296,6 +296,11 @@ mod tests { assert_oauth_store_uses_project_key_file(Some("auto"), Some("auto")); } + #[test] + fn oauth_secret_store_device_backend_auto_source_uses_project_key_file_without_env_key() { + assert_oauth_store_uses_project_key_file(Some("auto"), Some("device")); + } + #[test] fn oauth_secret_store_default_auto_uses_project_key_file_without_env_key() { assert_oauth_store_uses_project_key_file(None, None); diff --git a/crates/orchestrator-core/src/secret_keysource.rs b/crates/orchestrator-core/src/secret_keysource.rs index dd56d934..2dbf7bd0 100644 --- a/crates/orchestrator-core/src/secret_keysource.rs +++ b/crates/orchestrator-core/src/secret_keysource.rs @@ -312,9 +312,10 @@ pub fn resolve_key_source(config: &KeySourceConfig, salt: &[u8]) -> Result Date: Fri, 31 Jul 2026 22:10:25 +0000 Subject: [PATCH 27/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 8 ++++++-- crates/orchestrator-core/src/secret_device_store.rs | 3 +-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index 0ef09253..cb09d1c3 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -259,8 +259,7 @@ mod tests { "device-encrypted store", "a project key_file must select the headless-safe device backend" ); - store - .set("oauth_test", "token") + store.set("oauth_test", "token") .expect("OAuth secret write must use the project-configured key file"); drop(store); @@ -301,6 +300,11 @@ mod tests { assert_oauth_store_uses_project_key_file(Some("auto"), Some("device")); } + #[test] + fn oauth_secret_store_device_backend_default_source_uses_project_key_file_without_env_key() { + assert_oauth_store_uses_project_key_file(None, Some("device")); + } + #[test] fn oauth_secret_store_default_auto_uses_project_key_file_without_env_key() { assert_oauth_store_uses_project_key_file(None, None); diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index e4ca59f3..02288770 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -425,8 +425,7 @@ fn resolve_auto_backend(cfg: &protocol::SecretsConfig, scoped_root: &Path) -> &' /// injected via the corresponding env var. fn has_server_key_configured(cfg: &protocol::SecretsConfig) -> bool { use crate::secret_keysource::{KeySourceKind, ENV_PASSPHRASE, ENV_USER_KEY}; - let configured_source = cfg - .key_source + let configured_source = cfg.key_source .as_deref() .and_then(|source| KeySourceKind::parse(source).ok()); configured_source.is_some_and(|source| matches!(source, KeySourceKind::UserKey | KeySourceKind::Passphrase)) From c10c640056ea1ee1ad81a71f1980626b276bc602 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:25:44 +0000 Subject: [PATCH 28/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 35 +++++++++++++++++++++------ 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index cb09d1c3..710066af 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -220,6 +220,12 @@ mod tests { std::env::remove_var(name); Self { name, previous } } + + fn set(name: &'static str, value: &str) -> Self { + let previous = std::env::var(name).ok(); + std::env::set_var(name, value); + Self { name, previous } + } } impl Drop for EnvVarGuard { @@ -231,7 +237,11 @@ mod tests { } } - fn assert_oauth_store_uses_project_key_file(key_source: Option<&str>, backend: Option<&str>) { + fn assert_oauth_store_uses_project_key_file( + key_source: Option<&str>, + backend: Option<&str>, + empty_env_key: bool, + ) { // Secret-key environment variables are process-global. Keep removal, // store construction, and restoration in one serialized window so // these tests cannot borrow or overwrite another test's key. @@ -252,7 +262,11 @@ mod tests { }); std::fs::write(animus_dir.join("config.json"), serde_json::to_vec(&config).unwrap()).unwrap(); - let _key_guard = EnvVarGuard::remove("ANIMUS_SECRET_KEY"); + let _key_guard = if empty_env_key { + EnvVarGuard::set("ANIMUS_SECRET_KEY", " ") + } else { + EnvVarGuard::remove("ANIMUS_SECRET_KEY") + }; let store = build_secret_store_at(&project_root, tmp.path().join("state")); assert_eq!( store.backend_label(), @@ -282,31 +296,36 @@ mod tests { #[test] fn oauth_secret_store_honors_project_user_key_without_env_key() { - assert_oauth_store_uses_project_key_file(Some("user-key"), Some("device")); + assert_oauth_store_uses_project_key_file(Some("user-key"), Some("device"), false); } #[test] fn oauth_secret_store_auto_backend_honors_project_user_key_without_env_key() { - assert_oauth_store_uses_project_key_file(Some("user-key"), Some("auto")); + assert_oauth_store_uses_project_key_file(Some("user-key"), Some("auto"), false); } #[test] fn oauth_secret_store_auto_uses_project_key_file_without_env_key() { - assert_oauth_store_uses_project_key_file(Some("auto"), Some("auto")); + assert_oauth_store_uses_project_key_file(Some("auto"), Some("auto"), false); + } + + #[test] + fn oauth_secret_store_auto_ignores_empty_env_key_when_project_key_file_is_configured() { + assert_oauth_store_uses_project_key_file(Some("auto"), Some("auto"), true); } #[test] fn oauth_secret_store_device_backend_auto_source_uses_project_key_file_without_env_key() { - assert_oauth_store_uses_project_key_file(Some("auto"), Some("device")); + assert_oauth_store_uses_project_key_file(Some("auto"), Some("device"), false); } #[test] fn oauth_secret_store_device_backend_default_source_uses_project_key_file_without_env_key() { - assert_oauth_store_uses_project_key_file(None, Some("device")); + assert_oauth_store_uses_project_key_file(None, Some("device"), false); } #[test] fn oauth_secret_store_default_auto_uses_project_key_file_without_env_key() { - assert_oauth_store_uses_project_key_file(None, None); + assert_oauth_store_uses_project_key_file(None, None, false); } } From 4c9d7467c76f78cb04f6b2526db4ce2e43dbce58 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:32:56 +0000 Subject: [PATCH 29/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- docs/architecture/secret-backends.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/architecture/secret-backends.md b/docs/architecture/secret-backends.md index 0fa66f2f..505264de 100644 --- a/docs/architecture/secret-backends.md +++ b/docs/architecture/secret-backends.md @@ -118,6 +118,25 @@ The settings live in the `secrets` object in global or project `config.json`: - `secrets.key_source: auto | user-key | passphrase | device-id`. - `secrets.key_file: `. +For a headless deployment, mount a durable 32-byte key (hex or base64) and +point either the global or project config at it. `backend` and `key_source` may +remain `auto`; all CLI secret operations, including `mcp auth --complete`, load +the project `secrets` block: + +```json +{ + "secrets": { + "backend": "auto", + "key_source": "auto", + "key_file": "/run/secrets/animus-secret-key" + } +} +``` + +The key file must persist across redeployments. Losing or replacing it makes +the existing encrypted store unreadable; Animus does not wipe or rebuild the +store automatically. + `animus secret migrate` moves secrets between keyring and the device store (idempotent, non-destructive by default). `env` always wins (unchanged). From 9e29ff09aa01369cd40558b181cf7409299a142d Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:37:34 +0000 Subject: [PATCH 30/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- docs/architecture/secret-backends.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/architecture/secret-backends.md b/docs/architecture/secret-backends.md index 505264de..0dde793e 100644 --- a/docs/architecture/secret-backends.md +++ b/docs/architecture/secret-backends.md @@ -120,8 +120,9 @@ The settings live in the `secrets` object in global or project `config.json`: For a headless deployment, mount a durable 32-byte key (hex or base64) and point either the global or project config at it. `backend` and `key_source` may -remain `auto`; all CLI secret operations, including `mcp auth --complete`, load -the project `secrets` block: +remain `auto`. Secret commands and MCP OAuth operations (including +`mcp auth --complete`) merge the project `secrets` block over the global block +field by field, so either location supplies the same server-key source: ```json { From d44ba97c69bc5696a21a03a9d199f731eeb31d2f Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:43:57 +0000 Subject: [PATCH 31/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index 710066af..41b36eee 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -299,6 +299,11 @@ mod tests { assert_oauth_store_uses_project_key_file(Some("user-key"), Some("device"), false); } + #[test] + fn oauth_secret_store_user_key_ignores_empty_env_key() { + assert_oauth_store_uses_project_key_file(Some("user-key"), Some("device"), true); + } + #[test] fn oauth_secret_store_auto_backend_honors_project_user_key_without_env_key() { assert_oauth_store_uses_project_key_file(Some("user-key"), Some("auto"), false); From 23004324a7e27ba3edfcbb00c8468593197afcad Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:50:48 +0000 Subject: [PATCH 32/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- docs/architecture/secret-backends.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/architecture/secret-backends.md b/docs/architecture/secret-backends.md index 0dde793e..e7386374 100644 --- a/docs/architecture/secret-backends.md +++ b/docs/architecture/secret-backends.md @@ -110,7 +110,8 @@ already cover every target: ## Config (protocol::Config) -The settings live in the `secrets` object in global or project `config.json`: +The settings live in the `secrets` object in the global +`~/.animus/config.json` or project-local `.animus/config.json`: - `secrets.backend: auto | keyring | device | env` — `auto` keeps existing keyring installs on keyring (don't silently strand secrets) and selects From e7cda2cf122ac6df44daf16b87452ad3279bf560 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:00:43 +0000 Subject: [PATCH 33/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- .../orchestrator-core/src/secret_keysource.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/orchestrator-core/src/secret_keysource.rs b/crates/orchestrator-core/src/secret_keysource.rs index 2dbf7bd0..35d73e28 100644 --- a/crates/orchestrator-core/src/secret_keysource.rs +++ b/crates/orchestrator-core/src/secret_keysource.rs @@ -453,6 +453,28 @@ pub(crate) mod tests { assert_eq!(*src.key().unwrap(), raw); } + #[test] + fn resolve_auto_does_not_fall_back_when_configured_key_file_is_unreadable() { + let tmp = tempfile::tempdir().unwrap(); + let key_file = tmp.path().join("missing-server.key"); + let _guard = env_lock().lock().unwrap(); + let prev = std::env::var(ENV_USER_KEY).ok(); + std::env::remove_var(ENV_USER_KEY); + let config = KeySourceConfig { kind_override: None, key_file: Some(key_file.clone()), passphrase: None }; + let result = resolve_auto(&config, &[0u8; 16]); + match prev { + Some(value) => std::env::set_var(ENV_USER_KEY, value), + None => std::env::remove_var(ENV_USER_KEY), + } + + let error = match result { + Ok(_) => panic!("auto unexpectedly ignored an unreadable configured server key"), + Err(error) => error.to_string(), + }; + assert!(error.contains("secrets.key_file"), "unexpected error: {error}"); + assert!(error.contains(&key_file.display().to_string()), "unexpected error: {error}"); + } + #[test] fn resolve_auto_prefers_env_user_key_over_configured_key_file() { let env_key = [0x66u8; KEY_LEN]; From a44c223b65d5b234024ae6ef499862ed2710cb5d Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:09:27 +0000 Subject: [PATCH 34/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/orchestrator-core/src/secret_keysource.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/orchestrator-core/src/secret_keysource.rs b/crates/orchestrator-core/src/secret_keysource.rs index 35d73e28..ed2d07b4 100644 --- a/crates/orchestrator-core/src/secret_keysource.rs +++ b/crates/orchestrator-core/src/secret_keysource.rs @@ -450,7 +450,7 @@ pub(crate) mod tests { } let src = result.expect("resolve_auto with key_file configured should succeed"); assert_eq!(src.id(), "user-key", "auto must resolve to user-key when key_file is configured"); - assert_eq!(*src.key().unwrap(), raw); + assert_eq!(*src.key().unwrap(), raw, "auto must read key material from the configured key file"); } #[test] From 22762f2a141aab1492fad2ac9ae13f130306254d Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:17:07 +0000 Subject: [PATCH 35/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index 41b36eee..d73e6b58 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -309,6 +309,11 @@ mod tests { assert_oauth_store_uses_project_key_file(Some("user-key"), Some("auto"), false); } + #[test] + fn oauth_secret_store_default_backend_honors_project_user_key_without_env_key() { + assert_oauth_store_uses_project_key_file(Some("user-key"), None, false); + } + #[test] fn oauth_secret_store_auto_uses_project_key_file_without_env_key() { assert_oauth_store_uses_project_key_file(Some("auto"), Some("auto"), false); From 89720ffa1ce98eaf2e750c69cd4db48b4095d042 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:25:00 +0000 Subject: [PATCH 36/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index d73e6b58..8d1fc8e3 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -314,6 +314,11 @@ mod tests { assert_oauth_store_uses_project_key_file(Some("user-key"), None, false); } + #[test] + fn oauth_secret_store_default_backend_ignores_empty_env_key_with_project_user_key() { + assert_oauth_store_uses_project_key_file(Some("user-key"), None, true); + } + #[test] fn oauth_secret_store_auto_uses_project_key_file_without_env_key() { assert_oauth_store_uses_project_key_file(Some("auto"), Some("auto"), false); From 5d959f5738c024861b31336bc3dc820d0506fd9b Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:38:37 +0000 Subject: [PATCH 37/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- crates/animus-mcp-oauth/src/config.rs | 9 ++------- crates/orchestrator-core/src/secret_device_store.rs | 4 +--- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/crates/animus-mcp-oauth/src/config.rs b/crates/animus-mcp-oauth/src/config.rs index 8d1fc8e3..97bec3cc 100644 --- a/crates/animus-mcp-oauth/src/config.rs +++ b/crates/animus-mcp-oauth/src/config.rs @@ -237,11 +237,7 @@ mod tests { } } - fn assert_oauth_store_uses_project_key_file( - key_source: Option<&str>, - backend: Option<&str>, - empty_env_key: bool, - ) { + fn assert_oauth_store_uses_project_key_file(key_source: Option<&str>, backend: Option<&str>, empty_env_key: bool) { // Secret-key environment variables are process-global. Keep removal, // store construction, and restoration in one serialized window so // these tests cannot borrow or overwrite another test's key. @@ -273,8 +269,7 @@ mod tests { "device-encrypted store", "a project key_file must select the headless-safe device backend" ); - store.set("oauth_test", "token") - .expect("OAuth secret write must use the project-configured key file"); + store.set("oauth_test", "token").expect("OAuth secret write must use the project-configured key file"); drop(store); // Rebuild the store as the separate `mcp auth --complete` invocation diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index 02288770..38a255e2 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -425,9 +425,7 @@ fn resolve_auto_backend(cfg: &protocol::SecretsConfig, scoped_root: &Path) -> &' /// injected via the corresponding env var. fn has_server_key_configured(cfg: &protocol::SecretsConfig) -> bool { use crate::secret_keysource::{KeySourceKind, ENV_PASSPHRASE, ENV_USER_KEY}; - let configured_source = cfg.key_source - .as_deref() - .and_then(|source| KeySourceKind::parse(source).ok()); + let configured_source = cfg.key_source.as_deref().and_then(|source| KeySourceKind::parse(source).ok()); configured_source.is_some_and(|source| matches!(source, KeySourceKind::UserKey | KeySourceKind::Passphrase)) || (cfg.key_file.as_deref().is_some_and(|path| !path.trim().is_empty()) && matches!(configured_source, None | Some(KeySourceKind::Auto | KeySourceKind::UserKey))) From aaf72cba82a95c4f76edb3050f9940a36cd816b7 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:46:01 +0000 Subject: [PATCH 38/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- .../src/secret_device_store.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index 38a255e2..09719908 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -468,7 +468,12 @@ fn key_source_config(cfg: &protocol::SecretsConfig) -> KeySourceConfig { // Treat an empty JSON string as absent. Otherwise `auto` selects the // device backend and later attempts to read an empty path as a key // file, obscuring the actionable "no key provided" error. - key_file: cfg.key_file.as_deref().filter(|path| !path.trim().is_empty()).map(PathBuf::from), + key_file: cfg + .key_file + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(PathBuf::from), // `passphrase` is env-driven for both the CLI and the daemon: the key // source reads ANIMUS_SECRET_PASSPHRASE at resolve time (and errors with // that instruction when unset), so there is no in-process passphrase to @@ -679,6 +684,18 @@ mod tests { assert!(resolved.key_file.is_none(), "an empty key_file must be normalized to absent"); } + #[test] + fn key_file_path_is_trimmed_when_loaded_from_config() { + let cfg = protocol::SecretsConfig { + key_file: Some(" /srv/animus/secret.key\n".to_string()), + ..Default::default() + }; + + let resolved = key_source_config(&cfg); + + assert_eq!(resolved.key_file.as_deref(), Some(Path::new("/srv/animus/secret.key"))); + } + #[test] fn key_file_does_not_override_explicit_device_id_source() { use crate::secret_keysource::{ENV_PASSPHRASE, ENV_USER_KEY}; From 337d354600a96fd103e252205e3dfbdfd993bb59 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:54:23 +0000 Subject: [PATCH 39/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- .../src/secret_device_store.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index 09719908..290e2915 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -772,6 +772,35 @@ mod tests { assert_eq!(get_result.unwrap().as_deref(), Some("bar")); } + #[test] + fn build_secret_store_for_project_auto_uses_project_key_file() { + crate::test_env::stable_test_home(); + let _guard = env_lock().lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let project_dir = tmp.path().join("project"); + std::fs::create_dir_all(&project_dir).unwrap(); + let key_file = tmp.path().join("server.key"); + std::fs::write(&key_file, hex::encode([0xBCu8; KEY_LEN])).unwrap(); + write_project_secrets_config(&project_dir, Some("auto"), Some(key_file.to_str().unwrap())); + let scoped_root = tmp.path().join("state"); + std::fs::create_dir_all(&scoped_root).unwrap(); + + use crate::secret_keysource::ENV_USER_KEY; + let previous_key = std::env::var(ENV_USER_KEY).ok(); + std::env::remove_var(ENV_USER_KEY); + let store = build_secret_store_for_project("test-auto-project-scope", scoped_root, &project_dir); + let set_result = store.set("FOO", "bar"); + let get_result = store.get("FOO"); + match previous_key { + Some(value) => std::env::set_var(ENV_USER_KEY, value), + None => std::env::remove_var(ENV_USER_KEY), + } + + set_result.expect("auto must use the project key_file without ANIMUS_SECRET_KEY"); + assert_eq!(get_result.unwrap().as_deref(), Some("bar")); + assert_eq!(store.backend_label(), "device-encrypted store"); + } + #[test] fn resolve_auto_backend_uses_device_when_server_key_in_cfg() { let cfg = protocol::SecretsConfig { backend: None, key_source: Some("user-key".to_string()), key_file: None }; From e820e82a7fdbbeb38b2be478cc517d82f352c4a7 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:10:17 +0000 Subject: [PATCH 40/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- .../src/secret_device_store.rs | 41 +++++-- .../orchestrator-core/src/secret_keysource.rs | 101 ++++++++++++++---- docs/reference/secrets.md | 4 +- 3 files changed, 112 insertions(+), 34 deletions(-) diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index 290e2915..28126f1d 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -468,12 +468,7 @@ fn key_source_config(cfg: &protocol::SecretsConfig) -> KeySourceConfig { // Treat an empty JSON string as absent. Otherwise `auto` selects the // device backend and later attempts to read an empty path as a key // file, obscuring the actionable "no key provided" error. - key_file: cfg - .key_file - .as_deref() - .map(str::trim) - .filter(|path| !path.is_empty()) - .map(PathBuf::from), + key_file: cfg.key_file.as_deref().map(str::trim).filter(|path| !path.is_empty()).map(PathBuf::from), // `passphrase` is env-driven for both the CLI and the daemon: the key // source reads ANIMUS_SECRET_PASSPHRASE at resolve time (and errors with // that instruction when unset), so there is no in-process passphrase to @@ -686,10 +681,8 @@ mod tests { #[test] fn key_file_path_is_trimmed_when_loaded_from_config() { - let cfg = protocol::SecretsConfig { - key_file: Some(" /srv/animus/secret.key\n".to_string()), - ..Default::default() - }; + let cfg = + protocol::SecretsConfig { key_file: Some(" /srv/animus/secret.key\n".to_string()), ..Default::default() }; let resolved = key_source_config(&cfg); @@ -801,6 +794,34 @@ mod tests { assert_eq!(store.backend_label(), "device-encrypted store"); } + #[test] + fn build_secret_store_for_project_trims_configured_key_file() { + crate::test_env::stable_test_home(); + let _guard = env_lock().lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let project_dir = tmp.path().join("project"); + std::fs::create_dir_all(&project_dir).unwrap(); + let key_file = tmp.path().join("server.key"); + std::fs::write(&key_file, hex::encode([0xCDu8; KEY_LEN])).unwrap(); + let padded_key_file = format!(" {}\n", key_file.display()); + write_project_secrets_config(&project_dir, Some("auto"), Some(&padded_key_file)); + let scoped_root = tmp.path().join("state"); + std::fs::create_dir_all(&scoped_root).unwrap(); + + use crate::secret_keysource::ENV_USER_KEY; + let previous_key = std::env::var(ENV_USER_KEY).ok(); + std::env::remove_var(ENV_USER_KEY); + let store = build_secret_store_for_project("test-trimmed-key-file-scope", scoped_root, &project_dir); + let set_result = store.set("FOO", "bar"); + match previous_key { + Some(value) => std::env::set_var(ENV_USER_KEY, value), + None => std::env::remove_var(ENV_USER_KEY), + } + + set_result.expect("auto must trim and use the project key_file without ANIMUS_SECRET_KEY"); + assert_eq!(store.backend_label(), "device-encrypted store"); + } + #[test] fn resolve_auto_backend_uses_device_when_server_key_in_cfg() { let cfg = protocol::SecretsConfig { backend: None, key_source: Some("user-key".to_string()), key_file: None }; diff --git a/crates/orchestrator-core/src/secret_keysource.rs b/crates/orchestrator-core/src/secret_keysource.rs index ed2d07b4..da1f45cc 100644 --- a/crates/orchestrator-core/src/secret_keysource.rs +++ b/crates/orchestrator-core/src/secret_keysource.rs @@ -14,6 +14,7 @@ use anyhow::{anyhow, bail, Context, Result}; use rand::RngCore; use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; use zeroize::Zeroizing; /// Length of the wrapping key (and the master key it wraps). @@ -312,16 +313,49 @@ pub fn resolve_key_source(config: &KeySourceConfig, salt: &[u8]) -> Result AutoDecision { + if has_user_key { + AutoDecision::UserKey + } else if has_passphrase { + AutoDecision::Passphrase + } else if is_server || !has_tty { + AutoDecision::HardError + } else { + AutoDecision::DeviceId + } +} + +fn is_server_env() -> bool { + std::env::var("ANIMUS_SERVER").is_ok_and(|value| value.trim() == "1") +} + +fn has_interactive_tty() -> bool { + use std::io::IsTerminal; + std::io::stdin().is_terminal() || std::io::stdout().is_terminal() || std::io::stderr().is_terminal() +} + +/// `auto`: prefer operator-supplied server key material, then allow the +/// `device-id` fallback only on an interactive local host. Hardware providers +/// (Secure Enclave / DPAPI / TPM) can be wired in per platform; until a +/// platform's provider lands, `auto` resolves per the following priority: /// /// 1. `ANIMUS_SECRET_KEY` env var → `user-key` (runtime-injected key; highest priority) /// 2. `key_file` from `config` → `user-key` (operator-configured file; headless-safe) /// 3. configured passphrase or `ANIMUS_SECRET_PASSPHRASE` env var → `passphrase` /// (Argon2id KDF; headless-safe) -/// 4. `device-id` (fallback; interactive hosts only — binding, not on-device-secret-safe) +/// 4. `device-id` on interactive local hosts only (binding, not secrecy) +/// 5. hard error on server/headless hosts without operator key material /// /// Steps 1–3 let headless/server deployments work without setting /// `secret_key_source` explicitly: they just supply the key material (via env @@ -338,19 +372,30 @@ fn resolve_auto(config: &KeySourceConfig, salt: &[u8]) -> Result Ok(Box::new(UserKeySource::resolve(config.key_file.as_deref())?)), + AutoDecision::Passphrase => Ok(Box::new(PassphraseKeySource::resolve( config.passphrase.as_ref().map(|passphrase| passphrase.as_str()), salt, - )?)); + )?)), + AutoDecision::DeviceId => { + if !DEVICE_ID_FALLBACK_WARNED.swap(true, Ordering::Relaxed) { + tracing::warn!( + "secret_key_source = auto fell back to device-id: the secret store is bound to this machine but is decryptable by any local user; set {ENV_USER_KEY}, configure secrets.key_file, or set {ENV_PASSPHRASE} for at-rest secrecy" + ); + } + Ok(Box::new(DeviceIdKeySource::resolve(salt)?)) + } + AutoDecision::HardError => bail!( + "secret_key_source = auto has no key material on a server/headless host, and the device-id fallback is decryptable by any local user. Set {ENV_USER_KEY} (hex or base64, 32 bytes), configure secrets.key_file, or set {ENV_PASSPHRASE}" + ), } - Ok(Box::new(DeviceIdKeySource::resolve(salt)?)) } #[cfg(test)] @@ -540,12 +585,14 @@ pub(crate) mod tests { } #[test] - fn resolve_auto_ignores_empty_passphrase_env() { + fn resolve_auto_hard_errors_on_server_without_key_material() { let _guard = env_lock().lock().unwrap(); let prev_key = std::env::var(ENV_USER_KEY).ok(); let prev_pass = std::env::var(ENV_PASSPHRASE).ok(); + let prev_server = std::env::var("ANIMUS_SERVER").ok(); std::env::remove_var(ENV_USER_KEY); std::env::set_var(ENV_PASSPHRASE, " "); + std::env::set_var("ANIMUS_SERVER", "1"); let salt = [0xACu8; 16]; let result = resolve_auto(&KeySourceConfig::default(), &salt); match &prev_key { @@ -556,16 +603,12 @@ pub(crate) mod tests { Some(v) => std::env::set_var(ENV_PASSPHRASE, v), None => std::env::remove_var(ENV_PASSPHRASE), } - match result { - Ok(src) => assert_eq!(src.id(), "device-id"), - Err(err) => { - let message = format!("{err:#}"); - assert!( - message.contains("machine id") || message.contains("machine-id"), - "empty passphrase must fall through to device-id, got: {message}" - ); - } + match &prev_server { + Some(v) => std::env::set_var("ANIMUS_SERVER", v), + None => std::env::remove_var("ANIMUS_SERVER"), } + let error = result.err().expect("empty passphrase must not provide headless key material"); + assert!(error.to_string().contains("server/headless host"), "unexpected error: {error:#}"); } #[test] @@ -618,6 +661,20 @@ pub(crate) mod tests { assert_eq!(src.id(), "user-key", "user-key env must take priority over passphrase env"); } + #[test] + fn auto_decision_allows_device_id_only_interactively() { + assert_eq!(decide_auto(false, false, false, true), AutoDecision::DeviceId); + assert_eq!(decide_auto(false, false, false, false), AutoDecision::HardError); + assert_eq!(decide_auto(false, false, true, true), AutoDecision::HardError); + assert_eq!(decide_auto(false, false, true, false), AutoDecision::HardError); + } + + #[test] + fn auto_decision_preserves_operator_key_precedence_on_servers() { + assert_eq!(decide_auto(true, true, true, false), AutoDecision::UserKey); + assert_eq!(decide_auto(false, true, true, false), AutoDecision::Passphrase); + } + #[test] fn device_id_is_deterministic_and_binds_to_machine_material() { let salt = [9u8; 16]; diff --git a/docs/reference/secrets.md b/docs/reference/secrets.md index eca00bce..faf9912d 100644 --- a/docs/reference/secrets.md +++ b/docs/reference/secrets.md @@ -85,10 +85,10 @@ OAuth operations, including `animus mcp auth --complete`: ### Key sources (what wraps the device store's master key) -- **`device-id`** — `HKDF(machine-id + per-install salt)`. The machine id never travels with the file, so an off-device copy can't decrypt. Cross-platform, no prompt. Default fallback. +- **`device-id`** — `HKDF(machine-id + per-install salt)`. The machine id never travels with the file, so an off-device copy can't decrypt. Cross-platform, no prompt. Used as the `auto` fallback only on interactive local hosts; headless/server hosts must supply explicit key material. - **`user-key`** — an operator-supplied 32-byte key from `ANIMUS_SECRET_KEY` (hex/base64) or `key_file`. For headless/server with a deploy-injected key (systemd `LoadCredential`, mounted secret, external KMS). - **`passphrase`** — `Argon2id` over a passphrase read from `ANIMUS_SECRET_PASSPHRASE`. Env-driven for both the CLI and the daemon (so the mode is script-safe and behaves identically everywhere); in exposure terms a non-interactive passphrase is equivalent to `user-key`. The store errors with that variable name when it is unset. -- **`auto`** — resolves in priority order: (1) `ANIMUS_SECRET_KEY` env var → `user-key`; (2) `key_file` configured in the `secrets` block → `user-key`; (3) `ANIMUS_SECRET_PASSPHRASE` env var → `passphrase`; (4) `device-id` fallback. Steps 1–3 let headless/server deployments work without setting `key_source` explicitly — just supply the key material via env or file and `auto` selects the right source. The OS hardware-backed sources (Secure Enclave / DPAPI / TPM) are deferred. +- **`auto`** — resolves in priority order: (1) `ANIMUS_SECRET_KEY` env var → `user-key`; (2) `key_file` configured in the `secrets` block → `user-key`; (3) `ANIMUS_SECRET_PASSPHRASE` env var → `passphrase`; (4) `device-id`, but only on an interactive local host. Steps 1–3 let headless/server deployments work without setting `key_source` explicitly — just supply the key material via env or file and `auto` selects the right source. On a headless host (or when `ANIMUS_SERVER=1`), `auto` hard-errors if none of `ANIMUS_SECRET_KEY`, `secrets.key_file`, or `ANIMUS_SECRET_PASSPHRASE` supplies key material; it never falls back to the locally decryptable, machine-bound `device-id` source there. The OS hardware-backed sources (Secure Enclave / DPAPI / TPM) are deferred. ### Moving between backends From 22890d676df14873e0d0045fc67d367ec59476fe Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:17:23 +0000 Subject: [PATCH 41/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- .../orchestrator-core/src/secret_device_store.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index 28126f1d..926fd768 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -433,15 +433,13 @@ fn has_server_key_configured(cfg: &protocol::SecretsConfig) -> bool { || std::env::var(ENV_PASSPHRASE).is_ok_and(|raw| !raw.trim().is_empty()) } -/// Read the project-level `.animus/config.json` and return its `secrets` block. -/// Returns `None` when the file is absent or unparseable (side-effect-free). +/// Load the project configuration through the canonical protocol loader and +/// return its `secrets` block. Keeping secret-store construction on the same +/// loader as the rest of the CLI prevents OAuth (including a separate +/// `mcp auth --complete` process) from interpreting project configuration +/// differently from ordinary secret operations. fn load_project_secrets_config(project_root: &Path) -> Option { - let path = project_root.join(".animus").join("config.json"); - if !path.exists() { - return None; - } - let content = std::fs::read_to_string(&path).ok()?; - serde_json::from_str::(&content).ok()?.secrets + protocol::Config::load_or_default(project_root.to_string_lossy().as_ref()).ok()?.secrets } /// Merge two [`protocol::SecretsConfig`] values; `project` wins field-by-field. From 156e32bc3eefb4a6273223b41508a0eb9d58536c Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:29:45 +0000 Subject: [PATCH 42/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- CHANGELOG.md | 6 ++++ .../src/secret_device_store.rs | 29 +++++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0626f6df..a9b2d588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Headless secret stores now honor durable server keys consistently.** With + `secrets.key_source = auto`, `ANIMUS_SECRET_KEY` or a configured + `secrets.key_file` selects the device-encrypted store before any keychain or + device-id fallback. Project-level `secrets` settings are also applied by MCP + OAuth operations, including a separate `animus mcp auth --complete` process. + ## [0.6.11] - 2026-06-24 **v0.6.11 — `animus update` defers to avm.** diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index 926fd768..3b290a11 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -433,13 +433,17 @@ fn has_server_key_configured(cfg: &protocol::SecretsConfig) -> bool { || std::env::var(ENV_PASSPHRASE).is_ok_and(|raw| !raw.trim().is_empty()) } -/// Load the project configuration through the canonical protocol loader and -/// return its `secrets` block. Keeping secret-store construction on the same -/// loader as the rest of the CLI prevents OAuth (including a separate -/// `mcp auth --complete` process) from interpreting project configuration -/// differently from ordinary secret operations. +/// Read the project configuration, when present, and return its `secrets` +/// block. This intentionally avoids [`protocol::Config::load_or_default`]: +/// constructing a secret store is a read operation and must not create a +/// default `.animus/config.json` in an otherwise unconfigured project. fn load_project_secrets_config(project_root: &Path) -> Option { - protocol::Config::load_or_default(project_root.to_string_lossy().as_ref()).ok()?.secrets + let config_path = project_root.join(".animus").join("config.json"); + if !config_path.is_file() { + return None; + } + let contents = std::fs::read(config_path).ok()?; + serde_json::from_slice::(&contents).ok()?.secrets } /// Merge two [`protocol::SecretsConfig`] values; `project` wins field-by-field. @@ -763,6 +767,19 @@ mod tests { assert_eq!(get_result.unwrap().as_deref(), Some("bar")); } + #[test] + fn build_secret_store_for_project_does_not_configure_an_unconfigured_project() { + crate::test_env::stable_test_home(); + let tmp = tempfile::tempdir().unwrap(); + let project_dir = tmp.path().join("project"); + std::fs::create_dir_all(&project_dir).unwrap(); + let scoped_root = tmp.path().join("state"); + + let _store = build_secret_store_for_project("test-unconfigured-project-scope", scoped_root, &project_dir); + + assert!(!project_dir.join(".animus").exists(), "secret-store construction must not modify the project"); + } + #[test] fn build_secret_store_for_project_auto_uses_project_key_file() { crate::test_env::stable_test_home(); From e4fe54fdcfb54f8e434a81c22fb92e6de288dd57 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:40:24 +0000 Subject: [PATCH 43/43] animus-cli: first-class headless/server secret-key source (auto hard-errors; config-file user-key not honored by all paths) --- .../src/secret_device_store.rs | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/orchestrator-core/src/secret_device_store.rs b/crates/orchestrator-core/src/secret_device_store.rs index 3b290a11..c3f43b1f 100644 --- a/crates/orchestrator-core/src/secret_device_store.rs +++ b/crates/orchestrator-core/src/secret_device_store.rs @@ -443,7 +443,19 @@ fn load_project_secrets_config(project_root: &Path) -> Option(&contents).ok()?.secrets + let mut secrets = serde_json::from_slice::(&contents).ok()?.secrets?; + // A project key file belongs to the project configuration, not to the + // caller's current working directory. OAuth completion may run in a + // separate process (or from a daemon), so leaving a relative path + // unresolved would make otherwise identical secret operations select + // different key material. + if let Some(key_file) = secrets.key_file.as_deref().map(str::trim).filter(|path| !path.is_empty()) { + let path = Path::new(key_file); + if path.is_relative() { + secrets.key_file = Some(project_root.join(path).to_string_lossy().into_owned()); + } + } + Some(secrets) } /// Merge two [`protocol::SecretsConfig`] values; `project` wins field-by-field. @@ -809,6 +821,31 @@ mod tests { assert_eq!(store.backend_label(), "device-encrypted store"); } + #[test] + fn build_secret_store_for_project_resolves_relative_key_file_from_project_root() { + crate::test_env::stable_test_home(); + let _guard = env_lock().lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let project_dir = tmp.path().join("project"); + std::fs::create_dir_all(&project_dir).unwrap(); + std::fs::write(project_dir.join("server.key"), hex::encode([0xBEu8; KEY_LEN])).unwrap(); + write_project_secrets_config(&project_dir, Some("auto"), Some("server.key")); + let scoped_root = tmp.path().join("state"); + + use crate::secret_keysource::ENV_USER_KEY; + let previous_key = std::env::var(ENV_USER_KEY).ok(); + std::env::remove_var(ENV_USER_KEY); + let store = build_secret_store_for_project("test-relative-key-file-scope", scoped_root, &project_dir); + let set_result = store.set("FOO", "bar"); + match previous_key { + Some(value) => std::env::set_var(ENV_USER_KEY, value), + None => std::env::remove_var(ENV_USER_KEY), + } + + set_result.expect("a relative project key_file must resolve from the project root"); + assert_eq!(store.backend_label(), "device-encrypted store"); + } + #[test] fn build_secret_store_for_project_trims_configured_key_file() { crate::test_env::stable_test_home();