Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2e904e4
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 18, 2026
a00f8ea
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 29, 2026
0ff240f
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 29, 2026
6669621
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 29, 2026
3d5f1aa
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 29, 2026
6b3aac8
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 29, 2026
29bf13d
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 29, 2026
4dc7626
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 30, 2026
c6a18f7
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 30, 2026
c70e3c6
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 30, 2026
6fb1f29
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 30, 2026
654311a
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 30, 2026
c087471
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 30, 2026
6c339dc
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 30, 2026
c19c0c3
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 31, 2026
226bc9b
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 31, 2026
f617e4c
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 31, 2026
b084c72
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 31, 2026
9591ac8
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 31, 2026
85b7d98
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 31, 2026
7fbc9e9
animus-cli: first-class headless/server secret-key source (auto hard-…
Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 111 additions & 3 deletions crates/animus-mcp-oauth/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,24 @@ pub struct ServerResolution {
pub broker_oauth: Option<OauthConfig>,
}

/// 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
/// `.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<Arc<dyn SecretStore>, 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<std::path::PathBuf>) -> Arc<dyn SecretStore> {
let scoped_root = scoped_root.into();
let scope = resolve_keychain_scope(project_root, &scoped_root);
Ok(Arc::from(orchestrator_core::build_secret_store(&scope, scoped_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
Expand Down Expand Up @@ -188,3 +198,101 @@ fn finalize(
}),
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};

fn env_lock() -> &'static Mutex<()> {
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
ENV_LOCK.get_or_init(|| Mutex::new(()))
}

struct EnvVarGuard {
name: &'static str,
previous: Option<String>,
}

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),
}
}
}

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.
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");
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": {
"backend": backend,
"key_source": key_source,
"key_file": key_file
}
});
std::fs::write(animus_dir.join("config.json"), serde_json::to_vec(&config).unwrap()).unwrap();

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");
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"));
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!(
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(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"), Some("auto"));
}

#[test]
fn oauth_secret_store_default_auto_uses_project_key_file_without_env_key() {
assert_oauth_store_uses_project_key_file(None, None);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ fn keychain_store(project_root: &Path) -> Option<Box<dyn SecretStore>> {
.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.
Expand Down
14 changes: 9 additions & 5 deletions crates/orchestrator-cli/src/services/operations/ops_secret.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -61,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(&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)?;
Expand Down Expand Up @@ -286,7 +290,7 @@ fn build_store(project_root: &Path) -> Result<Box<dyn SecretStore>> {
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
Expand Down
5 changes: 4 additions & 1 deletion crates/orchestrator-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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_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,
Expand Down
Loading
Loading