diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index fc90e6ab14a..f37d385cac6 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -405,7 +405,7 @@ const MIGRATION_MARKER_NAME: &str = "identity.migrated"; /// The keyring operations the identity resolution flow needs. Abstracted so the /// corrupt-keyring recovery decision ([`recover_from_keyring`]) can be /// unit-tested against a fake without touching the live OS keyring. -trait IdentityKeyStore { +pub(crate) trait IdentityKeyStore { fn probe(&self, name: &str) -> crate::secret_store::KeyringProbe; fn load(&self, name: &str) -> Result, String>; fn store(&self, name: &str, value: &str) -> Result<(), String>; @@ -869,7 +869,7 @@ fn persist_identity_to_keyring( /// first via [`persist_identity_to_keyring`]; if the keyring is unavailable, /// falls back to the `0o600` identity.key file. Returns `Err` only when both /// the keyring write and the file fallback fail. -fn persist_imported_identity_impl( +pub(crate) fn persist_imported_identity_impl( store: &impl IdentityKeyStore, keys: &Keys, legacy_path: &std::path::Path, @@ -888,17 +888,6 @@ fn persist_imported_identity_impl( } } -/// Public entry point binding [`persist_imported_identity_impl`] to the shared -/// [`crate::secret_store::SecretStore`]. See the impl for the persistence policy. -pub(crate) fn persist_imported_identity( - store: &crate::secret_store::SecretStore, - keys: &Keys, - legacy_path: &std::path::Path, - data_dir: &std::path::Path, -) -> Result { - persist_imported_identity_impl(store, keys, legacy_path, data_dir) -} - /// Path of the migration-completed marker within `data_dir`. fn migration_marker_path(data_dir: &std::path::Path) -> std::path::PathBuf { data_dir.join(keyring_config::migration_marker_name( diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index b63370b95f8..b8fb544893a 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -119,6 +119,10 @@ fn agent_record() -> ManagedAgentRecord { agent_command_override: None, persona_source_version: None, provider: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } @@ -144,6 +148,7 @@ fn persona_with_model(model: &str) -> AgentDefinition { parallelism: None, created_at: "".to_string(), updated_at: "".to_string(), + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 9609db5f2df..4b98df534fb 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -405,16 +405,59 @@ enum InstallRestartOutcome { /// An agent qualifies iff: /// - it is a local backend with a live PID (`pid_alive`), /// - its effective command maps to `runtime_id`, -/// - it was **spawned in setup-listener mode** (`setup_mode`), AND -/// - its readiness **now computes `Ready`** (install fixed the blocker). -fn should_restart_after_install( +/// - it was **spawned in setup-listener mode** (`setup_mode`), +/// - its readiness **now computes `Ready`** (install fixed the blocker), AND +/// - none of its secret tiers are unavailable (`!secrets_unavailable`) — an +/// unavailable tier's empty hydrated env looks `Ready`, so without this a +/// stop-then-respawn would stop a live setup process only to hit the spawn +/// refusal (`FailedAfterStop`). +pub(super) fn should_restart_after_install( is_local: bool, pid_alive: bool, runtime_matches: bool, setup_mode: bool, now_ready: bool, + secrets_unavailable: bool, ) -> bool { - is_local && pid_alive && runtime_matches && setup_mode && now_ready + is_local && pid_alive && runtime_matches && setup_mode && now_ready && !secrets_unavailable +} + +/// Under-lock secret-availability gate for the post-install bounce: refuse +/// before the stop when any record-derivable secret tier is unavailable. An +/// unavailable tier hydrates to an empty env that looks `Ready`, so this is the +/// sole signal separating a bounce that would succeed from one that stops a +/// live process only to hit the spawn refusal (`FailedAfterStop`). Extracted as +/// the AppHandle-free seam so the refusal is unit-testable; mutating the +/// `effective_secrets_unavailable` consultation here turns a regression red. +pub(super) fn refuse_restart_on_unavailable_secrets( + record: &crate::managed_agents::ManagedAgentRecord, + personas: &[crate::managed_agents::AgentDefinition], +) -> Result<(), String> { + if crate::managed_agents::effective_secrets_unavailable(record, personas) { + return Err(format!( + "agent {} secrets unavailable from keyring under lock — not bouncing", + record.pubkey + )); + } + Ok(()) +} + +/// Fail closed when the committed global config cannot load at a restart +/// boundary. `spawn_agent_child` reloads the global config strictly +/// (`load_global_agent_config(app)?`), so a restart authorized while the global +/// ref is unavailable — even for an unrelated secret — would stop a live +/// process and only discover the refusal at respawn (`FailedAfterStop`). Global +/// availability is not derivable from a record, so it is a separate strict +/// `Result` gate rather than a fourth arm of `effective_secrets_unavailable`. +/// Mirrors `global_agent_config::refuse_save_on_unavailable_current`; extracted +/// as the AppHandle-free seam so the refusal is unit-testable (the callers pass +/// the `Result` from `load_global_agent_config(&app)`). +pub(super) fn refuse_restart_on_unavailable_global( + global_load: Result, +) -> Result { + global_load.map_err(|e| { + format!("global agent config unavailable at restart boundary — not bouncing: {e}") + }) } /// Restart all setup-mode agents whose runtime matches `runtime_id` and whose @@ -425,11 +468,7 @@ async fn restart_setup_mode_agents_after_install( ) -> (u32, u32) { use crate::{ app_state::AppState, - managed_agents::{ - agent_readiness, known_acp_runtime, load_global_agent_config, load_managed_agents, - load_personas, record_agent_command, resolve_effective_agent_env, AgentReadiness, - BackendKind, - }, + managed_agents::{load_global_agent_config, load_managed_agents, load_personas}, }; use tauri::Manager; @@ -439,48 +478,32 @@ async fn restart_setup_mode_agents_after_install( let candidates = tokio::task::spawn_blocking(move || { let records = load_managed_agents(&app_for_scan).unwrap_or_default(); let personas = load_personas(&app_for_scan).unwrap_or_default(); - let global = load_global_agent_config(&app_for_scan).unwrap_or_default(); - // Read the runtimes map to check setup_mode stamps. + // Read the runtimes map to check setup_mode stamps and PID liveness. let state_inner = app_for_scan.state::(); let runtimes = state_inner .managed_agent_processes .lock() .unwrap_or_else(|e| e.into_inner()); - records - .iter() - .filter(|record| { - let is_local = record.backend == BackendKind::Local; - let effective_cmd = record_agent_command(record, &personas); - let runtime_matches = - known_acp_runtime(&effective_cmd).is_some_and(|r| r.id == runtime_id_owned); + super::restart_ops::select_post_install_restart_candidates( + &records, + &personas, + &runtime_id_owned, + || load_global_agent_config(&app_for_scan), + |record| { let setup_mode = runtimes .iter() .find(|(key, _)| key.pubkey == record.pubkey) .map(|(_, p)| p.setup_mode) .unwrap_or(false); - let effective = resolve_effective_agent_env( - record, - &personas, - known_acp_runtime(&effective_cmd), - &global, - ); - let now_ready = matches!(agent_readiness(&effective), AgentReadiness::Ready); let pid_alive = runtimes.iter().any(|(key, runtime)| { key.pubkey.eq_ignore_ascii_case(&record.pubkey) && crate::managed_agents::process_is_running(runtime.child.id()) }); - should_restart_after_install( - is_local, - pid_alive, - runtime_matches, - setup_mode, - now_ready, - ) - }) - .map(|r| r.pubkey.clone()) - .collect::>() + (pid_alive, setup_mode) + }, + ) }) .await .unwrap_or_default(); @@ -517,10 +540,9 @@ async fn restart_single_agent_after_install( use crate::{ app_state::AppState, managed_agents::{ - agent_readiness, current_instance_id, find_managed_agent_mut, known_acp_runtime, - load_global_agent_config, load_managed_agents, load_personas, record_agent_command, - resolve_effective_agent_env, save_managed_agents, stop_managed_agent_process, - sync_managed_agent_processes, AgentReadiness, BackendKind, + current_instance_id, find_managed_agent_mut, load_global_agent_config, + load_managed_agents, load_personas, save_managed_agents, stop_managed_agent_process, + sync_managed_agent_processes, BackendKind, }, }; use tauri::Manager; @@ -570,41 +592,31 @@ async fn restart_single_agent_after_install( )); } + // Compute setup_mode under lock, then re-authorize + stop inside + // `authorize_post_install_restart`, which owns the gate order (strict + // global re-read, runtime-match, setup-mode, readiness, secret refusal) + // and runs the injected stop only on full Ok. The decision reads a + // cloned record; the stop closure re-finds the mutable record so the + // immutable-decision / mutable-stop borrow split stays clean. let personas = load_personas(&app_for_stop).unwrap_or_default(); - let global = load_global_agent_config(&app_for_stop).unwrap_or_default(); - - let effective_cmd = record_agent_command(record, &personas); - let runtime_matches = - known_acp_runtime(&effective_cmd).is_some_and(|r| r.id == runtime_id_owned); - if !runtime_matches { - return Err(format!( - "agent {pubkey_owned} runtime no longer matches {runtime_id_owned} under lock" - )); - } - let setup_mode = runtimes .iter() .find(|(key, _)| key.pubkey == pubkey_owned) .map(|(_, p)| p.setup_mode) .unwrap_or(false); - if !setup_mode { - return Err(format!( - "agent {pubkey_owned} is not in setup mode under lock — skipping" - )); - } - - let runtime_meta = known_acp_runtime(&effective_cmd); - let effective = resolve_effective_agent_env(record, &personas, runtime_meta, &global); - if !matches!(agent_readiness(&effective), AgentReadiness::Ready) { - return Err(format!( - "agent {pubkey_owned} readiness is still NotReady after install — not bouncing" - )); - } - - // Stop the process. - let record_mut = find_managed_agent_mut(&mut records, &pubkey_owned)?; - stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?; - save_managed_agents(&app_for_stop, &records)?; + let record = record.clone(); + super::restart_ops::authorize_post_install_restart( + &record, + &personas, + &runtime_id_owned, + setup_mode, + || load_global_agent_config(&app_for_stop), + || { + let record_mut = find_managed_agent_mut(&mut records, &pubkey_owned)?; + stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?; + save_managed_agents(&app_for_stop, &records) + }, + )?; Ok(runtime_keys) }) @@ -1059,753 +1071,8 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result = cmd - .get_args() - .map(|a| a.to_string_lossy().into_owned()) - .collect(); - let body = &args[2]; - assert!( - body.contains("set -o pipefail; "), - "the install body must set pipefail; got: {body}" - ); - assert!( - body.ends_with("curl -fsSL https://example.test/i.sh | bash"), - "the vendor command must be preserved verbatim; got: {body}" - ); - } - - /// The PATH prelude is emitted only where it helps, and the exact argument - /// vector is the contract: a stray trailing positional with no `$1` reader, - /// or an export whose `$1` the shell cannot split, both corrupt PATH. - /// Windows is excluded because `join_paths` is `;`-separated there while bash - /// splits PATH on `:` — and it is the platform where the inherited fallback - /// always fires. See `install_shell_args` for the full reasoning. - #[test] - fn test_install_shell_args_shape_per_platform() { - let composed = std::ffi::OsString::from("/buzz/node/bin:/usr/bin"); - let windows_composed = std::ffi::OsString::from(r"C:\buzz\node;C:\Windows\system32"); - let bare = ["-l", "-c", "set -o pipefail; echo hi"].map(std::ffi::OsString::from); - - assert_eq!( - super::install_shell_args("echo hi", Some(&composed), false), - [ - "-l", - "-c", - "export PATH=\"$1\"; set -o pipefail; echo hi", - "buzz-install", - "/buzz/node/bin:/usr/bin", - ] - .map(std::ffi::OsString::from), - "Unix must re-export the composed PATH after login init" - ); - assert_eq!( - super::install_shell_args("echo hi", Some(&windows_composed), true), - bare, - "Windows must not re-export a `;`-joined PATH inside bash" - ); - assert_eq!( - super::install_shell_args("echo hi", None, false), - bare, - "no composed PATH must yield the bare pipefail body and no positionals" - ); - } - - /// Regression for the login-startup-file overwrite: `cmd.env("PATH", …)` is - /// installed *before* `-l` sources the user's profile, so a profile that - /// assigns PATH silently discards the composed one. Uses `/bin/bash` - /// explicitly — the planted profile is bash-specific, so resolving the host - /// shell (which prefers zsh) would make this vacuous. - #[cfg(unix)] - #[test] - fn test_composed_path_survives_a_profile_that_clears_it() { - let home = tempfile::tempdir().expect("temp HOME"); - std::fs::write(home.path().join(".bash_profile"), "export PATH=\n") - .expect("plant a hostile login profile"); - let composed = std::ffi::OsString::from("/buzz/sentinel/bin:/usr/bin:/bin"); - - // `echo` is a shell builtin, so the child needs no PATH to report one. - let out = std::process::Command::new("/bin/bash") - .args(super::install_shell_args( - "echo \"$PATH\"", - Some(&composed), - false, - )) - .env("HOME", home.path()) - .env("PATH", &composed) - .stdin(std::process::Stdio::null()) - .output() - .expect("bash must spawn"); - - let path = String::from_utf8_lossy(&out.stdout); - assert!( - path.contains("/buzz/sentinel/bin"), - "the composed PATH must survive login init; got: {path:?}" - ); - } - - /// End-to-end on the real resolved install shell (no network): a pipeline - /// whose left-hand side fails must exit non-zero, while a fully successful - /// pipeline must still succeed. Without `pipefail` the status is the - /// right-hand side's and the left-hand failure is invisible. - #[cfg(unix)] - #[test] - fn test_install_shell_pipeline_status_follows_left_side() { - for (command, expect_success) in [("false | true", false), ("echo ok | cat", true)] { - let status = super::install_shell_command(command) - .expect("Unix must always resolve an install shell") - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .expect("install shell must spawn"); - assert_eq!( - status.success(), - expect_success, - "`{command}` must report success={expect_success}; got {status:?}" - ); - } - } - - // ── Phase A: Windows install shell selection ─────────────────────────────── - - /// On Windows (CI runner has Git pre-installed), resolve_install_shell succeeds. - #[cfg(windows)] - #[test] - fn test_resolve_install_shell_succeeds_on_windows_with_git() { - let result = super::resolve_install_shell(); - assert!( - result.is_ok(), - "Windows CI runner has Git — resolve_install_shell must succeed; got: {:?}", - result.err() - ); - let shell = result.unwrap(); - // The resolved path must end with bash.exe (Git Bash). - let fname = shell.file_name().and_then(|n| n.to_str()).unwrap_or(""); - assert!( - fname.eq_ignore_ascii_case("bash.exe"), - "Windows install shell must be bash.exe, got: {shell:?}" - ); - } - - /// On Windows, when no Git Bash is found, the error carries the Doctor hint. - #[cfg(windows)] - #[test] - fn test_resolve_install_shell_error_contains_doctor_hint() { - // We can't force resolve_install_shell to fail on CI (Git is installed), - // but we can verify the error string it would use matches the hint. - let hint = crate::managed_agents::git_bash::GIT_BASH_INSTALL_HINT; - assert!( - hint.contains("Git for Windows"), - "GIT_BASH_INSTALL_HINT must mention Git for Windows; got: {hint}" - ); - assert!( - hint.contains("PATH"), - "GIT_BASH_INSTALL_HINT must mention PATH option; got: {hint}" - ); - } - - /// install_shell_command returns a valid Command on Windows. - #[cfg(windows)] - #[test] - fn test_install_shell_command_returns_ok_on_windows() { - let result = super::install_shell_command("echo test"); - assert!( - result.is_ok(), - "install_shell_command must succeed on Windows with Git; got: {:?}", - result.err() - ); - } - - /// On Windows, `install_shell_command` must set PATH to a value that - /// includes the inherited process PATH, so node/npm are visible inside - /// the install shell even when no managed Node runtime is present. - #[cfg(windows)] - #[test] - fn test_install_shell_command_includes_process_path_on_windows() { - let _guard = crate::managed_agents::lock_path_mutex(); - let previous = std::env::var_os("PATH"); - // Plant a sentinel in the process PATH that the test can detect. - let sentinel = r"C:\TestSentinel\bin"; - std::env::set_var("PATH", sentinel); - - let result = super::install_shell_command("echo test"); - - match previous { - Some(p) => std::env::set_var("PATH", p), - None => std::env::remove_var("PATH"), - } - - let cmd = result.expect("install_shell_command must succeed on Windows with Git"); - let path_value = cmd - .get_envs() - .find(|(key, _)| *key == "PATH") - .and_then(|(_, val)| val) - .map(|v| v.to_string_lossy().into_owned()) - .expect("install_shell_command must always set a PATH env var on Windows"); - - // The sentinel (inherited process PATH) must appear in the composed PATH. - assert!( - path_value.contains(sentinel), - "install_shell_command PATH must include the inherited process PATH; got: {path_value}" - ); - // The sentinel must appear LAST — managed Buzz dirs must have precedence. - assert!( - path_value.ends_with(sentinel), - "inherited process PATH must be appended LAST so managed dirs keep precedence; got: {path_value}" - ); - } - - // ── Phase B: per-OS install commands ────────────────────────────────────── - - /// On non-Windows, cli_install_commands_for_os returns the default commands. - #[cfg(not(windows))] - #[test] - fn test_cli_install_commands_for_os_returns_default_on_unix() { - let claude = crate::managed_agents::known_acp_runtime_exact("claude").unwrap(); - assert_eq!( - claude.cli_install_commands_for_os(), - claude.cli_install_commands, - "on Unix, cli_install_commands_for_os must return the default install.sh commands" - ); - } - - /// buzz-agent has no install commands on any platform. - #[test] - fn test_buzz_agent_has_no_install_commands() { - let buzz = crate::managed_agents::known_acp_runtime_exact("buzz-agent").unwrap(); - assert!( - buzz.cli_install_commands_for_os().is_empty(), - "buzz-agent ships with the app — must never have install commands" - ); - } - - // ── PowerShell routing ──────────────────────────────────────────────────── - - /// Commands beginning with `powershell.exe` (any casing) must be identified - /// as PowerShell commands; all others must not. - #[cfg(windows)] - #[test] - fn test_is_powershell_command_detects_powershell_commands() { - assert!( - super::is_powershell_command( - r#"powershell.exe -NoProfile -NonInteractive -Command "irm https://chatgpt.com/codex/install.ps1 | iex""# - ), - "canonical codex install command must be detected as PowerShell" - ); - assert!( - super::is_powershell_command("POWERSHELL.EXE -Command foo"), - "is_powershell_command must be case-insensitive" - ); - assert!( - !super::is_powershell_command("npm install -g @agentclientprotocol/claude-agent-acp"), - "npm commands must NOT be detected as PowerShell" - ); - assert!( - !super::is_powershell_command(r"curl -fsSL https://example.com | bash"), - "bash pipe commands must NOT be detected as PowerShell" - ); - assert!( - !super::is_powershell_command(""), - "empty string must not be detected as PowerShell" - ); - } - - /// On Windows, `build_install_command` must return a `Command` whose - /// program is `powershell.exe` (not `bash.exe`) for PowerShell commands. - #[cfg(windows)] - #[test] - fn test_build_install_command_uses_powershell_natively_on_windows() { - let ps_command = r#"powershell.exe -NoProfile -NonInteractive -Command "irm https://chatgpt.com/codex/install.ps1 | iex""#; - let result = super::build_install_command(ps_command); - assert!( - result.is_ok(), - "build_install_command must succeed for a PowerShell command; got: {:?}", - result.err() - ); - let cmd = result.unwrap(); - let program = cmd.get_program().to_string_lossy().to_lowercase(); - assert!( - program.contains("powershell"), - "PowerShell install command must use powershell.exe, not bash; got: {program}" - ); - assert!( - !program.contains("bash"), - "PowerShell install command must NOT go through bash; got: {program}" - ); - } - - /// On Windows, `build_install_command` must route non-PowerShell commands - /// through Git Bash (program must be bash.exe). - #[cfg(windows)] - #[test] - fn test_build_install_command_uses_git_bash_for_non_powershell_on_windows() { - let npm_command = "npm install -g @agentclientprotocol/claude-agent-acp"; - let result = super::build_install_command(npm_command); - assert!( - result.is_ok(), - "build_install_command must succeed for an npm command on Windows with Git; got: {:?}", - result.err() - ); - let cmd = result.unwrap(); - let program = cmd.get_program().to_string_lossy().to_lowercase(); - assert!( - program.contains("bash"), - "non-PowerShell install command must still use bash.exe on Windows; got: {program}" - ); - } - - /// On non-Windows, `build_install_command` must always use the Unix shell - /// (zsh or bash), never powershell.exe. - #[cfg(not(windows))] - #[test] - fn test_build_install_command_uses_unix_shell_on_non_windows() { - let command = r"curl -fsSL https://example.com/install.sh | bash"; - let result = super::build_install_command(command); - assert!( - result.is_ok(), - "build_install_command must succeed on Unix; got: {:?}", - result.err() - ); - let cmd = result.unwrap(); - let program = cmd.get_program().to_string_lossy(); - assert!( - program.contains("bash") || program.contains("zsh"), - "Unix install command must use bash or zsh, got: {program}" - ); - } - - /// On Windows, `install_powershell_command` must build an exact argv: - /// flags before `-Command` forwarded, body unquoted (outer catalog quotes stripped), - /// no bash flags, and `-Command` found on token boundary not as substring. - #[cfg(windows)] - #[test] - fn test_powershell_command_argv_exact() { - // Catalog format: body wrapped in one outer double-quote pair (Bash-layer serialization). - let body = "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-codex.ps1'; Invoke-RestMethod https://chatgpt.com/codex/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE"; - let cmd = super::install_powershell_command(&format!( - r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "{body}""# - )); - assert_eq!( - cmd.get_args() - .map(|a| a.to_string_lossy().into_owned()) - .collect::>(), - vec!["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", body], - "argv must be exact with outer quotes stripped" - ); - } - - /// Token that merely contains `-command` as a substring must not be treated - /// as the `-Command` boundary; only an exact token match (case-insensitive) counts. - #[cfg(windows)] - #[test] - fn test_powershell_command_token_boundary_not_substring() { - let cmd = super::install_powershell_command( - r#"powershell.exe -x-command-y -Command "echo hello""#, - ); - assert_eq!( - cmd.get_args() - .map(|a| a.to_string_lossy().into_owned()) - .collect::>(), - vec!["-x-command-y", "-Command", "echo hello"], - "substring must not consume -Command boundary early" - ); - } - - /// Claude Code catalog command must dequote to the two-step download-then-execute body. - #[cfg(windows)] - #[test] - fn test_powershell_command_claude_catalog_dequoted() { - let cmd = super::install_powershell_command( - r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-claude.ps1'; Invoke-RestMethod https://claude.ai/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE""#, - ); - assert_eq!( - cmd.get_args() - .map(|a| a.to_string_lossy().into_owned()) - .collect::>(), - vec![ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-Command", - "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-claude.ps1'; Invoke-RestMethod https://claude.ai/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE", - ], - "Claude catalog command must be dequoted correctly" - ); - } - - /// Goose Windows catalog command must dequote to the two-step download-then-execute body - /// with the `$env:CONFIGURE` prefix intact — no backslash before the dollar sign. - /// This proves the `\$` → `$` contract: post-#2750 the spawn is native and - /// PowerShell receives the body verbatim, so a residual `\` would produce - /// `\$env:CONFIGURE='false'` which is a malformed statement. - #[cfg(windows)] - #[test] - fn test_powershell_command_goose_catalog_dequoted() { - let cmd = super::install_powershell_command( - r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$env:CONFIGURE='false'; $ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-goose.ps1'; Invoke-RestMethod https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE""#, - ); - assert_eq!( - cmd.get_args() - .map(|a| a.to_string_lossy().into_owned()) - .collect::>(), - vec![ - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-Command", - "$env:CONFIGURE='false'; $ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-goose.ps1'; Invoke-RestMethod https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE", - ], - "Goose catalog command must dequote with bare $env: (no backslash before $)" - ); - } -} +#[path = "agent_discovery_tests.rs"] +mod tests; /// Returns the Windows-only Git Bash prerequisite used by buzz-agent's shell MCP. /// `None` on other platforms keeps the shared Doctor surfaces platform-neutral. diff --git a/desktop/src-tauri/src/commands/agent_discovery_tests.rs b/desktop/src-tauri/src/commands/agent_discovery_tests.rs new file mode 100644 index 00000000000..e7f405a70d1 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery_tests.rs @@ -0,0 +1,864 @@ +//! Tests for `agent_discovery.rs`: npm-install detection, EACCES hints, adapter +//! install planning, and the post-install restart-eligibility predicate +//! (`should_restart_after_install`, including its fail-closed +//! `secrets_unavailable` guard). +//! +//! Extracted from `agent_discovery.rs` via `#[path]` so that module stays under +//! the desktop file-size ratchet; `super::*` resolves against it, matching the +//! `runtime_commands_tests.rs` convention. + +use super::*; + +// ── is_npm_global_install ───────────────────────────────────────────────── + +#[test] +fn test_is_npm_global_install_accepts_catalog_claude_command() { + assert!(is_npm_global_install( + "npm install -g @agentclientprotocol/claude-agent-acp" + )); +} + +#[test] +fn test_is_npm_global_install_accepts_catalog_codex_command() { + assert!(is_npm_global_install( + "npm install -g @agentclientprotocol/codex-acp" + )); +} + +#[test] +fn test_is_npm_global_install_accepts_short_flag() { + assert!(is_npm_global_install("npm i -g some-package")); +} + +#[test] +fn test_is_npm_global_install_accepts_uninstall() { + assert!(is_npm_global_install( + "npm uninstall -g @zed-industries/codex-acp" + )); +} + +#[test] +fn test_is_npm_global_install_accepts_leading_whitespace() { + assert!(is_npm_global_install(" npm install -g foo")); +} + +#[test] +fn test_is_npm_global_install_rejects_curl_pipe() { + assert!(!is_npm_global_install( + "curl -fsSL https://example.com/install.sh | bash" + )); +} + +#[test] +fn test_is_npm_global_install_rejects_non_global_install() { + assert!(!is_npm_global_install("npm install foo")); +} + +#[test] +fn test_is_npm_global_install_rejects_unrelated_command() { + assert!(!is_npm_global_install("cargo install some-tool")); +} + +// ── npm_eacces_hint ─────────────────────────────────────────────────────── + +#[test] +fn test_npm_eacces_hint_detects_old_format() { + let stderr = "npm ERR! code EACCES\nnpm ERR! syscall mkdir\nnpm ERR! path /usr/local/lib/node_modules\nnpm ERR! errno -13\nnpm ERR! Error: EACCES: permission denied, mkdir '/usr/local/lib/node_modules'"; + assert!(npm_eacces_hint(stderr, "npm install -g foo").is_some()); +} + +#[test] +fn test_npm_eacces_hint_detects_new_format() { + let stderr = "npm error EACCES: permission denied, mkdir '/usr/local/lib/node_modules'"; + assert!(npm_eacces_hint(stderr, "npm install -g foo").is_some()); +} + +#[test] +fn test_npm_eacces_hint_returns_none_for_404_stderr() { + let stderr = "npm error 404 Not Found - GET https://registry.npmjs.org/no-such-pkg"; + assert!(npm_eacces_hint(stderr, "npm install -g no-such-pkg").is_none()); +} + +// ── adapter_needs_install (codex version gate) ──────────────────────────── + +/// plan_adapter_install is the pure install-plan seam used by +/// install_acp_runtime_blocking. These tests verify: +/// - A 0.x binary (AdapterOutdated) → uninstall-then-install sequence returned +/// - A current 1.x binary (Available) → None (no reinstall) +/// - A 1.x binary below the floor → install plan returned +/// - Missing binary (None path) → catalog install commands returned +#[cfg(unix)] +#[test] +fn test_plan_adapter_install_selects_npm_command_for_outdated_0x_codex_binary() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("codex-acp"); + // Simulate old 0.16.x: --version exits non-zero (unrecognised flag) + std::fs::write(&bin, "#!/bin/sh\nexit 1\n").expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; + let plan = plan_adapter_install("codex", Some(&bin), install_cmds, Some("/usr/bin:/bin")); + + assert!( + plan.is_some(), + "0.x codex adapter must trigger install plan" + ); + let cmds = plan.unwrap(); + // Outdated arm: must uninstall the old package first, then install new. + assert_eq!( + cmds, + vec![ + "npm uninstall -g @zed-industries/codex-acp", + "npm install -g @agentclientprotocol/codex-acp", + ], + "outdated codex adapter must produce uninstall-then-install sequence; got {cmds:?}" + ); +} + +#[cfg(unix)] +#[test] +fn test_plan_adapter_install_returns_none_for_current_1x_codex_binary() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("codex-acp"); + // Simulate the minimum supported adapter version. + std::fs::write( + &bin, + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.7'\nexit 0\n", + ) + .expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; + let plan = plan_adapter_install("codex", Some(&bin), install_cmds, Some("/usr/bin:/bin")); + + assert!( + plan.is_none(), + "current codex adapter must not trigger install plan (no reinstall needed)" + ); +} + +#[cfg(unix)] +#[test] +fn test_plan_adapter_install_updates_older_1x_codex_binary() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("codex-acp"); + // A 1.x adapter below MIN_CODEX_ACP_VERSION must still be reinstalled. + std::fs::write( + &bin, + "#!/bin/sh\necho '@agentclientprotocol/codex-acp 1.1.5'\nexit 0\n", + ) + .expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; + let plan = plan_adapter_install("codex", Some(&bin), install_cmds, Some("/usr/bin:/bin")); + + assert!( + plan.is_some(), + "older 1.x codex adapter must trigger update plan" + ); +} + +#[test] +fn test_plan_adapter_install_returns_catalog_cmds_when_no_adapter_path() { + let install_cmds = &["npm install -g @agentclientprotocol/codex-acp"]; + let plan = plan_adapter_install("codex", None, install_cmds, None); + assert!(plan.is_some(), "missing adapter must trigger install plan"); + // Missing arm: use the catalog's install commands directly (no prior + // package to uninstall — fresh install, not a reinstall). + assert_eq!( + plan.unwrap(), + vec!["npm install -g @agentclientprotocol/codex-acp"], + "missing codex adapter must use catalog install commands only" + ); +} + +#[cfg(unix)] +#[test] +fn test_plan_adapter_install_non_codex_runtime_never_reinstalls() { + use std::os::unix::fs::PermissionsExt; + + // For non-codex runtimes, any resolved binary means no install needed. + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("goose-acp"); + std::fs::write(&bin, "#!/bin/sh\nexit 1\n").expect("write script"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod script"); + + let install_cmds = &["npm install -g @block/goose-acp"]; + let plan = plan_adapter_install("goose", Some(&bin), install_cmds, None); + assert!( + plan.is_none(), + "non-codex runtime with resolved binary must not trigger reinstall" + ); +} + +// ── should_restart_after_install ───────────────────────────────────────── + +/// Setup-mode agent on matching runtime that is now Ready → restart. +#[test] +fn test_should_restart_after_install_setup_mode_now_ready_is_candidate() { + assert!( + should_restart_after_install(true, true, true, true, true, false), + "setup-mode codex agent that became Ready must be restarted after install" + ); +} + +/// Setup-mode agent still NotReady after install (e.g. logged out) → no restart. +#[test] +fn test_should_restart_after_install_still_not_ready_is_not_candidate() { + assert!( + !should_restart_after_install(true, true, true, true, false, false), + "setup-mode agent still NotReady must NOT be restarted (would re-enter setup mode)" + ); +} + +/// Healthy in-pool agent (setup_mode=false) → no restart, even if now Ready. +#[test] +fn test_should_restart_after_install_healthy_agent_is_not_candidate() { + assert!( + !should_restart_after_install(true, true, true, false, true, false), + "healthy in-pool agent (setup_mode=false) must NOT be bounced on install" + ); +} + +/// Agent on a different runtime_id → no restart. +#[test] +fn test_should_restart_after_install_different_runtime_is_not_candidate() { + assert!( + !should_restart_after_install(true, true, false, true, true, false), + "agent on a different runtime must NOT be restarted by this install" + ); +} + +/// Remote/provider-backend agent → no restart (not local). +#[test] +fn test_should_restart_after_install_non_local_is_not_candidate() { + assert!( + !should_restart_after_install(false, true, true, true, true, false), + "non-local (provider-backend) agent must NOT be restarted" + ); +} + +/// Dead process (pid_alive=false) → no restart. +#[test] +fn test_should_restart_after_install_dead_pid_is_not_candidate() { + assert!( + !should_restart_after_install(true, false, true, true, true, false), + "agent whose process is no longer running must NOT be restarted" + ); +} + +/// A setup-mode agent that would otherwise be a candidate (all five prior +/// inputs true) but whose secrets are unavailable → NO restart. An +/// unavailable tier's empty hydrated env makes readiness compute `Ready`, +/// so this is the only input distinguishing a bounce that would succeed +/// from one that would stop the process and hit the spawn refusal +/// (`FailedAfterStop`). Mutation check: drop `&& !secrets_unavailable` from +/// the predicate and this test fails while the positive control stays green. +#[test] +fn test_should_restart_after_install_secrets_unavailable_is_not_candidate() { + assert!( + !should_restart_after_install(true, true, true, true, true, true), + "an otherwise-eligible setup-mode agent with unavailable secrets must NOT be bounced" + ); +} + +// ── Restart-gate fixtures ───────────────────────────────────────────────── +// +// `local_record()` is the healthy baseline for the under-lock secret-gate +// tests below and (via the ops module) the production-seam binding tests. With +// `secrets_unavailable=false` and empty personas, every tier of +// `effective_secrets_unavailable` is false. + +/// Minimal local `ManagedAgentRecord`: local backend, no persona link, no +/// harness pin. With `secrets_unavailable=false` and empty personas, every +/// tier of `effective_secrets_unavailable` is false — the healthy baseline +/// that makes the unavailable-tier assertions below meaningful. +fn local_record() -> crate::managed_agents::ManagedAgentRecord { + serde_json::from_str(&format!( + r#"{{ + "pubkey": "{}", + "name": "restart-gate-test", + "relay_url": "", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": "", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }}"#, + "aa".repeat(32) + )) + .unwrap() +} + +// ── Production-seam binding: refuse_restart_on_unavailable_secrets ──────── +// +// The under-lock recheck calls this before the stop. Drive it with real +// records so dropping the consultation turns a regression red. + +/// A healthy record passes the under-lock secret gate (Ok → proceed to stop). +#[test] +fn test_refuse_restart_on_unavailable_secrets_allows_healthy_record() { + let record = local_record(); + assert!( + refuse_restart_on_unavailable_secrets(&record, &[]).is_ok(), + "a healthy record must pass the under-lock secret gate" + ); +} + +/// An unavailable record is refused BEFORE the stop (Err). Mutation check: +/// replace the `effective_secrets_unavailable(..)` call with `false` and this +/// flips to Ok while the control above stays green — binds the under-lock call +/// site (the destructive stop-then-fail path Thufir flagged). +#[test] +fn test_refuse_restart_on_unavailable_secrets_refuses_unavailable_record() { + let mut record = local_record(); + record.secrets_unavailable = true; + let out = refuse_restart_on_unavailable_secrets(&record, &[]); + let err = out.expect_err("an unavailable record must be refused before the stop"); + assert!( + err.contains("secrets unavailable"), + "the refusal must name the secret-unavailable cause: {err}" + ); +} + +// ── Production-seam binding: refuse_restart_on_unavailable_global ───────── +// +// Both post-install sites and the global-config under-lock recheck route the +// strict global load through this seam. Bind it directly so restoring +// `unwrap_or_default()` (an Err mapped to Ok(default)) turns a regression red. + +/// A global-load Err (committed ref unhydratable) must refuse the restart +/// rather than fall through to `unwrap_or_default()` — the pre-`FailedAfterStop` +/// class Thufir found for the global tier. Mutation check: swap the seam body +/// for `Ok(global_load.unwrap_or_default())` and this fails — Ok not Err. +#[test] +fn test_refuse_restart_on_unavailable_global_refuses_on_load_error() { + let out = super::refuse_restart_on_unavailable_global(Err( + "global env_vars unavailable: gen abc123 not found in keyring".to_string(), + )); + let err = out.expect_err("an unavailable global must refuse the restart before the stop"); + assert!( + err.contains("global agent config unavailable") && err.contains("not found in keyring"), + "the refusal must explain the restart is blocked and carry the loader cause: {err}" + ); +} + +/// A successful global load passes through unchanged so the caller reuses it +/// for env resolution. Pins that only the Err arm is mapped. +#[test] +fn test_refuse_restart_on_unavailable_global_passes_through_available_global() { + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("gpt-5".to_string()), + ..Default::default() + }; + let out = super::refuse_restart_on_unavailable_global(Ok(global.clone())); + assert_eq!( + out.expect("an available global must pass through").model, + global.model, + "the seam must return the loaded config unchanged on success" + ); +} + +// ── badge availability-drift (Phase 2) ─────────────────────────────────── +// +// `availability_drift` is a pure predicate over two `Option` values — +// no global state, no parallelism hazard. + +/// Both sides known and different → drift detected. +#[test] +fn test_availability_drift_detected_when_stamped_differs_from_current() { + use crate::managed_agents::{availability_drift, AcpAvailabilityStatus}; + assert!( + availability_drift( + Some(&AcpAvailabilityStatus::Available), + Some(AcpAvailabilityStatus::AdapterOutdated), + ), + "Available stamped vs AdapterOutdated current must be detected as drift" + ); +} + +/// Both sides known and equal → no drift. +#[test] +fn test_availability_drift_no_drift_when_stamped_equals_current() { + use crate::managed_agents::{availability_drift, AcpAvailabilityStatus}; + assert!( + !availability_drift( + Some(&AcpAvailabilityStatus::Available), + Some(AcpAvailabilityStatus::Available), + ), + "matching stamped and current must not show drift" + ); +} + +/// Stamped is None (cold cache at spawn) → no drift regardless of current. +#[test] +fn test_availability_drift_none_stamp_never_drifts() { + use crate::managed_agents::{availability_drift, AcpAvailabilityStatus}; + assert!( + !availability_drift(None, Some(AcpAvailabilityStatus::Available)), + "None stamp (cold cache at spawn) must never signal drift" + ); +} + +/// Current is None (cache cold now) → no drift regardless of stamp. +#[test] +fn test_availability_drift_none_current_never_drifts() { + use crate::managed_agents::{availability_drift, AcpAvailabilityStatus}; + assert!( + !availability_drift(Some(&AcpAvailabilityStatus::Available), None), + "None current (cache cold) must never signal drift" + ); +} + +/// Non-codex agent (stamp is None) → no drift (None case). +#[test] +fn test_availability_drift_non_codex_none_never_drifts() { + use crate::managed_agents::{availability_drift, AcpAvailabilityStatus}; + // Non-codex agents have `adapter_availability = None` — must never flip. + assert!( + !availability_drift(None, Some(AcpAvailabilityStatus::AdapterMissing)), + "non-codex agent (None stamp) must never trigger drift badge" + ); +} + +// ── Phase A: install shell selection ───────────────────────────────────── + +/// On Unix, resolve_install_shell always succeeds (returns zsh or bash). +#[cfg(unix)] +#[test] +fn test_resolve_install_shell_succeeds_on_unix() { + let result = super::resolve_install_shell(); + assert!(result.is_ok(), "Unix must always resolve a shell"); + let shell = result.unwrap(); + assert!( + shell == std::path::Path::new("/bin/zsh") || shell == std::path::Path::new("/bin/bash"), + "expected /bin/zsh or /bin/bash, got {shell:?}" + ); +} + +/// install_shell_command returns a valid Command on Unix. +#[cfg(unix)] +#[test] +fn test_install_shell_command_returns_ok_on_unix() { + let result = super::install_shell_command("echo test"); + assert!(result.is_ok(), "install_shell_command must succeed on Unix"); +} + +// ── pipefail: install pipes must not mask a failing left-hand side ──────── + +/// The command handed to the install shell must run under `set -o pipefail;` +/// with the vendor command preserved verbatim, so `curl … | bash` fails when +/// `curl` does. Platform-agnostic: only the PATH prelude differs by OS, and +/// `test_install_shell_args_shape_per_platform` pins that. +#[test] +fn test_install_shell_command_enables_pipefail() { + let cmd = super::install_shell_command("curl -fsSL https://example.test/i.sh | bash") + .expect("install shell must resolve on a test host"); + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + let body = &args[2]; + assert!( + body.contains("set -o pipefail; "), + "the install body must set pipefail; got: {body}" + ); + assert!( + body.ends_with("curl -fsSL https://example.test/i.sh | bash"), + "the vendor command must be preserved verbatim; got: {body}" + ); +} + +/// The PATH prelude is emitted only where it helps, and the exact argument +/// vector is the contract: a stray trailing positional with no `$1` reader, +/// or an export whose `$1` the shell cannot split, both corrupt PATH. +/// Windows is excluded because `join_paths` is `;`-separated there while bash +/// splits PATH on `:` — and it is the platform where the inherited fallback +/// always fires. See `install_shell_args` for the full reasoning. +#[test] +fn test_install_shell_args_shape_per_platform() { + let composed = std::ffi::OsString::from("/buzz/node/bin:/usr/bin"); + let windows_composed = std::ffi::OsString::from(r"C:\buzz\node;C:\Windows\system32"); + let bare = ["-l", "-c", "set -o pipefail; echo hi"].map(std::ffi::OsString::from); + + assert_eq!( + super::install_shell_args("echo hi", Some(&composed), false), + [ + "-l", + "-c", + "export PATH=\"$1\"; set -o pipefail; echo hi", + "buzz-install", + "/buzz/node/bin:/usr/bin", + ] + .map(std::ffi::OsString::from), + "Unix must re-export the composed PATH after login init" + ); + assert_eq!( + super::install_shell_args("echo hi", Some(&windows_composed), true), + bare, + "Windows must not re-export a `;`-joined PATH inside bash" + ); + assert_eq!( + super::install_shell_args("echo hi", None, false), + bare, + "no composed PATH must yield the bare pipefail body and no positionals" + ); +} + +/// Regression for the login-startup-file overwrite: `cmd.env("PATH", …)` is +/// installed *before* `-l` sources the user's profile, so a profile that +/// assigns PATH silently discards the composed one. Uses `/bin/bash` +/// explicitly — the planted profile is bash-specific, so resolving the host +/// shell (which prefers zsh) would make this vacuous. +#[cfg(unix)] +#[test] +fn test_composed_path_survives_a_profile_that_clears_it() { + let home = tempfile::tempdir().expect("temp HOME"); + std::fs::write(home.path().join(".bash_profile"), "export PATH=\n") + .expect("plant a hostile login profile"); + let composed = std::ffi::OsString::from("/buzz/sentinel/bin:/usr/bin:/bin"); + + // `echo` is a shell builtin, so the child needs no PATH to report one. + let out = std::process::Command::new("/bin/bash") + .args(super::install_shell_args( + "echo \"$PATH\"", + Some(&composed), + false, + )) + .env("HOME", home.path()) + .env("PATH", &composed) + .stdin(std::process::Stdio::null()) + .output() + .expect("bash must spawn"); + + let path = String::from_utf8_lossy(&out.stdout); + assert!( + path.contains("/buzz/sentinel/bin"), + "the composed PATH must survive login init; got: {path:?}" + ); +} + +/// End-to-end on the real resolved install shell (no network): a pipeline +/// whose left-hand side fails must exit non-zero, while a fully successful +/// pipeline must still succeed. Without `pipefail` the status is the +/// right-hand side's and the left-hand failure is invisible. +#[cfg(unix)] +#[test] +fn test_install_shell_pipeline_status_follows_left_side() { + for (command, expect_success) in [("false | true", false), ("echo ok | cat", true)] { + let status = super::install_shell_command(command) + .expect("Unix must always resolve an install shell") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("install shell must spawn"); + assert_eq!( + status.success(), + expect_success, + "`{command}` must report success={expect_success}; got {status:?}" + ); + } +} + +// ── Phase A: Windows install shell selection ─────────────────────────────── + +/// On Windows (CI runner has Git pre-installed), resolve_install_shell succeeds. +#[cfg(windows)] +#[test] +fn test_resolve_install_shell_succeeds_on_windows_with_git() { + let result = super::resolve_install_shell(); + assert!( + result.is_ok(), + "Windows CI runner has Git — resolve_install_shell must succeed; got: {:?}", + result.err() + ); + let shell = result.unwrap(); + // The resolved path must end with bash.exe (Git Bash). + let fname = shell.file_name().and_then(|n| n.to_str()).unwrap_or(""); + assert!( + fname.eq_ignore_ascii_case("bash.exe"), + "Windows install shell must be bash.exe, got: {shell:?}" + ); +} + +/// On Windows, when no Git Bash is found, the error carries the Doctor hint. +#[cfg(windows)] +#[test] +fn test_resolve_install_shell_error_contains_doctor_hint() { + // We can't force resolve_install_shell to fail on CI (Git is installed), + // but we can verify the error string it would use matches the hint. + let hint = crate::managed_agents::git_bash::GIT_BASH_INSTALL_HINT; + assert!( + hint.contains("Git for Windows"), + "GIT_BASH_INSTALL_HINT must mention Git for Windows; got: {hint}" + ); + assert!( + hint.contains("PATH"), + "GIT_BASH_INSTALL_HINT must mention PATH option; got: {hint}" + ); +} + +/// install_shell_command returns a valid Command on Windows. +#[cfg(windows)] +#[test] +fn test_install_shell_command_returns_ok_on_windows() { + let result = super::install_shell_command("echo test"); + assert!( + result.is_ok(), + "install_shell_command must succeed on Windows with Git; got: {:?}", + result.err() + ); +} + +/// On Windows, `install_shell_command` must set PATH to a value that +/// includes the inherited process PATH, so node/npm are visible inside +/// the install shell even when no managed Node runtime is present. +#[cfg(windows)] +#[test] +fn test_install_shell_command_includes_process_path_on_windows() { + let _guard = crate::managed_agents::lock_path_mutex(); + let previous = std::env::var_os("PATH"); + // Plant a sentinel in the process PATH that the test can detect. + let sentinel = r"C:\TestSentinel\bin"; + std::env::set_var("PATH", sentinel); + + let result = super::install_shell_command("echo test"); + + match previous { + Some(p) => std::env::set_var("PATH", p), + None => std::env::remove_var("PATH"), + } + + let cmd = result.expect("install_shell_command must succeed on Windows with Git"); + let path_value = cmd + .get_envs() + .find(|(key, _)| *key == "PATH") + .and_then(|(_, val)| val) + .map(|v| v.to_string_lossy().into_owned()) + .expect("install_shell_command must always set a PATH env var on Windows"); + + // The sentinel (inherited process PATH) must appear in the composed PATH. + assert!( + path_value.contains(sentinel), + "install_shell_command PATH must include the inherited process PATH; got: {path_value}" + ); + // The sentinel must appear LAST — managed Buzz dirs must have precedence. + assert!( + path_value.ends_with(sentinel), + "inherited process PATH must be appended LAST so managed dirs keep precedence; got: {path_value}" + ); +} + +// ── Phase B: per-OS install commands ────────────────────────────────────── + +/// On non-Windows, cli_install_commands_for_os returns the default commands. +#[cfg(not(windows))] +#[test] +fn test_cli_install_commands_for_os_returns_default_on_unix() { + let claude = crate::managed_agents::known_acp_runtime_exact("claude").unwrap(); + assert_eq!( + claude.cli_install_commands_for_os(), + claude.cli_install_commands, + "on Unix, cli_install_commands_for_os must return the default install.sh commands" + ); +} + +/// buzz-agent has no install commands on any platform. +#[test] +fn test_buzz_agent_has_no_install_commands() { + let buzz = crate::managed_agents::known_acp_runtime_exact("buzz-agent").unwrap(); + assert!( + buzz.cli_install_commands_for_os().is_empty(), + "buzz-agent ships with the app — must never have install commands" + ); +} + +// ── PowerShell routing ──────────────────────────────────────────────────── + +/// Commands beginning with `powershell.exe` (any casing) must be identified +/// as PowerShell commands; all others must not. +#[cfg(windows)] +#[test] +fn test_is_powershell_command_detects_powershell_commands() { + assert!( + super::is_powershell_command( + r#"powershell.exe -NoProfile -NonInteractive -Command "irm https://chatgpt.com/codex/install.ps1 | iex""# + ), + "canonical codex install command must be detected as PowerShell" + ); + assert!( + super::is_powershell_command("POWERSHELL.EXE -Command foo"), + "is_powershell_command must be case-insensitive" + ); + assert!( + !super::is_powershell_command("npm install -g @agentclientprotocol/claude-agent-acp"), + "npm commands must NOT be detected as PowerShell" + ); + assert!( + !super::is_powershell_command(r"curl -fsSL https://example.com | bash"), + "bash pipe commands must NOT be detected as PowerShell" + ); + assert!( + !super::is_powershell_command(""), + "empty string must not be detected as PowerShell" + ); +} + +/// On Windows, `build_install_command` must return a `Command` whose +/// program is `powershell.exe` (not `bash.exe`) for PowerShell commands. +#[cfg(windows)] +#[test] +fn test_build_install_command_uses_powershell_natively_on_windows() { + let ps_command = r#"powershell.exe -NoProfile -NonInteractive -Command "irm https://chatgpt.com/codex/install.ps1 | iex""#; + let result = super::build_install_command(ps_command); + assert!( + result.is_ok(), + "build_install_command must succeed for a PowerShell command; got: {:?}", + result.err() + ); + let cmd = result.unwrap(); + let program = cmd.get_program().to_string_lossy().to_lowercase(); + assert!( + program.contains("powershell"), + "PowerShell install command must use powershell.exe, not bash; got: {program}" + ); + assert!( + !program.contains("bash"), + "PowerShell install command must NOT go through bash; got: {program}" + ); +} + +/// On Windows, `build_install_command` must route non-PowerShell commands +/// through Git Bash (program must be bash.exe). +#[cfg(windows)] +#[test] +fn test_build_install_command_uses_git_bash_for_non_powershell_on_windows() { + let npm_command = "npm install -g @agentclientprotocol/claude-agent-acp"; + let result = super::build_install_command(npm_command); + assert!( + result.is_ok(), + "build_install_command must succeed for an npm command on Windows with Git; got: {:?}", + result.err() + ); + let cmd = result.unwrap(); + let program = cmd.get_program().to_string_lossy().to_lowercase(); + assert!( + program.contains("bash"), + "non-PowerShell install command must still use bash.exe on Windows; got: {program}" + ); +} + +/// On non-Windows, `build_install_command` must always use the Unix shell +/// (zsh or bash), never powershell.exe. +#[cfg(not(windows))] +#[test] +fn test_build_install_command_uses_unix_shell_on_non_windows() { + let command = r"curl -fsSL https://example.com/install.sh | bash"; + let result = super::build_install_command(command); + assert!( + result.is_ok(), + "build_install_command must succeed on Unix; got: {:?}", + result.err() + ); + let cmd = result.unwrap(); + let program = cmd.get_program().to_string_lossy(); + assert!( + program.contains("bash") || program.contains("zsh"), + "Unix install command must use bash or zsh, got: {program}" + ); +} + +/// On Windows, `install_powershell_command` must build an exact argv: +/// flags before `-Command` forwarded, body unquoted (outer catalog quotes stripped), +/// no bash flags, and `-Command` found on token boundary not as substring. +#[cfg(windows)] +#[test] +fn test_powershell_command_argv_exact() { + // Catalog format: body wrapped in one outer double-quote pair (Bash-layer serialization). + let body = "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-codex.ps1'; Invoke-RestMethod https://chatgpt.com/codex/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE"; + let cmd = super::install_powershell_command(&format!( + r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "{body}""# + )); + assert_eq!( + cmd.get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect::>(), + vec!["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", body], + "argv must be exact with outer quotes stripped" + ); +} + +/// Token that merely contains `-command` as a substring must not be treated +/// as the `-Command` boundary; only an exact token match (case-insensitive) counts. +#[cfg(windows)] +#[test] +fn test_powershell_command_token_boundary_not_substring() { + let cmd = + super::install_powershell_command(r#"powershell.exe -x-command-y -Command "echo hello""#); + assert_eq!( + cmd.get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect::>(), + vec!["-x-command-y", "-Command", "echo hello"], + "substring must not consume -Command boundary early" + ); +} + +/// Claude Code catalog command must dequote to the two-step download-then-execute body. +#[cfg(windows)] +#[test] +fn test_powershell_command_claude_catalog_dequoted() { + let cmd = super::install_powershell_command( + r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-claude.ps1'; Invoke-RestMethod https://claude.ai/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE""#, + ); + assert_eq!( + cmd.get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect::>(), + vec![ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-claude.ps1'; Invoke-RestMethod https://claude.ai/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE", + ], + "Claude catalog command must be dequoted correctly" + ); +} + +/// Goose Windows catalog command must dequote to the two-step download-then-execute body +/// with the `$env:CONFIGURE` prefix intact — no backslash before the dollar sign. +/// This proves the `\$` → `$` contract: post-#2750 the spawn is native and +/// PowerShell receives the body verbatim, so a residual `\` would produce +/// `\$env:CONFIGURE='false'` which is a malformed statement. +#[cfg(windows)] +#[test] +fn test_powershell_command_goose_catalog_dequoted() { + let cmd = super::install_powershell_command( + r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$env:CONFIGURE='false'; $ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-goose.ps1'; Invoke-RestMethod https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE""#, + ); + assert_eq!( + cmd.get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect::>(), + vec![ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$env:CONFIGURE='false'; $ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-goose.ps1'; Invoke-RestMethod https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE", + ], + "Goose catalog command must dequote with bare $env: (no backslash before $)" + ); +} diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 183f27dba12..fdf5f4253d0 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -70,7 +70,26 @@ pub async fn get_agent_models( // so model discovery runs against the persona's current harness, not the // frozen record snapshot. An explicit per-agent override wins. let personas = load_personas(&app).unwrap_or_default(); - let global = load_global_agent_config(&app).unwrap_or_default(); + // Fail closed on unavailable effective secrets BEFORE any credentialed + // provider request or model subprocess (mirrors the spawn/deploy gates). + let global = crate::managed_agents::effective_config::require_effective_secrets_available( + record, + &personas, + load_global_agent_config(&app), + )?; + + // Harness tier: model discovery layers the harness definition's env + // (provider keys) into the probe env, so an unavailable harness env + // projection would query with a silently-incomplete env. Refuse before + // any credentialed request (mirrors the spawn/deploy harness gate). + // Card mint / profile signing do not consume harness env, so this gate + // lives here rather than in `require_effective_secrets_available`. + if let Some(hid) = crate::managed_agents::unavailable_harness_id(record, &personas) { + return Err(model_discovery_error( + &pubkey, + &format!("harness ({hid}) env could not be loaded from the keyring"), + )); + } // Single pure helper — descriptor + authoritative model/provider // resolver, packaged so the linked-agent regression test binds the @@ -368,81 +387,11 @@ fn openai_compatible_models_url_for_discovery(env: &BTreeMap) -> format!("{}/models", base_url.trim_end_matches('/')) } -fn is_agent_text_model_id(id: &str) -> bool { - let lower = id.to_ascii_lowercase(); - if [ - "audio", - "dall-e", - "embedding", - "image", - "moderation", - "realtime", - "speech", - "transcribe", - "tts", - "whisper", - ] - .iter() - .any(|needle| lower.contains(needle)) - { - return false; - } - - lower.starts_with("gpt-") || lower.starts_with('o') || lower.starts_with("chatgpt-") -} - -fn openai_dated_snapshot_alias(id: &str) -> Option { - let (base, date) = id.rsplit_once('-')?; - if date.len() != 2 || !date.chars().all(|character| character.is_ascii_digit()) { - return None; - } - let (base, month) = base.rsplit_once('-')?; - if month.len() != 2 || !month.chars().all(|character| character.is_ascii_digit()) { - return None; - } - let (base, year) = base.rsplit_once('-')?; - if year.len() != 4 || !year.chars().all(|character| character.is_ascii_digit()) { - return None; - } - - Some(base.to_string()) -} - -fn openai_model_display_name(id: &str) -> String { - let canonical = openai_dated_snapshot_alias(id).unwrap_or_else(|| id.to_string()); - if let Some(rest) = canonical.strip_prefix("chatgpt-") { - return format!("ChatGPT {}", title_case_model_suffix(rest)); - } - if let Some(rest) = canonical.strip_prefix("gpt-") { - return format!("GPT-{}", title_case_model_suffix(rest)); - } - - canonical -} - -fn title_case_model_suffix(value: &str) -> String { - value - .split('-') - .enumerate() - .map(|(index, part)| { - let part = if part.eq_ignore_ascii_case("pro") { - "Pro".to_string() - } else if part.eq_ignore_ascii_case("mini") { - "mini".to_string() - } else if part.eq_ignore_ascii_case("nano") { - "nano".to_string() - } else { - part.to_string() - }; - - if index == 0 { - part - } else { - format!(" {part}") - } - }) - .collect::() -} +/// OpenAI model-id → display-name helpers, split to a sibling to keep this +/// file under the desktop file-size ratchet. +#[path = "agent_models_naming.rs"] +mod naming; +use naming::{is_agent_text_model_id, openai_dated_snapshot_alias, openai_model_display_name}; fn normalize_openai_compatible_models( response: OpenAiModelListResponse, @@ -826,6 +775,20 @@ pub async fn update_managed_agent( record.updated_at = now_iso(); + // A rename re-publishes a signed kind:0 profile. Refuse when the + // agent's effective secrets are unavailable: a failed `auth_tag_ref` + // leaves `secrets_unavailable` set and `auth_tag` empty, so signing + // would publish WITHOUT the NIP-OA tag. Gate before the save so a + // refused rename changes neither disk nor relay. + if name_changed { + let personas = load_personas(&app).unwrap_or_default(); + crate::managed_agents::effective_config::require_effective_secrets_available( + record, + &personas, + load_global_agent_config(&app), + )?; + } + save_managed_agents(&app, &records)?; let record = records diff --git a/desktop/src-tauri/src/commands/agent_models_naming.rs b/desktop/src-tauri/src/commands/agent_models_naming.rs new file mode 100644 index 00000000000..4105589e6b4 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_naming.rs @@ -0,0 +1,79 @@ +//! OpenAI model-id → display-name helpers for `get_agent_models`, split out +//! of `agent_models.rs` to keep it under the desktop file-size ratchet. Wired +//! via `#[path = "agent_models_naming.rs"] mod naming;` and used through it. + +pub(super) fn is_agent_text_model_id(id: &str) -> bool { + let lower = id.to_ascii_lowercase(); + if [ + "audio", + "dall-e", + "embedding", + "image", + "moderation", + "realtime", + "speech", + "transcribe", + "tts", + "whisper", + ] + .iter() + .any(|needle| lower.contains(needle)) + { + return false; + } + + lower.starts_with("gpt-") || lower.starts_with('o') || lower.starts_with("chatgpt-") +} + +pub(super) fn openai_dated_snapshot_alias(id: &str) -> Option { + let (base, date) = id.rsplit_once('-')?; + if date.len() != 2 || !date.chars().all(|character| character.is_ascii_digit()) { + return None; + } + let (base, month) = base.rsplit_once('-')?; + if month.len() != 2 || !month.chars().all(|character| character.is_ascii_digit()) { + return None; + } + let (base, year) = base.rsplit_once('-')?; + if year.len() != 4 || !year.chars().all(|character| character.is_ascii_digit()) { + return None; + } + + Some(base.to_string()) +} + +pub(super) fn openai_model_display_name(id: &str) -> String { + let canonical = openai_dated_snapshot_alias(id).unwrap_or_else(|| id.to_string()); + if let Some(rest) = canonical.strip_prefix("chatgpt-") { + return format!("ChatGPT {}", title_case_model_suffix(rest)); + } + if let Some(rest) = canonical.strip_prefix("gpt-") { + return format!("GPT-{}", title_case_model_suffix(rest)); + } + + canonical +} + +pub(super) fn title_case_model_suffix(value: &str) -> String { + value + .split('-') + .enumerate() + .map(|(index, part)| { + let part = if part.eq_ignore_ascii_case("pro") { + "Pro".to_string() + } else if part.eq_ignore_ascii_case("mini") { + "mini".to_string() + } else if part.eq_ignore_ascii_case("nano") { + "nano".to_string() + } else { + part.to_string() + }; + + if index == 0 { + part + } else { + format!(" {part}") + } + }) + .collect::() +} diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 79dd7263c61..f0ff0f189d9 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -404,6 +404,7 @@ fn model_discovery_ignores_stale_record_for_linked_agent() { parallelism: None, created_at: "".to_string(), updated_at: "".to_string(), + secrets_unavailable: false, }; // agent_model_discovery_config is the single helper get_agent_models diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 26136719706..61f369895cf 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -905,6 +905,10 @@ pub async fn create_managed_agent( } else { relay_mesh.clone() }, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, }; records.push(record); @@ -1345,12 +1349,10 @@ pub async fn delete_managed_agent( .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } - // Remote agent shutdown is handled entirely by the frontend: // 1. Frontend sends "!shutdown" @mention via WebSocket (signed by user's key) // 2. Harness sees it, exits gracefully, sets presence to "offline" -// 3. Desktop's existing presence polling sees "offline" — UI updates automatically -// No backend Tauri command needed. Presence IS the status. +// 3. Desktop's existing presence polling sees "offline" — UI updates automatically. No backend command needed; presence IS the status. #[path = "agents_deploy.rs"] mod deploy; pub(super) mod provider_access; @@ -1359,13 +1361,11 @@ use deploy::build_deploy_payload; use deploy::{deploy_payload_json, DeployProjections}; #[cfg(test)] use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider}; - #[path = "agents_profile.rs"] mod profile; #[cfg(test)] use profile::{profile_needs_sync, resolve_legacy_avatar}; pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData}; - #[cfg(test)] #[path = "agents_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 47ee5f92d49..f2a97535b8c 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -130,8 +130,34 @@ pub(super) fn build_deploy_payload( return Err(err); } - let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + // Fail closed on an unresolvable global env_vars ref: refuse the deploy + // rather than shipping a payload with a silently-incomplete env (mirrors + // the `spawn_key_refusal` record-level gate above, for the global tier). + let global = crate::managed_agents::load_global_agent_config(app)?; let personas = load_personas(app).unwrap_or_default(); + // Fail closed on a definition with unavailable secrets: refuse deploy if + // the linked definition's env_vars could not be hydrated (definition tier, + // mirrors the instance-tier `spawn_key_refusal` and global-tier gates above). + if let Some(pid) = crate::managed_agents::unavailable_definition_id(record, &personas) { + return Err(format!( + "agent {} cannot be deployed: its definition ({pid}) has one or more secrets \ + that could not be loaded from the keyring. \ + Refusing to deploy with missing definition secrets; \ + retry once the keyring is reachable.", + record.pubkey + )); + } + // Fail closed on an unavailable harness env projection: the effective + // harness's `env_ref` could not be hydrated, so its definition env would + // deploy empty (harness tier, mirrors the instance/definition/global gates). + if let Some(hid) = crate::managed_agents::unavailable_harness_id(record, &personas) { + return Err(format!( + "agent {} cannot be deployed: its harness ({hid}) env could not be loaded \ + from the keyring. Refusing to deploy with missing harness env; \ + retry once the keyring is reachable.", + record.pubkey + )); + } let teams = crate::managed_agents::load_teams(app).unwrap_or_default(); let persona_env = crate::managed_agents::live_persona_env(&personas, record.persona_id.as_deref()); diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index f550a72e0c3..88f06388f41 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -62,6 +62,10 @@ fn bare_agent_record( definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition { @@ -87,6 +91,7 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen parallelism: None, created_at: "".to_string(), updated_at: "".to_string(), + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/commands/global_agent_config.rs b/desktop/src-tauri/src/commands/global_agent_config.rs index 91219bafb9c..e98246ce76f 100644 --- a/desktop/src-tauri/src/commands/global_agent_config.rs +++ b/desktop/src-tauri/src/commands/global_agent_config.rs @@ -16,11 +16,9 @@ use tauri::AppHandle; use crate::{ app_state::AppState, managed_agents::{ - agent_readiness, current_instance_id, find_managed_agent_mut, known_acp_runtime, - load_global_agent_config, load_managed_agents, load_personas, record_agent_command, - resolve_effective_agent_env, save_global_agent_config, save_managed_agents, - stop_managed_agent_process, sync_managed_agent_processes, validate_global_config, - AgentReadiness, BackendKind, GlobalAgentConfig, + current_instance_id, find_managed_agent_mut, load_global_agent_config, load_managed_agents, + load_personas, save_global_agent_config, save_managed_agents, stop_managed_agent_process, + sync_managed_agent_processes, validate_global_config, BackendKind, GlobalAgentConfig, }, }; @@ -72,9 +70,27 @@ pub async fn set_global_agent_config( // lock in Phase 2 after sync_managed_agent_processes. let app_for_write = app.clone(); let phase1 = tokio::task::spawn_blocking(move || { + use tauri::Manager; + // Serialize the whole read-modify-write (validate → snapshot old → + // save → re-read → candidate scan) against boot-time GC and the card + // mint save path, which take the same lock. Without it, GC could read + // the JSON between this save's keyring write and its atomic JSON commit + // and delete the just-written generation. `save_global_agent_config` + // itself must NOT take the lock (card mint and the boot migration hold + // it around their own save call — a second acquire would deadlock). + let state = app_for_write.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .unwrap_or_else(|e| e.into_inner()); + validate_global_config(&config)?; - let old_global = load_global_agent_config(&app_for_write).unwrap_or_default(); + // Fail closed when the CURRENTLY committed global config cannot load — + // see `refuse_save_on_unavailable_current` for why `unwrap_or_default()` + // here would orphan a live-but-dangling generation. + let old_global = + refuse_save_on_unavailable_current(load_global_agent_config(&app_for_write))?; save_global_agent_config(&app_for_write, &config)?; @@ -185,38 +201,18 @@ fn collect_restart_candidates( .lock() .unwrap_or_else(|error| error.into_inner()); - let candidates = records - .iter() - .filter(|record| { - if record.backend != BackendKind::Local { - return false; - } - let has_live_runtime = runtimes.iter_mut().any(|(key, runtime)| { + let candidates = super::restart_ops::select_config_change_restart_candidates( + &records, + &all_personas, + old_global, + new_global, + |record| { + runtimes.iter_mut().any(|(key, runtime)| { key.pubkey.eq_ignore_ascii_case(&record.pubkey) && runtime.child.try_wait().ok().flatten().is_none() - }); - if !has_live_runtime { - return false; - } - let effective_cmd = record_agent_command(record, &all_personas); - let runtime_meta = known_acp_runtime(&effective_cmd); - let old_effective = - resolve_effective_agent_env(record, &all_personas, runtime_meta, old_global); - let new_effective = - resolve_effective_agent_env(record, &all_personas, runtime_meta, new_global); - let old_ready = matches!(agent_readiness(&old_effective), AgentReadiness::Ready); - let new_ready = matches!(agent_readiness(&new_effective), AgentReadiness::Ready); - // For a Ready+running agent: the process must be alive now and the - // process-env map must differ. The alive check avoids queuing a - // restart for a process that already exited between the pre-filter - // scan and Phase 2. NotReady→Ready bypasses the alive check - // because Phase 2 will stop-then-start unconditionally. - let env_changed = old_ready && old_effective.env != new_effective.env; - - should_restart_on_config_change(old_ready, new_ready, env_changed) - }) - .map(|r| r.pubkey.clone()) - .collect(); + }) + }, + ); (candidates, all_personas) } @@ -300,32 +296,28 @@ async fn restart_local_agent_on_config_change( )); } - // Re-check the eligibility predicate under lock: - // (old NotReady && new Ready) OR (old Ready && env changed) - // TODO: busy/mid-turn deferral would slot in here - // + // Re-check eligibility and re-read the committed global strictly under + // lock, then stop — all inside `authorize_config_change_restart`, which + // owns the gate order and runs the injected stop only on full Ok. // Reuse personas_snapshot from Phase 1 — avoids loading personas again // per agent when the save-command personas haven't changed. - let effective_cmd = record_agent_command(record, &personas_owned); - let runtime_meta = known_acp_runtime(&effective_cmd); - let old_effective = - resolve_effective_agent_env(record, &personas_owned, runtime_meta, &old_global_clone); - let new_effective = - resolve_effective_agent_env(record, &personas_owned, runtime_meta, &new_global_clone); - let old_ready = matches!(agent_readiness(&old_effective), AgentReadiness::Ready); - let new_ready = matches!(agent_readiness(&new_effective), AgentReadiness::Ready); - // Under lock, the alive check was already done above via process_is_running. - let env_changed = old_ready && old_effective.env != new_effective.env; - if !should_restart_on_config_change(old_ready, new_ready, env_changed) { - return Err(format!( - "agent {pubkey_owned} restart condition no longer valid under lock" - )); - } - - // Stop the process. - let record_mut = find_managed_agent_mut(&mut records, &pubkey_owned)?; - stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?; - save_managed_agents(&app_for_stop, &records)?; + // + // The decision reads a cloned record (immutable); the stop closure + // re-finds the mutable record so the immutable-decision / mutable-stop + // borrow split stays clean. + let record = record.clone(); + super::restart_ops::authorize_config_change_restart( + &record, + &personas_owned, + &old_global_clone, + &new_global_clone, + || load_global_agent_config(&app_for_stop), + || { + let record_mut = find_managed_agent_mut(&mut records, &pubkey_owned)?; + stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?; + save_managed_agents(&app_for_stop, &records) + }, + )?; Ok(runtime_keys) }) @@ -411,20 +403,89 @@ fn persist_last_error(app: &AppHandle, pubkey: &str, error: &str) -> Result<(), /// restart would not repair the missing auth token. If the binary disappears, /// the process would already be dead and the PID alive-check in the candidate /// scan would have excluded it. -fn should_restart_on_config_change(old_ready: bool, new_ready: bool, env_changed: bool) -> bool { - (!old_ready && new_ready) || (old_ready && env_changed) +/// +/// **Fail-closed on unavailable secrets:** when any secret tier is unavailable +/// (`secrets_unavailable`) the record's empty hydrated env can look `Ready` and +/// its env can differ from the old config for unrelated reasons, so restart is +/// refused — a stop-then-respawn would stop a live process only to hit the +/// spawn refusal (`FailedAfterStop`). +pub(super) fn should_restart_on_config_change( + old_ready: bool, + new_ready: bool, + env_changed: bool, + secrets_unavailable: bool, +) -> bool { + !secrets_unavailable && ((!old_ready && new_ready) || (old_ready && env_changed)) +} + +/// Fail closed when the CURRENTLY committed global config cannot load before an +/// overwrite: its `env_vars_ref` points at a keyring entry that is missing or +/// unreadable this boot. `unwrap_or_default()` here would silently replace a +/// dangling-but-live committed ref with the caller's config, orphaning the +/// generation the ref still points at. No destructive "replace despite +/// unavailability" action is modeled, so refusing is the only correct arm — +/// retry once the keyring is reachable. +/// +/// Extracted as the AppHandle-free seam so the refusal is unit-testable (the +/// command obtains its `Result` from `load_global_agent_config(&app)`). +fn refuse_save_on_unavailable_current( + current_load: Result, +) -> Result { + current_load.map_err(|e| { + format!( + "cannot save global agent config: the current global config has \ + secrets that could not be loaded from the keyring ({e}). \ + Refusing to overwrite it while its secrets are unavailable; \ + retry once the keyring is reachable." + ) + }) } #[cfg(test)] mod tests { use super::should_restart_on_config_change; + use super::{refuse_save_on_unavailable_current, GlobalAgentConfig}; + + /// A global loader `Err` (the current committed `env_vars_ref` could not + /// hydrate) must refuse the overwrite rather than fall through to + /// `unwrap_or_default()`, which would orphan the live-but-dangling + /// generation. Mutation check: swap the seam's body for + /// `Ok(current_load.unwrap_or_default())` and this fails — `Ok` not `Err`. + #[test] + fn save_refuses_when_current_global_unavailable() { + let out = refuse_save_on_unavailable_current(Err( + "global env_vars unavailable: gen abc123 not found in keyring".to_string(), + )); + let err = out.expect_err("an unavailable current global must refuse the save"); + assert!( + err.contains("cannot save global agent config") && err.contains("not found in keyring"), + "refusal must explain the save is blocked and carry the loader cause: {err}" + ); + } + + /// A successful load passes through so the caller reuses it as `old_global` + /// for the restart-candidate scan. Pins that only the `Err` arm is mapped. + #[test] + fn save_passes_through_available_current_global() { + let current = GlobalAgentConfig { + model: Some("gpt-5".to_string()), + ..Default::default() + }; + let out = refuse_save_on_unavailable_current(Ok(current.clone())); + assert_eq!( + out.expect("an available current global must pass through") + .model, + current.model, + "the seam must return the loaded config unchanged on success" + ); + } /// Running agent (Ready) whose effective env changed → restart candidate. #[test] fn env_changed_running_agent_is_candidate() { // old_ready=true, new_ready=true, env_changed=true assert!( - should_restart_on_config_change(true, true, true), + should_restart_on_config_change(true, true, true, false), "running agent with changed env must be restarted" ); } @@ -434,7 +495,7 @@ mod tests { fn unchanged_running_agent_is_not_candidate() { // old_ready=true, new_ready=true, env_changed=false assert!( - !should_restart_on_config_change(true, true, false), + !should_restart_on_config_change(true, true, false, false), "running agent with identical env must NOT be restarted" ); } @@ -444,7 +505,7 @@ mod tests { fn not_ready_to_ready_is_candidate() { // old_ready=false, new_ready=true, env_changed=false (env_changed irrelevant) assert!( - should_restart_on_config_change(false, true, false), + should_restart_on_config_change(false, true, false, false), "NotReady → Ready must be a restart candidate" ); } @@ -455,7 +516,7 @@ mod tests { fn ready_to_not_ready_env_changed_is_candidate() { // old_ready=true (had key), new_ready=false (key removed), env_changed=true assert!( - should_restart_on_config_change(true, false, true), + should_restart_on_config_change(true, false, true, false), "Ready → NotReady with env change must be a restart candidate" ); } @@ -465,7 +526,7 @@ mod tests { fn both_not_ready_unchanged_is_not_candidate() { // old_ready=false, new_ready=false, env_changed=false assert!( - !should_restart_on_config_change(false, false, false), + !should_restart_on_config_change(false, false, false, false), "both NotReady with no env change must NOT be a candidate" ); } @@ -476,7 +537,7 @@ mod tests { // Changed one unrelated env var but still missing the required key. // old_ready=false, new_ready=false, env_changed=true assert!( - !should_restart_on_config_change(false, false, true), + !should_restart_on_config_change(false, false, true, false), "NotReady→NotReady (env changed but still broken) must NOT be a candidate" ); } @@ -490,8 +551,28 @@ mod tests { fn not_ready_to_ready_with_env_change_is_candidate() { // old_ready=false, new_ready=true, env_changed=true assert!( - should_restart_on_config_change(false, true, true), + should_restart_on_config_change(false, true, true, false), "NotReady → Ready (with env change) must be a restart candidate" ); } + + /// An agent that would otherwise be a restart candidate (`NotReady → Ready`, + /// or Ready with env changed) but whose secrets are unavailable → NO + /// restart. An unavailable tier's empty hydrated env can make readiness + /// compute `Ready` and can differ from the old config for unrelated + /// reasons, so this flag is the sole guard against stopping a live process + /// only to hit the spawn refusal (`FailedAfterStop`). Mutation check: drop + /// the leading `!secrets_unavailable &&` and both asserts below flip while + /// the healthy-restart controls above stay green. + #[test] + fn secrets_unavailable_is_never_a_candidate() { + assert!( + !should_restart_on_config_change(false, true, false, true), + "an unblocked (NotReady → Ready) agent with unavailable secrets must NOT be restarted" + ); + assert!( + !should_restart_on_config_change(true, true, true, true), + "a running agent with changed env but unavailable secrets must NOT be restarted" + ); + } } diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index ec2357b85e7..7087b27d82b 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -362,12 +362,21 @@ pub async fn import_identity( let key_path = data_dir.join("identity.key"); let (pubkey, storage) = commit_imported_identity(&state, &data_dir, keys, |keys| { + // Serialize the shared-keyring blob write against a concurrent agent + // save/GC on the same SecretStore through the lock-owning + // `persist_identity_locked` seam: it acquires the cross-process + // secret transaction lock (the same store-directory inode every + // agent save takes) and persists under it, so identity and agent + // secrets can never interleave on the shared blob. Held across the + // persist span only — the in-memory key swap that follows this + // closure touches no keyring blob. + let lock_dir = crate::managed_agents::storage::secret_txn_lock_dir(&app_handle)?; // Persist into the OS keyring first (store → read-back verify → // marker → delete file). Falls back to the 0o600 file when the // keyring is unavailable; returns Err only when both backends fail. let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); - crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir) + persist_identity_locked(store, keys, &key_path, &data_dir, &lock_dir) })?; let pubkey_hex = pubkey.to_hex(); @@ -451,6 +460,33 @@ pub(crate) fn commit_imported_identity( Ok((pubkey, storage)) } +/// Lock-owning identity-persist seam: acquire the cross-process secret +/// transaction lock on `lock_dir`, then persist the identity under it. This is +/// the single seam every identity-persist entry point (`import_identity`, +/// `persist_current_identity`, and the pairing recover path) routes through — +/// so the lock and the keyring blob write it protects are one unit. +/// +/// Identity and agent secrets share one `SecretStore` blob, so an unserialized +/// identity persist could interleave with a concurrent agent save/GC projection +/// transaction on the same blob. The lock target is the store **directory +/// inode** ([`crate::secret_store::store_txn_lock_dir`]) — the same one every +/// agent save takes — so the two surfaces mutually exclude. Held across the +/// persist span only; the in-memory key swap that follows touches no blob. +/// +/// AppHandle-free (takes `store` and `lock_dir` directly) so the interleave +/// regression can drive this exact seam: delete the acquisition here and the +/// concurrent-save probe acquires instead of reporting exclusion. +pub(crate) fn persist_identity_locked( + store: &S, + keys: &Keys, + legacy_path: &std::path::Path, + data_dir: &std::path::Path, + lock_dir: &std::path::Path, +) -> Result { + let _txn = crate::secret_store::transaction_lock_at(lock_dir)?; + crate::app_state::persist_imported_identity_impl(store, keys, legacy_path, data_dir) +} + /// Make the current ephemeral identity durable by persisting it to the OS /// keyring (or falling back to identity.key). This is called when the user /// chooses to start a new identity instead of re-importing their previous one @@ -494,8 +530,14 @@ pub async fn persist_current_identity( let key_path = data_dir.join("identity.key"); let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); - let storage = - crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; + let storage = { + // Serialize the shared-keyring blob write against a concurrent agent + // save/GC on the same SecretStore through the lock-owning + // `persist_identity_locked` seam. Scoped to the persist span only — + // the identity_lost clear that follows touches no keyring blob. + let lock_dir = crate::managed_agents::storage::secret_txn_lock_dir(&app_handle)?; + persist_identity_locked(store, &keys, &key_path, &data_dir, &lock_dir)? + }; // Keys are already the live identity. Record where the durable write // landed before clearing identity_lost. @@ -788,3 +830,7 @@ mod nostr_identity_binding_tests { #[cfg(test)] #[path = "identity_key_backup_tests.rs"] mod identity_key_backup_tests; + +#[cfg(all(test, unix, feature = "system-keyring"))] +#[path = "identity_txn_lock_tests.rs"] +mod identity_txn_lock_tests; diff --git a/desktop/src-tauri/src/commands/identity_txn_lock_tests.rs b/desktop/src-tauri/src/commands/identity_txn_lock_tests.rs new file mode 100644 index 00000000000..0eb8624ecc3 --- /dev/null +++ b/desktop/src-tauri/src/commands/identity_txn_lock_tests.rs @@ -0,0 +1,160 @@ +//! W6-A interleave coverage — driven through the PRODUCTION lock-owning seam. +//! +//! The identity-persist entry points (`import_identity`, +//! `persist_current_identity`, and the pairing recover path) all route through +//! `crate::commands::identity::persist_identity_locked`, which acquires the same +//! cross-process secret transaction lock a concurrent agent save/GC holds +//! (`transaction_lock_at(store_txn_lock_dir(store_file))`) and then persists the +//! identity under it. So an identity keyring write can no longer interleave with +//! a projection transaction on the shared `SecretStore` blob. +//! +//! This drives that seam directly instead of hand-building a lock span: the +//! injected store pauses the persist mid-keyring-write, and while it is paused a +//! concurrent agent-save projection span (an independent open file description, +//! exactly what a second process or a parallel save has) must be excluded. +//! Delete the acquisition from `persist_identity_locked` and the probe acquires +//! the lock instead of reporting exclusion — this test goes red. + +use crate::app_state::{IdentityKeyStore, IdentityStorage}; +use crate::secret_store::KeyringProbe; +use std::collections::HashMap; +use std::sync::mpsc; + +/// An `IdentityKeyStore` that pauses the persist exactly once, inside `store`, +/// so an observer can prove `persist_identity_locked` holds the transaction lock +/// across the keyring write. `store` runs after the seam acquires the lock and +/// before it releases, so the pause is squarely inside the protected span. +struct BlockingIdentityStore { + slot: std::cell::RefCell>, + entered: mpsc::Sender<()>, + release: mpsc::Receiver<()>, + tripped: std::cell::Cell, +} + +impl BlockingIdentityStore { + fn new(entered: mpsc::Sender<()>, release: mpsc::Receiver<()>) -> Self { + Self { + slot: std::cell::RefCell::new(HashMap::new()), + entered, + release, + tripped: std::cell::Cell::new(false), + } + } +} + +impl IdentityKeyStore for BlockingIdentityStore { + fn probe(&self, _name: &str) -> KeyringProbe { + KeyringProbe::ReachableButEmpty + } + fn load(&self, name: &str) -> Result, String> { + Ok(self.slot.borrow().get(name).cloned()) + } + fn store(&self, name: &str, value: &str) -> Result<(), String> { + // Pause once, under the seam's held lock, so the observer's non-blocking + // probe reports EWOULDBLOCK. Store afterward so the read-back verify and + // the rest of the persist succeed (→ SystemKeyring). + if !self.tripped.replace(true) { + self.entered.send(()).expect("signal persist entered store"); + self.release.recv().expect("observer released the persist"); + } + self.slot + .borrow_mut() + .insert(name.to_string(), value.to_string()); + Ok(()) + } + fn delete(&self, name: &str) -> Result<(), String> { + self.slot.borrow_mut().remove(name); + Ok(()) + } + fn verify_stored(&self, name: &str, expected: &str) -> Result { + Ok(self.slot.borrow().get(name).map(String::as_str) == Some(expected)) + } +} + +/// While `persist_identity_locked` holds the transaction lock across its keyring +/// write, a concurrent agent-save projection span (an independent open file +/// description) must be excluded, and may acquire only after the persist span +/// releases. +/// +/// Deterministic by handshake: the persist span holds the lock until the +/// injected store's paused `store` call is explicitly released — no timing +/// sleeps. The probe runs while the persist is paused, so exclusion is the +/// seam's own acquisition. +#[test] +fn test_persist_identity_locked_excludes_concurrent_agent_save_on_shared_txn_lock() { + use crate::secret_store::store_txn_lock_dir; + use std::os::unix::io::AsRawFd; + + // A store directory shared by identity and agent secrets — the same file + // `managed_agents_store_path` yields and the seam resolves its lock from. + let dir = std::env::temp_dir().join(format!("buzz-w6a-interleave-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create store dir"); + let store_file = dir.join("managed-agents.json"); + std::fs::write(&store_file, b"[]").expect("seed store file"); + let data_dir = dir.clone(); + let key_path = dir.join("identity.key"); + + // The crux of W6-A: the persist seam derives its lock target from the store + // FILE via `store_txn_lock_dir`, so identity persist and agent save contend + // on one directory inode instead of racing on the shared keyring blob. + let lock_dir = store_txn_lock_dir(&store_file); + + let keys = nostr::Keys::generate(); + let (entered_tx, entered_rx) = mpsc::channel::<()>(); + let (release_tx, release_rx) = mpsc::channel::<()>(); + + // Identity-persist span: run the PRODUCTION seam on a worker; its injected + // store pauses inside the keyring write, holding the lock until released. + let persist_lock_dir = lock_dir.clone(); + let persist = std::thread::spawn(move || { + let store = BlockingIdentityStore::new(entered_tx, release_rx); + let storage = crate::commands::identity::persist_identity_locked( + &store, + &keys, + &key_path, + &data_dir, + &persist_lock_dir, + ) + .expect("identity persist under the txn lock"); + assert_eq!( + storage, + IdentityStorage::SystemKeyring, + "the blocking store persists successfully to the keyring" + ); + }); + + entered_rx + .recv() + .expect("persist reached its keyring write under the lock"); + + // Agent-save projection span: a non-blocking acquire on an independent OFD + // must fail while the persist seam holds the lock. + let probe = std::fs::File::open(&lock_dir).expect("agent save opens store dir"); + let rc = unsafe { libc::flock(probe.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if rc == 0 { + // Acquired: the seam is NOT holding the lock (regression). Release the + // persist so the thread can exit, then fail with a clear message. + unsafe { libc::flock(probe.as_raw_fd(), libc::LOCK_UN) }; + release_tx.send(()).expect("release persist span"); + persist.join().expect("identity persist thread"); + let _ = std::fs::remove_dir_all(&dir); + panic!("an agent save acquired the txn lock while identity persist should have held it"); + } + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EWOULDBLOCK), + "exclusion must report EWOULDBLOCK" + ); + drop(probe); + + // Hand back: persist releases, and the save span acquires through the real + // blocking API only after release. + release_tx.send(()).expect("release identity persist span"); + persist.join().expect("identity persist thread"); + + let save_guard = crate::secret_store::transaction_lock_at(&lock_dir) + .expect("agent save acquires txn lock after release"); + drop(save_guard); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 52473716465..715508cb1fa 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -58,6 +58,7 @@ mod project_terminal; mod qr_download; mod relay_members; mod relay_reconnect; +mod restart_ops; mod social; mod team_snapshot; mod teams; diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index aedd67854c1..1acc2239b27 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -482,9 +482,18 @@ async fn import_recovered_identity( std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; let key_path = data_dir.join("identity.key"); crate::commands::identity::commit_imported_identity(&state, &data_dir, keys, |keys| { + // Serialize the shared-keyring blob write against a concurrent + // agent save/GC on the same SecretStore through the lock-owning + // `persist_identity_locked` seam, held across the persist span + // only. Lock order on this path is identity_mutation → pairing + // generation_fence → txn; no save/GC path touches either of the + // first two, so there is no inversion. + let lock_dir = crate::managed_agents::storage::secret_txn_lock_dir(&app)?; let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); - crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir) + crate::commands::identity::persist_identity_locked( + store, keys, &key_path, &data_dir, &lock_dir, + ) })?; Ok(()) }) diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 14c7c196b2b..9e0da6a5955 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -262,55 +262,12 @@ pub fn load_agent_card(stored_file_name: String, app: AppHandle) -> Result, - persona_env: &std::collections::BTreeMap, - record_env: &std::collections::BTreeMap, - process_value: Option, -) -> Option { - for layer in [record_env, persona_env, global_env] { - if let Some(v) = layer.get(key) { - let v = v.trim(); - if !v.is_empty() { - return Some(v.to_string()); - } - } - } - process_value.filter(|k| !k.trim().is_empty()) -} - -/// Pure classification: same four env inputs as `resolve_env_from_layers`, -/// returns which layer supplies `OPENAI_API_KEY` (agent > persona > global > -/// process > none). -pub(crate) fn resolve_key_layer( - global_env: &std::collections::BTreeMap, - persona_env: &std::collections::BTreeMap, - record_env: &std::collections::BTreeMap, - process_value: Option, -) -> &'static str { - let key = "OPENAI_API_KEY"; - let nonempty = |m: &std::collections::BTreeMap| { - m.get(key).is_some_and(|v| !v.trim().is_empty()) - }; - if nonempty(record_env) { - return "agent"; - } - if nonempty(persona_env) { - return "persona"; - } - if nonempty(global_env) { - return "global"; - } - let proc = process_value.as_deref().unwrap_or(""); - if !proc.trim().is_empty() { - return "process"; - } - "none" -} +/// Pure env-layer key resolution (`resolve_env_from_layers`, +/// `resolve_key_layer`), split to a sibling to keep this file under the +/// desktop file-size ratchet. +#[path = "card/key_layers.rs"] +mod key_layers; +pub(crate) use key_layers::{resolve_env_from_layers, resolve_key_layer}; /// The Responses endpoint to post mints to. `OPENAI_BASE_URL` (same env /// layering as the key) overrides the default host, supporting endpoints and @@ -554,8 +511,14 @@ pub async fn mint_agent_card( let (record, is_definition) = resolve_from_lists(&id, &instances, &definitions).map(|(r, d)| (r.clone(), d))?; - let global = load_global_agent_config(&app).unwrap_or_default(); let personas = load_personas(&app).unwrap_or_default(); + // Fail closed on unavailable effective secrets before the OpenAI key + // resolution, owner signing, and the bearer-auth API spend. + let global = crate::managed_agents::effective_config::require_effective_secrets_available( + &record, + &personas, + load_global_agent_config(&app), + )?; let persona_env = record .persona_id .as_deref() diff --git a/desktop/src-tauri/src/commands/personas/card/key_layers.rs b/desktop/src-tauri/src/commands/personas/card/key_layers.rs new file mode 100644 index 00000000000..3eef7498b2c --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/card/key_layers.rs @@ -0,0 +1,53 @@ +//! Pure env-layer key resolution for card minting, split out of `card.rs` +//! to keep it under the desktop file-size ratchet. Wired via +//! `#[path = "card/key_layers.rs"] mod key_layers;` and re-exported. + +use std::collections::BTreeMap; + +/// Pure layering: global env < persona env < agent record env, then the +/// process environment as a development fallback. Returns the first +/// non-empty value for `key`. +pub(crate) fn resolve_env_from_layers( + key: &str, + global_env: &BTreeMap, + persona_env: &BTreeMap, + record_env: &BTreeMap, + process_value: Option, +) -> Option { + for layer in [record_env, persona_env, global_env] { + if let Some(v) = layer.get(key) { + let v = v.trim(); + if !v.is_empty() { + return Some(v.to_string()); + } + } + } + process_value.filter(|k| !k.trim().is_empty()) +} + +/// Pure classification: same four env inputs as `resolve_env_from_layers`, +/// returns which layer supplies `OPENAI_API_KEY` (agent > persona > global > +/// process > none). +pub(crate) fn resolve_key_layer( + global_env: &BTreeMap, + persona_env: &BTreeMap, + record_env: &BTreeMap, + process_value: Option, +) -> &'static str { + let key = "OPENAI_API_KEY"; + let nonempty = |m: &BTreeMap| m.get(key).is_some_and(|v| !v.trim().is_empty()); + if nonempty(record_env) { + return "agent"; + } + if nonempty(persona_env) { + return "persona"; + } + if nonempty(global_env) { + return "global"; + } + let proc = process_value.as_deref().unwrap_or(""); + if !proc.trim().is_empty() { + return "process"; + } + "none" +} diff --git a/desktop/src-tauri/src/commands/personas/create.rs b/desktop/src-tauri/src/commands/personas/create.rs index 944013029b8..592616df56e 100644 --- a/desktop/src-tauri/src/commands/personas/create.rs +++ b/desktop/src-tauri/src/commands/personas/create.rs @@ -75,6 +75,7 @@ pub async fn create_persona( parallelism: None, created_at: now.clone(), updated_at: now, + secrets_unavailable: false, }; apply_persona_behavior(&mut persona, input.behavior)?; personas.push(persona.clone()); diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 8ff7cfbd9bd..67dd4d6475a 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -70,6 +70,10 @@ fn make_agent( definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index e65973f1493..64ada923b1b 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -30,6 +30,7 @@ fn local_in_app() -> AgentDefinition { parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), + secrets_unavailable: false, } } @@ -57,6 +58,7 @@ fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition { parallelism: None, created_at: "2025-06-01T00:00:00Z".to_string(), updated_at: "2025-06-01T00:00:00Z".to_string(), + secrets_unavailable: false, } } @@ -215,6 +217,10 @@ fn local_agent() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index 89f2d1519ec..99874dc37a6 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -280,6 +280,7 @@ mod tests { parallelism: None, created_at: "2026-07-27T00:00:00Z".to_string(), updated_at: "2026-07-27T00:00:00Z".to_string(), + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/commands/personas/sharing.rs b/desktop/src-tauri/src/commands/personas/sharing.rs index 914c56252d0..846ebbdbdcd 100644 --- a/desktop/src-tauri/src/commands/personas/sharing.rs +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -163,6 +163,7 @@ mod tests { parallelism: None, created_at: "2026-07-27T00:00:00Z".to_string(), updated_at: "2026-07-27T00:00:00Z".to_string(), + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e7bd1597e63..7f62a114afb 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -190,6 +190,32 @@ fn parse_format_is_png(s: &str) -> Result { } } +/// Resolve the global config for a snapshot export, failing closed when its +/// committed `env_vars_ref` cannot be hydrated. +/// +/// Extracted from [`materialize_snapshot_bytes`] as the AppHandle-free seam so +/// the refusal can be driven to `Err` in a unit test (the command itself only +/// obtains its `Result` from `load_global_agent_config(&app)`, which needs a +/// full app). A snapshot is a verbatim portable copy of the effective runtime, +/// provider, and model configuration. `unwrap_or_default()` would coerce a +/// loader `Err` into an all-empty config and then materialize empty +/// runtime/provider/model defaults for an agent that inherits them — silently +/// shipping a snapshot that is NOT the documented verbatim copy and imports +/// with different behavior. Refuse export/send instead; retry once the keyring +/// is reachable. +fn resolve_snapshot_global( + global_load: Result, +) -> Result { + global_load.map_err(|e| { + format!( + "cannot export this agent snapshot: the global agent config has \ + secrets that could not be loaded from the keyring ({e}), so the \ + snapshot's inherited runtime/provider/model defaults cannot be \ + resolved faithfully. Retry once the keyring is reachable." + ) + }) +} + fn materialize_portable_runtime_defaults( record: &mut ManagedAgentRecord, global: &crate::managed_agents::GlobalAgentConfig, @@ -254,7 +280,12 @@ pub(crate) async fn materialize_snapshot_bytes( // provider, and model configuration, not a pointer to the sender's // machine-wide defaults. This does not translate or substitute values // for a different recipient setup. - let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(); + // + // Fail closed when the global config cannot load — see + // `resolve_snapshot_global` for why `unwrap_or_default()` would ship a + // silently-degraded snapshot instead. + let global = + resolve_snapshot_global(crate::managed_agents::load_global_agent_config(&app))?; materialize_portable_runtime_defaults(&mut def_record, &global); let memory_pubkey = if memory_level != MemoryLevel::None { diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index b769d74d7bb..50fc02ea056 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -64,6 +64,10 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } @@ -121,6 +125,69 @@ fn inherited_runtime_provider_and_model_are_materialized_for_export() { assert_eq!(record.model.as_deref(), Some("databricks-gpt-5-6-sol")); } +/// Documents WHY `materialize_snapshot_bytes` must fail closed on a global +/// loader `Err` instead of `unwrap_or_default()`: with an all-empty +/// `default()` global (the value `unwrap_or_default()` would substitute for an +/// unreadable committed ref), an inheriting record materializes EMPTY +/// runtime/provider/model — silently dropping the inherited config and +/// breaking the "verbatim portable copy" contract. The command's `?` on the +/// mapped loader error is what prevents this degraded snapshot from ever being +/// built. +#[test] +fn empty_default_global_would_drop_inherited_config_hence_export_must_refuse() { + let mut record = make_definition("wren"); + // record inherits everything (no explicit runtime/provider/model). + let degraded_global = crate::managed_agents::GlobalAgentConfig::default(); + + materialize_portable_runtime_defaults(&mut record, °raded_global); + + assert!( + record.runtime.is_none() && record.provider.is_none() && record.model.is_none(), + "an all-empty default global materializes empty inherited config — the \ + exact silent degradation `materialize_snapshot_bytes` now refuses by \ + propagating the loader Err instead of unwrap_or_default()" + ); +} + +/// The refusal path itself: a global loader `Err` (a committed `env_vars_ref` +/// that could not hydrate) must propagate as a snapshot refusal — the export +/// never reaches `materialize_portable_runtime_defaults` with a degraded +/// config. This is the seam `materialize_snapshot_bytes` consumes; the test +/// above proves the degradation such a fall-through would cause, this one +/// proves the fall-through cannot happen. Mutation check: replace the seam's +/// body with `global_load.or_else(|_| Ok(Default::default()))` (the +/// `unwrap_or_default()` regression) and this fails — `Ok` instead of `Err`. +#[test] +fn snapshot_global_loader_err_refuses_export() { + let out = resolve_snapshot_global(Err( + "global env_vars unavailable: gen abc123 not found in keyring".to_string(), + )); + let err = out.expect_err("a global loader Err must refuse the snapshot export"); + assert!( + err.contains("cannot export this agent snapshot") && err.contains("not found in keyring"), + "refusal must explain the export is blocked and carry the loader cause: {err}" + ); +} + +/// The happy path: a successful load passes through untouched so the caller +/// reuses it for `materialize_portable_runtime_defaults`. Pins that the seam +/// only maps the `Err` arm and never rewrites a good config. +#[test] +fn snapshot_global_loader_ok_passes_through() { + let global = crate::managed_agents::GlobalAgentConfig { + preferred_runtime: Some("goose".to_string()), + provider: Some("databricks_v2".to_string()), + model: Some("databricks-gpt-5-6-sol".to_string()), + ..Default::default() + }; + let out = resolve_snapshot_global(Ok(global.clone())); + assert_eq!( + out.expect("a successful load must pass through").model, + global.model, + "the seam must return the loaded global unchanged on success" + ); +} + #[test] fn explicit_runtime_provider_and_model_win_over_global_defaults() { let mut record = make_definition("wren"); diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d7f0323304b..cc168a37c93 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -584,6 +584,7 @@ pub async fn confirm_agent_snapshot_import( parallelism: minted_parallelism, created_at: now.clone(), updated_at: now.clone(), + secrets_unavailable: false, }; personas.push(persona.clone()); @@ -592,8 +593,7 @@ pub async fn confirm_agent_snapshot_import( // Enqueue the kind:30175 persona event via the retention path. super::super::pending::retain_persona_pending(&app, &state, &persona); - // Build the managed agent record — no machine-local commands, no - // secrets, no lineage from the snapshot. + // Build the managed agent record — no machine-local commands, no secrets, no lineage. let record = ManagedAgentRecord { pubkey: pubkey.clone(), name: display_name.clone(), @@ -654,6 +654,10 @@ pub async fn confirm_agent_snapshot_import( relay_mesh: None, runtime: snapshot.definition.runtime.clone(), name_pool: snapshot.definition.name_pool.clone(), + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, }; records.push(record.clone()); @@ -980,10 +984,8 @@ mod import_avatar_tests { |_| async { Err("relay upload failed".to_string()) }, ) .await; - assert_eq!(result.unwrap_err(), "relay upload failed"); } - #[tokio::test] async fn malformed_inline_avatar_fails_before_upload() { let result = diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index c453b09a9de..9ec7d47ed2f 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -73,6 +73,10 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index b3830e62b52..d5f70eb2f82 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -7,9 +7,10 @@ use tauri::AppHandle; use crate::{ app_state::AppState, managed_agents::{ - apply_persona_behavior, effective_agent_command, load_managed_agents, load_personas, - managed_agent_avatar_url, save_managed_agents, save_personas, try_regenerate_nest, - validate_agent_definition_text, AgentDefinition, ManagedAgentRecord, UpdatePersonaRequest, + apply_persona_behavior, effective_agent_command, load_global_agent_config, + load_managed_agents, load_personas, managed_agent_avatar_url, save_managed_agents, + save_personas, try_regenerate_nest, validate_agent_definition_text, AgentDefinition, + ManagedAgentRecord, UpdatePersonaRequest, }, util::now_iso, }; @@ -145,6 +146,11 @@ pub(super) async fn update_persona_with( let mut params: ProfileSyncParams = Vec::new(); let mut agents_modified = false; let workspace_relay = crate::relay::relay_ws_url_with_override(&state); + // Loaded once for the per-instance signing gate below. `global` + // is the strict loader Result — cloned per instance because the + // gate consumes it by value. + let personas = load_personas(&app).unwrap_or_default(); + let global = load_global_agent_config(&app); // Propagate the display_name rename to instances that still // carry the old definition display_name (pool-named instances @@ -187,7 +193,27 @@ pub(super) async fn update_persona_with( if record_changed { agents_modified = true; - if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) { + // Refuse to publish a signed profile for an instance + // whose effective secrets are unavailable: a failed + // `auth_tag_ref` hydration leaves the in-memory + // `auth_tag` empty, so signing would strip the NIP-OA + // tag. Skip only this instance — one stale record must + // not block the rest of the persona rename. The local + // record edits above still persist, so this instance's + // profile converges on its next successful start/reconcile + // (once its secrets hydrate), not merely on a reachable boot. + let secrets_ok = + crate::managed_agents::effective_config::require_effective_secrets_available( + record, + &personas, + global.clone(), + ); + if let Err(e) = secrets_ok { + eprintln!( + "buzz-desktop: skipping relay profile sync for {}: {e}", + record.pubkey + ); + } else if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) { let relay_url = crate::relay::effective_agent_relay_url( &record.relay_url, &workspace_relay, diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index c60215ae4dd..a67be5122c3 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -58,6 +58,10 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/commands/restart_ops.rs b/desktop/src-tauri/src/commands/restart_ops.rs new file mode 100644 index 00000000000..634ec2e1f81 --- /dev/null +++ b/desktop/src-tauri/src/commands/restart_ops.rs @@ -0,0 +1,224 @@ +//! Restart-authorization operations for the two auto-restart flows +//! (`install_acp_runtime` post-install bounce and `set_global_agent_config`). +//! +//! Each flow has a pre-scan that selects candidate pubkeys without holding the +//! store lock, and an under-lock step that re-authorizes a single agent and +//! then stops its process. Both the candidate selection and the +//! authorize-before-stop decision live here as AppHandle-free operations with +//! **injected dependencies**: the global-config loader is a closure the op +//! invokes itself, per-record runtime state is a closure, and the stop is an +//! injected effect. The production async flows in `agent_discovery.rs` and +//! `global_agent_config.rs` are thin shells that acquire locks, load records, +//! and supply those closures. +//! +//! Injecting the loader (rather than a pre-computed `Result`) is what binds the +//! strict-global gates: the op invokes the loader and maps its `Err` to a +//! refusal, so a test can drive the failure path and a stop spy observes that +//! no stop fired. A shell that passed a snapshot would leave the strict choice +//! untested. Injecting the stop effect binds the authorize-before-stop ordering +//! and every pre-stop gate: dropping any gate lets the spy record a stop that +//! should have been refused. + +use crate::managed_agents::{ + agent_readiness, effective_secrets_unavailable, known_acp_runtime, record_agent_command, + resolve_effective_agent_env, AgentDefinition, AgentReadiness, BackendKind, GlobalAgentConfig, + ManagedAgentRecord, +}; + +use super::agent_discovery::{ + refuse_restart_on_unavailable_global, refuse_restart_on_unavailable_secrets, + should_restart_after_install, +}; +use super::global_agent_config::should_restart_on_config_change; + +/// Select the pubkeys of setup-mode agents to bounce after an adapter install. +/// +/// Owns the strict-global gate (via `load_global`) and the eligibility +/// predicate including the `effective_secrets_unavailable` consultation. An +/// unreadable committed global yields **zero candidates** for the scan, because +/// a restart authorized now would stop a live process only to hit the strict +/// reload at spawn (`FailedAfterStop`). `runtime_state` returns +/// `(pid_alive, setup_mode)` for a record from the caller's runtimes map. +pub(super) fn select_post_install_restart_candidates( + records: &[ManagedAgentRecord], + personas: &[AgentDefinition], + runtime_id: &str, + load_global: impl FnOnce() -> Result, + mut runtime_state: impl FnMut(&ManagedAgentRecord) -> (bool, bool), +) -> Vec { + let global = match refuse_restart_on_unavailable_global(load_global()) { + Ok(global) => global, + Err(e) => { + eprintln!("buzz-desktop: install_acp_runtime: skipping restart scan — {e}"); + return Vec::new(); + } + }; + + records + .iter() + .filter(|record| { + let is_local = record.backend == BackendKind::Local; + let effective_cmd = record_agent_command(record, personas); + let runtime_meta = known_acp_runtime(&effective_cmd); + let runtime_matches = runtime_meta.is_some_and(|r| r.id == runtime_id); + let (pid_alive, setup_mode) = runtime_state(record); + let effective = resolve_effective_agent_env(record, personas, runtime_meta, &global); + let now_ready = matches!(agent_readiness(&effective), AgentReadiness::Ready); + should_restart_after_install( + is_local, + pid_alive, + runtime_matches, + setup_mode, + now_ready, + effective_secrets_unavailable(record, personas), + ) + }) + .map(|r| r.pubkey.clone()) + .collect() +} + +/// Re-authorize a single post-install bounce under the store lock and, only if +/// every gate passes, run the injected `stop` effect. +/// +/// Gate order mirrors the production flow: strict global load (via +/// `load_global`), runtime-match, setup-mode, readiness, then the under-lock +/// secret-availability refusal — all **before** the stop. Any `Err` short- +/// circuits before `stop` runs, so a stop spy never records a bounce that +/// should have been refused. +pub(super) fn authorize_post_install_restart( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + runtime_id: &str, + setup_mode: bool, + load_global: impl FnOnce() -> Result, + stop: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + // Strict global load under lock: `spawn_agent_child` reloads it strictly, + // so an unreadable global here means the respawn would refuse after we + // already stopped the process. Refuse before the stop. + let global = refuse_restart_on_unavailable_global(load_global())?; + + let effective_cmd = record_agent_command(record, personas); + let runtime_meta = known_acp_runtime(&effective_cmd); + if runtime_meta.is_none_or(|r| r.id != runtime_id) { + return Err(format!( + "agent {} runtime no longer matches {runtime_id} under lock", + record.pubkey + )); + } + if !setup_mode { + return Err(format!( + "agent {} is not in setup mode under lock — skipping", + record.pubkey + )); + } + + let effective = resolve_effective_agent_env(record, personas, runtime_meta, &global); + if !matches!(agent_readiness(&effective), AgentReadiness::Ready) { + return Err(format!( + "agent {} readiness is still NotReady after install — not bouncing", + record.pubkey + )); + } + + // Fail-closed under lock: an unavailable secret tier makes the empty + // hydrated env look Ready, so re-check before the stop (never after). + refuse_restart_on_unavailable_secrets(record, personas)?; + + stop() +} + +/// Select the pubkeys of running local agents to bounce after a global-config +/// change. +/// +/// Owns the eligibility predicate including the `effective_secrets_unavailable` +/// consultation. `is_live` reports whether the record still has a live pair +/// runtime (the caller checks its runtimes map). The phase-1 `old_global` / +/// `new_global` snapshots drive the readiness comparison; the committed-global +/// availability gate lives in the under-lock step, not here. +pub(super) fn select_config_change_restart_candidates( + records: &[ManagedAgentRecord], + personas: &[AgentDefinition], + old_global: &GlobalAgentConfig, + new_global: &GlobalAgentConfig, + mut is_live: impl FnMut(&ManagedAgentRecord) -> bool, +) -> Vec { + records + .iter() + .filter(|record| { + if record.backend != BackendKind::Local { + return false; + } + if !is_live(record) { + return false; + } + let effective_cmd = record_agent_command(record, personas); + let runtime_meta = known_acp_runtime(&effective_cmd); + let old_effective = + resolve_effective_agent_env(record, personas, runtime_meta, old_global); + let new_effective = + resolve_effective_agent_env(record, personas, runtime_meta, new_global); + let old_ready = matches!(agent_readiness(&old_effective), AgentReadiness::Ready); + let new_ready = matches!(agent_readiness(&new_effective), AgentReadiness::Ready); + // NotReady→Ready bypasses the env-diff check (Phase 2 stops-then- + // starts unconditionally); a Ready agent needs an env delta. + let env_changed = old_ready && old_effective.env != new_effective.env; + should_restart_on_config_change( + old_ready, + new_ready, + env_changed, + effective_secrets_unavailable(record, personas), + ) + }) + .map(|r| r.pubkey.clone()) + .collect() +} + +/// Re-authorize a single config-change bounce under the store lock and, only if +/// every gate passes, run the injected `stop` effect. +/// +/// Gate order mirrors the production flow: the restart predicate (readiness +/// transition / env delta, plus the secret-availability consultation), then the +/// strict committed-global re-read (via `load_global`) — the phase-1 snapshots +/// cannot attest the committed ref is still hydratable at stop time — all +/// **before** the stop. +pub(super) fn authorize_config_change_restart( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + old_global: &GlobalAgentConfig, + new_global: &GlobalAgentConfig, + load_global: impl FnOnce() -> Result, + stop: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + let effective_cmd = record_agent_command(record, personas); + let runtime_meta = known_acp_runtime(&effective_cmd); + let old_effective = resolve_effective_agent_env(record, personas, runtime_meta, old_global); + let new_effective = resolve_effective_agent_env(record, personas, runtime_meta, new_global); + let old_ready = matches!(agent_readiness(&old_effective), AgentReadiness::Ready); + let new_ready = matches!(agent_readiness(&new_effective), AgentReadiness::Ready); + let env_changed = old_ready && old_effective.env != new_effective.env; + if !should_restart_on_config_change( + old_ready, + new_ready, + env_changed, + effective_secrets_unavailable(record, personas), + ) { + return Err(format!( + "agent {} restart condition no longer valid under lock", + record.pubkey + )); + } + + // Strict re-read of the committed global under lock before the stop: the + // phase-1 snapshots drove the readiness comparison, but cannot attest the + // committed ref is still hydratable NOW. `spawn_agent_child` reloads it + // strictly, so an unreadable global here means respawn would refuse after + // the stop (`FailedAfterStop`). + refuse_restart_on_unavailable_global(load_global())?; + + stop() +} + +#[cfg(test)] +#[path = "restart_ops_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/restart_ops_tests.rs b/desktop/src-tauri/src/commands/restart_ops_tests.rs new file mode 100644 index 00000000000..8519e2e90a4 --- /dev/null +++ b/desktop/src-tauri/src/commands/restart_ops_tests.rs @@ -0,0 +1,311 @@ +//! Stop-spy tests for the four restart-authorization operations. +//! +//! Each op is driven with an injected global loader and (for the under-lock +//! ops) an injected stop effect that a spy observes. The Ready-but-secrets- +//! unavailable record is the case readiness alone misses: its empty hydrated +//! env computes `Ready`, so only the secret gate stops the bounce. Together +//! with a failing global loader these drive every gate, so removing any one of +//! them turns a test red — the acceptance the direct-helper tests could not +//! meet. + +use super::*; +use crate::managed_agents::{GlobalAgentConfig, ManagedAgentRecord}; +use std::cell::Cell; + +const BUZZ_AGENT: &str = "buzz-agent"; + +/// A local record whose effective env satisfies the `buzz-agent` readiness +/// gate (provider + model + provider key present inline), so `agent_readiness` +/// returns `Ready` and the command resolves to `buzz-agent`. This is the +/// baseline that makes the secret-gate assertions meaningful — a record that +/// failed readiness for unrelated reasons could not distinguish the gates. +fn ready_record() -> ManagedAgentRecord { + let mut rec: ManagedAgentRecord = serde_json::from_str(&format!( + r#"{{ + "pubkey": "{}", + "name": "restart-ops-test", + "relay_url": "", + "acp_command": "buzz-acp", + "agent_command": "buzz-agent", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": "", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }}"#, + "aa".repeat(32) + )) + .unwrap(); + rec.env_vars + .insert("BUZZ_AGENT_PROVIDER".into(), "anthropic".into()); + rec.env_vars + .insert("BUZZ_AGENT_MODEL".into(), "claude-opus-4-5".into()); + rec.env_vars + .insert("ANTHROPIC_API_KEY".into(), "sk-test".into()); + rec +} + +fn ok_global() -> impl FnOnce() -> Result { + || Ok(GlobalAgentConfig::default()) +} + +fn err_global() -> impl FnOnce() -> Result { + || Err("global env_vars unavailable: gen abc123 not found in keyring".to_string()) +} + +// ── select_post_install_restart_candidates ──────────────────────────────── + +/// Positive control: a Ready record with matching runtime, live PID, and setup +/// mode is a candidate. This proves the negative cases below isolate a single +/// gate rather than failing readiness for an unrelated reason. +#[test] +fn test_select_post_install_ready_record_is_candidate() { + let records = [ready_record()]; + let out = + select_post_install_restart_candidates(&records, &[], BUZZ_AGENT, ok_global(), |_| { + (true, true) + }); + assert_eq!( + out, + vec![records[0].pubkey.clone()], + "a Ready setup-mode record on the matching runtime must be a candidate" + ); +} + +/// M1: dropping the `effective_secrets_unavailable` consultation in the op lets +/// a secrets-unavailable record (whose empty env still computes Ready) become a +/// candidate. With the gate present it is excluded. +#[test] +fn test_select_post_install_secrets_unavailable_record_is_not_candidate() { + let mut record = ready_record(); + record.secrets_unavailable = true; + let records = [record]; + let out = + select_post_install_restart_candidates(&records, &[], BUZZ_AGENT, ok_global(), |_| { + (true, true) + }); + assert!( + out.is_empty(), + "a record with unavailable secrets must NOT be a post-install candidate" + ); +} + +/// M5: restoring `unwrap_or_default()` for the global load lets the scan run +/// against a default global on an unreadable committed ref. With the strict +/// gate present, an `Err` from the loader yields zero candidates. +#[test] +fn test_select_post_install_unavailable_global_yields_zero_candidates() { + let records = [ready_record()]; + let out = + select_post_install_restart_candidates(&records, &[], BUZZ_AGENT, err_global(), |_| { + (true, true) + }); + assert!( + out.is_empty(), + "an unreadable committed global must yield zero post-install candidates" + ); +} + +// ── authorize_post_install_restart ──────────────────────────────────────── + +/// Positive control: a Ready record with an available global and setup mode +/// runs the stop. This makes the refusal cases below meaningful. +#[test] +fn test_authorize_post_install_ready_record_runs_stop() { + let record = ready_record(); + let stops = Cell::new(0u32); + let out = authorize_post_install_restart(&record, &[], BUZZ_AGENT, true, ok_global(), || { + stops.set(stops.get() + 1); + Ok(()) + }); + assert!(out.is_ok(), "an eligible record must authorize the bounce"); + assert_eq!( + stops.get(), + 1, + "the stop must fire exactly once when eligible" + ); +} + +/// M2: deleting the under-lock `refuse_restart_on_unavailable_secrets` call +/// lets the stop fire for a secrets-unavailable record (its empty env still +/// computes Ready). With the gate present, the op refuses BEFORE the stop. +#[test] +fn test_authorize_post_install_secrets_unavailable_refuses_before_stop() { + let mut record = ready_record(); + record.secrets_unavailable = true; + let stops = Cell::new(0u32); + let out = authorize_post_install_restart(&record, &[], BUZZ_AGENT, true, ok_global(), || { + stops.set(stops.get() + 1); + Ok(()) + }); + assert!(out.is_err(), "a secrets-unavailable record must be refused"); + assert_eq!( + stops.get(), + 0, + "no stop may fire when secrets are unavailable — the FailedAfterStop path" + ); +} + +/// M6: restoring `unwrap_or_default()` for the under-lock global load lets the +/// stop fire on an unreadable committed ref. With the strict gate present, the +/// op refuses before the stop. +#[test] +fn test_authorize_post_install_unavailable_global_refuses_before_stop() { + let record = ready_record(); + let stops = Cell::new(0u32); + let out = authorize_post_install_restart(&record, &[], BUZZ_AGENT, true, err_global(), || { + stops.set(stops.get() + 1); + Ok(()) + }); + assert!( + out.is_err(), + "an unreadable committed global must be refused" + ); + assert_eq!( + stops.get(), + 0, + "no stop may fire when the committed global is unreadable" + ); +} + +// ── select_config_change_restart_candidates ─────────────────────────────── + +/// A record whose readiness unblocks NotReady→Ready across the global change: +/// old global lacks the provider key, new global supplies it. The record keeps +/// provider+model inline so only the key gates readiness. +fn key_from_global_record() -> ManagedAgentRecord { + let mut rec = ready_record(); + rec.env_vars.remove("ANTHROPIC_API_KEY"); + rec +} + +fn global_with_key() -> GlobalAgentConfig { + let mut g = GlobalAgentConfig::default(); + g.env_vars + .insert("ANTHROPIC_API_KEY".into(), "sk-test".into()); + g +} + +/// Positive control: a NotReady→Ready record with a live runtime is a +/// config-change candidate. +#[test] +fn test_select_config_change_unblocked_record_is_candidate() { + let records = [key_from_global_record()]; + let old_global = GlobalAgentConfig::default(); + let new_global = global_with_key(); + let out = + select_config_change_restart_candidates(&records, &[], &old_global, &new_global, |_| true); + assert_eq!( + out, + vec![records[0].pubkey.clone()], + "a NotReady→Ready record with a live runtime must be a candidate" + ); +} + +/// M3: dropping the `effective_secrets_unavailable` consultation lets a +/// secrets-unavailable record become a config-change candidate. With the gate +/// present it is excluded even though it unblocked NotReady→Ready. +#[test] +fn test_select_config_change_secrets_unavailable_record_is_not_candidate() { + let mut record = key_from_global_record(); + record.secrets_unavailable = true; + let records = [record]; + let old_global = GlobalAgentConfig::default(); + let new_global = global_with_key(); + let out = + select_config_change_restart_candidates(&records, &[], &old_global, &new_global, |_| true); + assert!( + out.is_empty(), + "a record with unavailable secrets must NOT be a config-change candidate" + ); +} + +// ── authorize_config_change_restart ─────────────────────────────────────── + +/// Positive control: a NotReady→Ready record with an available global runs the +/// stop. +#[test] +fn test_authorize_config_change_unblocked_record_runs_stop() { + let record = key_from_global_record(); + let old_global = GlobalAgentConfig::default(); + let new_global = global_with_key(); + let stops = Cell::new(0u32); + let out = authorize_config_change_restart( + &record, + &[], + &old_global, + &new_global, + ok_global(), + || { + stops.set(stops.get() + 1); + Ok(()) + }, + ); + assert!(out.is_ok(), "an unblocked record must authorize the bounce"); + assert_eq!( + stops.get(), + 1, + "the stop must fire exactly once when eligible" + ); +} + +/// M4: dropping the `effective_secrets_unavailable` consultation in the restart +/// predicate lets the stop fire for a secrets-unavailable record. With the gate +/// present, the op refuses before the stop. +#[test] +fn test_authorize_config_change_secrets_unavailable_refuses_before_stop() { + let mut record = key_from_global_record(); + record.secrets_unavailable = true; + let old_global = GlobalAgentConfig::default(); + let new_global = global_with_key(); + let stops = Cell::new(0u32); + let out = authorize_config_change_restart( + &record, + &[], + &old_global, + &new_global, + ok_global(), + || { + stops.set(stops.get() + 1); + Ok(()) + }, + ); + assert!(out.is_err(), "a secrets-unavailable record must be refused"); + assert_eq!( + stops.get(), + 0, + "no stop may fire when secrets are unavailable" + ); +} + +/// M7: deleting the under-lock committed-global re-read lets the stop fire on +/// an unreadable committed ref (the phase-1 snapshots said Ready). With the +/// re-read present, the op refuses before the stop. +#[test] +fn test_authorize_config_change_unavailable_global_refuses_before_stop() { + let record = key_from_global_record(); + let old_global = GlobalAgentConfig::default(); + let new_global = global_with_key(); + let stops = Cell::new(0u32); + let out = authorize_config_change_restart( + &record, + &[], + &old_global, + &new_global, + err_global(), + || { + stops.set(stops.get() + 1); + Ok(()) + }, + ); + assert!( + out.is_err(), + "an unreadable committed global must be refused before the stop" + ); + assert_eq!( + stops.get(), + 0, + "no stop may fire when the committed global re-read fails" + ); +} diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d7..0bc16dd1403 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -139,6 +139,7 @@ fn definition_from_snapshot( parallelism: behavior.parallelism, created_at: now.to_string(), updated_at: now.to_string(), + secrets_unavailable: false, }) } @@ -611,6 +612,10 @@ pub async fn confirm_team_snapshot_import( relay_mesh: None, runtime: member.definition.runtime.clone(), name_pool: member.definition.name_pool.clone(), + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, }; minted.push(MintedMember { diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index c9a6d8812a5..87e0ac1bb0e 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -75,6 +75,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { parallelism: None, created_at: "now".to_string(), updated_at: "now".to_string(), + secrets_unavailable: false, }, AgentDefinition { id: "bob".to_string(), @@ -97,6 +98,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { parallelism: None, created_at: "now".to_string(), updated_at: "now".to_string(), + secrets_unavailable: false, }, ]; let team = TeamRecord { @@ -160,6 +162,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { parallelism: None, created_at: "now".to_string(), updated_at: "now".to_string(), + secrets_unavailable: false, }]; let team = TeamRecord { id: "t1".to_string(), @@ -231,6 +234,10 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { relay_mesh: None, runtime: None, name_pool: vec![], + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, }; let mut memory_map = std::collections::HashMap::new(); diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 416b0c76c9d..40743cfe9c4 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -222,6 +222,10 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 8508c27073d..50f4599dca5 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -419,6 +419,10 @@ mod tests { agent_command_override: None, persona_source_version: None, provider: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index b4492418e59..9546308b9c3 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -72,6 +72,10 @@ fn minimal_record() -> ManagedAgentRecord { definition_respond_to_allowlist: vec!["abc123def".to_string()], definition_parallelism: Some(4), relay_mesh: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 62caffeb2e4..7e6242aaef9 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -118,6 +118,10 @@ fn test_record() -> ManagedAgentRecord { agent_command_override: None, persona_source_version: None, provider: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs index ba0448beaff..af184e77679 100644 --- a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs +++ b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs @@ -18,6 +18,13 @@ use std::path::Path; use serde::{Deserialize, Serialize}; +use crate::managed_agents::secret_projection::{ + cancel_gc_candidacy, deserialize_env_map, load_secret, serialize_env_map, write_secret, + ProjectionStore, WriteOutcome, +}; +use crate::managed_agents::secret_seam::{migrate_inline_field, FieldMigration}; +use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; + /// Regex-equivalent predicate for a valid harness ID. /// /// IDs must match `[a-z0-9_][a-z0-9_-]*` — lowercase alphanumeric plus @@ -59,8 +66,29 @@ pub(crate) struct HarnessDefinition { /// Environment variables injected at spawn time. Definition env is applied /// first and LOSES on conflict with Buzz-injected vars — `BUZZ_MANAGED_AGENT` /// is always authoritative and cannot be overridden here. + /// + /// Secret-projection contract (mirrors `ManagedAgentRecord.env_vars`): on a + /// keyring-backed save this map is stripped to the OS keyring and left empty + /// on disk, with [`env_ref`](Self::env_ref) pointing at the generation. When + /// non-empty on disk it is the authoritative inline-fallback state (keyring + /// unavailable) and wins over any stale `env_ref` on hydration. #[serde(default)] pub env: BTreeMap, + /// Generation reference for the keyring-projected `env` map, set when the + /// inline `env` was stripped into the keyring under `harness::env:`. + /// Non-secret pointer only. Absent when the definition carries no env, or on + /// a keyless build where env stays inline in the `0o600` JSON. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env_ref: Option, + /// Runtime-only marker: set when [`env_ref`](Self::env_ref) is present but + /// its keyring generation could not be hydrated (missing, unreadable, or + /// malformed bytes). Never serialized — reconstructed on every load by + /// [`hydrate_harness_env`], mirroring `ManagedAgentRecord::secrets_unavailable`. + /// Distinguishes a keyring outage from a genuinely-empty `env`: the spawn, + /// deploy, readiness, and model-discovery gates fail closed on it, and a + /// metadata save preserves the raw persisted ref instead of erasing it. + #[serde(skip)] + pub env_unavailable: bool, /// Link to external docs for manual install/setup instructions. #[serde(default)] pub install_instructions_url: String, @@ -69,7 +97,193 @@ pub(crate) struct HarnessDefinition { pub install_hint: String, } -/// Scan `dir` for `*.json` files and deserialize each into a `HarnessDefinition`. +/// Keyring blob coordinate for a harness definition's projected `env` map. +/// +/// The `harness:` namespace is deliberately distinct from the agent-store +/// namespaces (`global:env:` / `agent:…` / `definition:…`) that +/// [`crate::managed_agents::secret_projection::is_projection_key`] matches, so +/// the agent-store GC sweeps never touch harness generations. Harness env edits +/// are rare, so the un-reclaimed generations accrete slowly (there is no harness +/// namespace GC) as an encrypted-at-rest cost documented as a known limitation. +pub(crate) fn harness_env_key(id: &str, gen: &str) -> String { + format!("harness:{id}:env:{gen}") +} + +// ── Env secret projection (mirrors the agent-store `secret_seam`) ──────────── +// +// A harness definition carries a single `env` map that can hold provider +// secrets (e.g. `ANTHROPIC_API_KEY`). These three helpers move it between the +// on-disk JSON and the OS keyring under the `harness::env:` coordinate. +// Each is generic over [`ProjectionStore`] so tests drive a fake and never the +// live OS keyring (the default `system-keyring` feature makes the live store +// real under `cargo test`). + +/// Save-path strip: project a non-empty inline `env` into the keyring under a +/// fresh generation and clear it on disk. Mutates `def` in place. +/// +/// # Empty inline `env`: clear vs. preserve +/// +/// An empty inline `env` is normally a user-clear (a UI save carries the user's +/// full intent — empty means "no env") and clears the ref. But the TS edit form +/// never round-trips `env_ref`, and it seeds its env from the catalog entry — +/// which is EMPTY for a record whose keyring env could not be hydrated. Saving +/// that partially-hydrated view would erase the still-live ref, turning a +/// keyring outage into permanent pointer loss. So `preserved_ref` carries the +/// raw persisted ref of an on-disk record that is currently `env_unavailable` +/// (computed by [`persisted_unavailable_env_ref`]): on the empty-projection +/// branch it is kept instead of cleared, mirroring the agent seam's +/// `WriteOutcome::Nothing if unavailable` guard. A genuine clear of a healthy +/// record passes `None` and clears normally. +/// +/// On a keyring write failure the value stays inline (`0o600` fallback) with the +/// ref cleared (the inline map is now the authoritative fallback state). +fn strip_harness_env( + store: &S, + def: &mut HarnessDefinition, + preserved_ref: Option<&str>, +) { + let id = def.id.clone(); + let inline_env = if !def.env.is_empty() { + serialize_env_map(&def.env).ok() + } else { + None + }; + match write_secret( + store, + |gen| harness_env_key(&id, gen), + inline_env.as_deref(), + &format!("harness:{id} env"), + ) { + WriteOutcome::Persisted { gen } => { + cancel_gc_candidacy(store, &harness_env_key(&id, &gen)); + def.env.clear(); + def.env_ref = Some(gen); + } + // Empty inline env. Preserve the ref when the persisted record was + // `env_unavailable` (a save over a partially-hydrated view is not a + // user-clear); otherwise this is a genuine clear. + WriteOutcome::Nothing => { + def.env_ref = preserved_ref.map(str::to_string); + } + // Keyring write of a non-empty env failed: value kept inline, ref cleared. + WriteOutcome::KeptInline { .. } => { + def.env_ref = None; + } + } +} + +/// Re-read the on-disk record for `id` and rehydrate it, returning its raw +/// `env_ref` **iff** that record is currently `env_unavailable` (a ref is +/// present but its keyring generation could not be hydrated). Returns `None` +/// when the file is absent, healthy, or carries no ref. +/// +/// This is the save-path signal that distinguishes a keyring outage from a +/// genuine user-clear. The TS edit form never round-trips `env_ref` (it always +/// arrives `None`) and seeds its env from the catalog entry — which is empty +/// for an unavailable record — so without this the naive empty-env save would +/// erase the live ref. Taking `dir` as a parameter (rather than a global +/// lookup) keeps the store-injected save tests hermetic. +fn persisted_unavailable_env_ref( + store: &S, + dir: &Path, + id: &str, +) -> Option { + let path = dir.join(format!("{id}.json")); + let contents = std::fs::read_to_string(&path).ok()?; + let mut def: HarnessDefinition = serde_json::from_str(&contents).ok()?; + let raw_ref = def.env_ref.clone(); + hydrate_harness_env(store, &mut def); + if def.env_unavailable { + raw_ref + } else { + None + } +} + +/// Load-path hydrate: fill an empty on-disk `env` from the keyring generation +/// named by `env_ref`. A non-empty inline `env` is the authoritative +/// keyring-unavailable fallback and wins over any ref. +/// +/// When `env_ref` is present but its generation cannot be hydrated — missing, +/// unreadable, or malformed bytes — this sets [`HarnessDefinition::env_unavailable`] +/// and leaves `env` empty. That marker is distinct from a genuinely-empty `env` +/// (no ref): the spawn/deploy/readiness/model-discovery gates fail closed on it, +/// and the save path preserves the raw ref rather than erasing it. Mirrors the +/// agent tier's `secrets_unavailable`, which `hydrate_all_secrets_for_records` +/// sets on the same failure. The load still succeeds so a single unavailable +/// harness never fails discovery for the rest. +fn hydrate_harness_env(store: &S, def: &mut HarnessDefinition) { + if !def.env.is_empty() { + return; // inline fallback is authoritative + } + let id = def.id.clone(); + match load_secret( + store, + def.env_ref.as_deref(), + |gen| harness_env_key(&id, gen), + &format!("harness:{id} env"), + ) { + Ok(Some(s)) => match deserialize_env_map(&s) { + Ok(map) => def.env = map, + Err(e) => { + tracing::warn!("custom_harnesses: harness {id} env deserialize failed: {e}"); + def.env_unavailable = true; + } + }, + Ok(None) => {} + Err(e) => { + tracing::warn!("custom_harnesses: {e}"); + def.env_unavailable = true; + } + } +} + +/// Boot-migration transition: W1-safe field migration of one definition's +/// `env`. An absent inline value here means "already projected on a prior +/// launch," so an existing ref is preserved rather than cleared (the +/// distinction from [`strip_harness_env`], which reads empty as a user-clear). +/// Returns `true` when the ref changed and the file must be rewritten. +/// +/// Shares the single W1-safe seam +/// ([`crate::managed_agents::secret_seam::migrate_inline_field`]) with the agent +/// tiers so the two surfaces cannot diverge on the empty-inline semantics. +pub(crate) fn migrate_harness_env( + store: &S, + def: &mut HarnessDefinition, +) -> bool { + let id = def.id.clone(); + let inline_env = if !def.env.is_empty() { + serialize_env_map(&def.env).ok() + } else { + None + }; + match migrate_inline_field( + store, + |gen| harness_env_key(&id, gen), + inline_env.as_deref(), + def.env_ref.as_deref(), + &format!("harness:{id} env"), + ) { + FieldMigration::Projected { gen } => { + def.env.clear(); + def.env_ref = Some(gen); + true + } + FieldMigration::Preserved | FieldMigration::Cleared => false, + } +} + +/// Resolve the live secret-projection store, or `None` on a keyless build. +/// +/// Consumes the agent-store resolver so harness env shares the exact same +/// `SecretStore` instance (one cache, one mutex) as every other projected +/// secret — never a second handle that could race on the blob. +fn live_projection_store() -> Option<&'static crate::secret_store::SecretStore> { + crate::managed_agents::storage::agent_secret_store_pub() +} + +/// Scan `dir` for `*.json` files and deserialize each into a `HarnessDefinition`, +/// hydrating each definition's `env` from the live keyring. /// /// Errors per file are logged with `tracing::warn` and skipped — a single /// malformed file never fails discovery for the rest. Returns only @@ -86,6 +300,28 @@ pub(crate) struct HarnessDefinition { /// call** — this function performs no caching, mirroring goose's /// `refresh_custom_providers()` pattern. pub(crate) fn load_custom_harnesses(dir: &Path) -> Vec { + load_custom_harnesses_with(live_projection_store(), dir) +} + +/// Testable core of [`load_custom_harnesses`], generic over the projection +/// store so tests drive a [`ProjectionStore`] fake instead of the live keyring. +/// Scans + validates the directory, then hydrates each definition's `env`. +pub(crate) fn load_custom_harnesses_with( + store: Option<&S>, + dir: &Path, +) -> Vec { + let mut definitions = load_custom_harnesses_from_disk(dir); + if let Some(store) = store { + for def in &mut definitions { + hydrate_harness_env(store, def); + } + } + definitions +} + +/// Raw directory scan + per-file validation, WITHOUT env hydration. The +/// store-independent half of [`load_custom_harnesses_with`]. +fn load_custom_harnesses_from_disk(dir: &Path) -> Vec { let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return vec![], @@ -308,6 +544,80 @@ pub(crate) fn lookup_loaded_harness_by_id(id: &str) -> Option( + record: &'a ManagedAgentRecord, + personas: &'a [AgentDefinition], +) -> &'a str { + record + .runtime + .as_deref() + .or_else(|| { + record.persona_id.as_deref().and_then(|pid| { + personas + .iter() + .find(|p| p.id == pid) + .and_then(|p| p.runtime.as_deref()) + }) + }) + .unwrap_or("") +} + +/// The effective harness id when that harness's projected `env` is unavailable — +/// its `env_ref` is present but could not be hydrated from the keyring +/// (missing, unreadable, or malformed generation). +/// +/// This is the harness tier of the fail-closed spawn gate, alongside +/// [`crate::managed_agents::spawn_key_refusal`]'s instance-tier check, +/// [`crate::managed_agents::unavailable_definition_id`]'s definition tier, and +/// the global-tier check in `spawn_agent_child`. Returns the offending id +/// (rather than a bool) so the spawn path can name it in the refusal without a +/// second lookup; status callers use `.is_some()`. A record whose effective +/// harness is a healthy definition, a builtin (no registry entry), or a +/// dangling id yields `None`. +pub(crate) fn unavailable_harness_id( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], +) -> Option { + let runtime_id = effective_runtime_id(record, personas); + lookup_loaded_harness_by_id(runtime_id) + .filter(|def| def.env_unavailable) + .map(|def| def.id.clone()) +} + +/// Whether ANY secret tier for `record` is currently unavailable — its ref is +/// present but could not be hydrated from the keyring (missing, unreadable, or +/// malformed generation). ORs the three registry-derivable tiers that every +/// fail-closed spawn seam already consults individually: +/// +/// - the instance tier (`record.secrets_unavailable`), +/// - the definition tier ([`crate::managed_agents::unavailable_definition_id`]), +/// - the harness tier ([`unavailable_harness_id`]). +/// +/// This is the single predicate the restart-eligibility boundaries call before +/// any `stop_managed_agent_process`: an unavailable record's hydrated env is +/// empty and therefore indistinguishable from an intentionally-empty one at the +/// `agent_readiness`/`resolve_effective_agent_env` layer, so those raw signals +/// cannot be trusted to gate a destructive stop-then-respawn. Gating here keeps +/// a running/setup process alive rather than stopping it and only discovering +/// the refusal at respawn (`FailedAfterStop`). The global tier is not +/// registry-derivable from a record and is covered by the spawn-time gate. +pub(crate) fn effective_secrets_unavailable( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], +) -> bool { + record.secrets_unavailable + || crate::managed_agents::unavailable_definition_id(record, personas).is_some() + || unavailable_harness_id(record, personas).is_some() +} + /// Warm the loaded-harness registry synchronously from `custom_dir`. /// /// Must be called **before** `restore_managed_agents_on_launch` so that cold @@ -397,22 +707,55 @@ pub(crate) struct SaveOutcome { pub removed_old_path: Option, } -/// Write a harness definition to `dir/.json` using a backup-swap strategy -/// that is safe on all platforms (including Windows where `fs::rename` over an -/// existing file fails with "access denied"): +/// Write a harness definition to `dir/.json`, projecting its `env` secrets +/// into the OS keyring first. The resolved live [`ProjectionStore`] is used; +/// see [`save_custom_harness_to_dir_with`] for the store-injected core. +/// +/// The caller is responsible for full validation (id, env, etc.) BEFORE +/// calling this function — no validation is performed here. +pub(crate) fn save_custom_harness_to_dir( + dir: &Path, + definition: &HarnessDefinition, + rename_old_id: Option<&str>, +) -> Result { + save_custom_harness_to_dir_with(live_projection_store(), dir, definition, rename_old_id) +} + +/// Store-injected core of [`save_custom_harness_to_dir`]: strip `env` secrets +/// into `store`, then write the stripped definition to `dir/.json` using a +/// backup-swap strategy that is safe on all platforms (including Windows where +/// `fs::rename` over an existing file fails with "access denied"): /// -/// 1. Serialize the definition and write it to a unique temp file via -/// `atomic_write_file`. +/// 1. Serialize the STRIPPED definition and write it to a unique temp file via +/// `atomic_write_file`, created `0o600` before any bytes hit disk (the JSON +/// carries inline env in the keyless / keyring-unavailable fallback). /// 2. If the target already exists, rename it to `.bak` (the backup). /// 3. `commit()` the temp file (renames temp → target). -/// * On success: delete `.bak` (best-effort; a stale `.bak` is harmless). +/// * On success: delete `.bak` (best-effort). A leaked `.bak` is harmless +/// when the file it backs up had its env projected into the keyring, but +/// carries inline secrets in the keyring-unavailable fallback — which is +/// why the boot migration scrubs `*.json.bak` (see +/// [`crate::migration::migration_harness_secrets`]). /// * On failure: restore `.bak` → target so the original is never lost. /// 4. If `rename_old_id` is `Some`, remove `/.json` after the /// new file is committed (non-fatal if NotFound). /// -/// The caller is responsible for full validation (id, env, etc.) BEFORE -/// calling this function — no validation is performed here. -pub(crate) fn save_custom_harness_to_dir( +/// `definition` is NOT mutated — a clone is stripped so the caller keeps the +/// full `env` for the returned catalog entry (edit round-trip). On a keyless +/// build (`store` is `None`) the `env` stays inline in the `0o600` JSON. +/// +/// Renaming a record whose env is currently `env_unavailable` (a live keyring +/// ref that could not be hydrated) is refused with an error: the ref is keyed by +/// id, so a rename can neither re-read it under the new id nor safely carry it +/// forward without stranding it at an unwritten coordinate. +/// +/// No cross-process secret-transaction lock is taken here: the `harness:` +/// keyring namespace is deliberately excluded from the agent-store GC +/// ([`crate::managed_agents::secret_projection::is_projection_key`]), so no +/// concurrent sweep can ever delete a harness generation between its write and +/// its JSON commit — the window that lock exists to close does not exist here. +pub(crate) fn save_custom_harness_to_dir_with( + store: Option<&S>, dir: &Path, definition: &HarnessDefinition, rename_old_id: Option<&str>, @@ -420,15 +763,52 @@ pub(crate) fn save_custom_harness_to_dir( use atomic_write_file::AtomicWriteFile; use std::io::Write; - let json = serde_json::to_string_pretty(definition) + // Strip env → keyring on a clone. The keyring write happens BEFORE the + // atomic JSON commit (the commit point), mirroring the agent-store seam. + let mut to_write = definition.clone(); + if let Some(store) = store { + // Preserve a live-but-unhydrated ref across a same-id metadata save; the + // TS form round-trips neither the ref nor the marker and seeds `env` + // from the (empty) catalog entry, so without this the empty-env save + // would erase the ref — turning a keyring outage into pointer loss. + // + // A rename of an `env_unavailable` record has no safe outcome: the ref + // is keyed by id (`harness::env:`), so re-reading under the new + // id sees "new harness, empty env" (ref erased), and carrying the ref + // forward would resolve to `harness::env:` — a coordinate + // that was never written, leaving the record permanently unavailable. + // Refuse it until the keyring hydrates or the env is re-entered. + let preserved_ref = match rename_old_id { + Some(old_id) => { + if persisted_unavailable_env_ref(store, dir, old_id).is_some() { + return Err(format!( + "cannot rename harness {old_id:?}: its keyring env is currently \ + unavailable, so the ref cannot be carried to the new id \ + (retry once the keyring hydrates or re-enter the env)" + )); + } + None + } + None => persisted_unavailable_env_ref(store, dir, &to_write.id), + }; + strip_harness_env(store, &mut to_write, preserved_ref.as_deref()); + } + + let json = serde_json::to_string_pretty(&to_write) .map_err(|e| format!("failed to serialize harness definition: {e}"))?; - let target_path = dir.join(format!("{}.json", definition.id)); - let bak_path = dir.join(format!("{}.json.bak", definition.id)); + let target_path = dir.join(format!("{}.json", to_write.id)); + let bak_path = dir.join(format!("{}.json.bak", to_write.id)); - // Stage write to temp file. + // Stage write to temp file, owner-only before any bytes hit disk. let mut file = AtomicWriteFile::open(&target_path) .map_err(|e| format!("failed to open {}: {e}", target_path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("failed to set {} permissions: {e}", target_path.display()))?; + } file.write_all(json.as_bytes()) .map_err(|e| format!("failed to write harness definition: {e}"))?; @@ -462,7 +842,9 @@ pub(crate) fn save_custom_harness_to_dir( return Err(format!("failed to finalize harness definition: {e}")); } - // Commit succeeded — remove the backup (best-effort; stale .bak is harmless). + // Commit succeeded — remove the backup (best-effort). A leaked `.bak` is + // harmless once env was projected to the keyring, but secret-bearing in the + // inline fallback; the boot migration scrubs `*.json.bak` for that case. if had_backup { let _ = std::fs::remove_file(&bak_path); } @@ -491,741 +873,5 @@ pub(crate) fn save_custom_harness_to_dir( } #[cfg(test)] -mod tests { - use super::*; - use std::fs; - - // ── ID validation ──────────────────────────────────────────────────────── - - #[test] - fn valid_id_lowercase_with_hyphen() { - assert!(is_valid_harness_id("my-agent")); - } - - #[test] - fn valid_id_underscore_start() { - assert!(is_valid_harness_id("_my_agent")); - } - - #[test] - fn valid_id_alphanumeric() { - assert!(is_valid_harness_id("agent42")); - } - - #[test] - fn invalid_id_uppercase() { - assert!(!is_valid_harness_id("MyAgent")); - } - - #[test] - fn invalid_id_starts_with_hyphen() { - assert!(!is_valid_harness_id("-bad-id")); - } - - #[test] - fn invalid_id_empty() { - assert!(!is_valid_harness_id("")); - } - - #[test] - fn invalid_id_path_traversal() { - assert!(!is_valid_harness_id("../etc/passwd")); - } - - // ── Collision check ────────────────────────────────────────────────────── - - #[test] - fn builtin_ids_are_rejected() { - // Tier-1 hard-coded IDs must always be reserved. - for id in &["goose", "claude", "codex", "buzz-agent"] { - assert!(check_id_collision(id).is_err(), "{id} should be rejected"); - } - // Tier-2 preset IDs must also be reserved (derived from PRESET_HARNESSES). - for id in crate::managed_agents::discovery::preset_harness_ids() { - assert!(check_id_collision(id).is_err(), "{id} should be rejected"); - } - } - - #[test] - fn unknown_id_passes_collision_check() { - assert!(check_id_collision("my-custom-agent").is_ok()); - } - - // ── File loading ───────────────────────────────────────────────────────── - - #[test] - fn load_valid_json_returns_definition() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("my-agent.json"), - r#"{"id":"my-agent","label":"My Agent","command":"my-agent-bin"}"#, - ) - .unwrap(); - - let defs = load_custom_harnesses(dir.path()); - assert_eq!(defs.len(), 1); - assert_eq!(defs[0].id, "my-agent"); - assert_eq!(defs[0].label, "My Agent"); - assert_eq!(defs[0].command, "my-agent-bin"); - } - - #[test] - fn load_skips_non_json_files() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("my-agent.toml"), r#"id = "my-agent""#).unwrap(); - - let defs = load_custom_harnesses(dir.path()); - assert_eq!(defs.len(), 0, "non-JSON file should be ignored"); - } - - #[test] - fn load_skips_invalid_json_without_panicking() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("bad.json"), "{ not valid json").unwrap(); - - // Must not panic or propagate an error. - let defs = load_custom_harnesses(dir.path()); - assert_eq!(defs.len(), 0); - } - - #[test] - fn load_skips_definition_with_invalid_id() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("Bad.json"), - r#"{"id":"Bad-Id","label":"Bad","command":"bad"}"#, - ) - .unwrap(); - - let defs = load_custom_harnesses(dir.path()); - assert_eq!( - defs.len(), - 0, - "invalid id should cause the entry to be skipped" - ); - } - - #[test] - fn load_skips_definition_with_empty_command() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("empty-cmd.json"), - r#"{"id":"empty-cmd","label":"Empty","command":""}"#, - ) - .unwrap(); - - let defs = load_custom_harnesses(dir.path()); - assert_eq!( - defs.len(), - 0, - "empty command should cause the entry to be skipped" - ); - } - - #[test] - fn load_skips_definition_with_non_http_install_url() { - // installInstructionsUrl must start with https:// or http://. - // A bare path, javascript: URI, or other scheme is rejected. - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("bad-url.json"), - r#"{"id":"bad-url","label":"Bad","command":"bad-bin","installInstructionsUrl":"file:///etc/passwd"}"#, - ) - .unwrap(); - - let defs = load_custom_harnesses(dir.path()); - assert_eq!( - defs.len(), - 0, - "non-http install URL should cause the entry to be skipped" - ); - } - - #[test] - fn load_accepts_empty_or_https_install_url() { - let dir = tempfile::tempdir().unwrap(); - // Empty URL is fine (optional field). - fs::write( - dir.path().join("no-url.json"), - r#"{"id":"no-url","label":"No URL","command":"no-url-bin"}"#, - ) - .unwrap(); - // https:// is accepted. - fs::write( - dir.path().join("good-url.json"), - r#"{"id":"good-url","label":"Good URL","command":"good-bin","installInstructionsUrl":"https://example.com/install"}"#, - ) - .unwrap(); - - let defs = load_custom_harnesses(dir.path()); - assert_eq!( - defs.len(), - 2, - "empty and https:// URLs must both be accepted" - ); - } - - #[test] - fn load_missing_dir_returns_empty_vec() { - let dir = tempfile::tempdir().unwrap(); - let nonexistent = dir.path().join("does_not_exist"); - - let defs = load_custom_harnesses(&nonexistent); - assert_eq!(defs.len(), 0); - } - - #[test] - fn load_continues_after_one_bad_entry() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("bad.json"), "!!!").unwrap(); - fs::write( - dir.path().join("good.json"), - r#"{"id":"good-one","label":"Good","command":"good-binary"}"#, - ) - .unwrap(); - - let defs = load_custom_harnesses(dir.path()); - assert_eq!(defs.len(), 1, "bad entry skipped, good entry loaded"); - assert_eq!(defs[0].id, "good-one"); - } - - #[test] - fn load_applies_id_collision_check() { - // A custom file whose id shadows a built-in ("goose") must be dropped - // BY THE LOADER — `load_custom_harnesses` is the enforcement boundary - // shared by both the warm path and discovery. This exercises the real - // loader against a real file, not just the helper predicate. - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("goose.json"), - r#"{"id":"goose","label":"Not Goose","command":"goose","args":["--evil"]}"#, - ) - .unwrap(); - assert!( - load_custom_harnesses(dir.path()).is_empty(), - "loader must drop a file shadowing a builtin id" - ); - assert!(check_id_collision("goose").is_err()); - assert!(check_id_collision("custom-goose").is_ok()); - } - - #[test] - fn load_dedups_duplicate_ids_first_file_wins() { - // Two files carrying the same custom id: the loader must keep exactly - // one definition (directory-order first wins; the duplicate is dropped). - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("a.json"), - r#"{"id":"custom-dup","label":"First","command":"first-cmd"}"#, - ) - .unwrap(); - fs::write( - dir.path().join("b.json"), - r#"{"id":"custom-dup","label":"Second","command":"second-cmd"}"#, - ) - .unwrap(); - let loaded = load_custom_harnesses(dir.path()); - assert_eq!( - loaded.len(), - 1, - "loader must dedup duplicate ids within the directory" - ); - assert_eq!(loaded[0].id, "custom-dup"); - } - - // ── Round-trip via save_custom_harness_to_dir (B-4) ───────────────────── - // - // These tests exercise the REAL persistence helper, not raw fs::write. - // They prove: create, same-ID edit (backup-swap), rename (old file removed), - // backup file cleaned up on success. - - fn make_def(id: &str, label: &str) -> HarnessDefinition { - HarnessDefinition { - id: id.to_string(), - label: label.to_string(), - command: format!("{id}-bin"), - args: vec![], - env: BTreeMap::new(), - install_instructions_url: String::new(), - install_hint: String::new(), - } - } - - #[test] - fn save_to_dir_create_writes_file_and_loads_back() { - let dir = tempfile::tempdir().unwrap(); - let def = make_def("my-harness", "My Harness"); - - let outcome = save_custom_harness_to_dir(dir.path(), &def, None).unwrap(); - - assert_eq!(outcome.target_path, dir.path().join("my-harness.json")); - assert!(outcome.removed_old_path.is_none(), "no old file on create"); - - let loaded = load_custom_harnesses(dir.path()); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0].id, "my-harness"); - assert_eq!(loaded[0].label, "My Harness"); - } - - #[test] - fn save_to_dir_same_id_edit_replaces_content() { - let dir = tempfile::tempdir().unwrap(); - let v1 = make_def("my-harness", "V1 Label"); - save_custom_harness_to_dir(dir.path(), &v1, None).unwrap(); - - // Same-ID edit: label changes. - let v2 = make_def("my-harness", "V2 Label"); - let outcome = save_custom_harness_to_dir(dir.path(), &v2, None).unwrap(); - - // No old-path reported (id unchanged). - assert!(outcome.removed_old_path.is_none()); - - let loaded = load_custom_harnesses(dir.path()); - assert_eq!(loaded.len(), 1, "same-id edit must not duplicate entries"); - assert_eq!(loaded[0].label, "V2 Label", "v2 content must be present"); - } - - #[test] - fn save_to_dir_backup_is_cleaned_up_after_same_id_edit() { - let dir = tempfile::tempdir().unwrap(); - let v1 = make_def("my-harness", "V1"); - save_custom_harness_to_dir(dir.path(), &v1, None).unwrap(); - - let v2 = make_def("my-harness", "V2"); - save_custom_harness_to_dir(dir.path(), &v2, None).unwrap(); - - // .bak file must be gone after a successful commit. - let bak = dir.path().join("my-harness.json.bak"); - assert!( - !bak.exists(), - ".bak file must be removed after successful same-id edit" - ); - } - - #[test] - fn save_to_dir_rename_removes_old_file_and_creates_new() { - let dir = tempfile::tempdir().unwrap(); - let old_def = make_def("old-id", "Old"); - save_custom_harness_to_dir(dir.path(), &old_def, None).unwrap(); - - // Rename: new id, old_id supplied. - let new_def = make_def("new-id", "New"); - let outcome = save_custom_harness_to_dir(dir.path(), &new_def, Some("old-id")).unwrap(); - - // The outcome carries the old path that was removed. - let expected_old = dir.path().join("old-id.json"); - assert_eq!( - outcome.removed_old_path, - Some(expected_old.clone()), - "removed_old_path must be the old file" - ); - - // Old file gone, new file present. - assert!(!expected_old.exists(), "old-id.json must be removed"); - let loaded = load_custom_harnesses(dir.path()); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0].id, "new-id"); - } - - #[test] - fn save_to_dir_rename_nonexistent_old_id_is_non_fatal() { - // rename_old_id pointing to a file that does not exist must succeed - // (NotFound is silently ignored by the helper). - let dir = tempfile::tempdir().unwrap(); - let def = make_def("alpha", "Alpha"); - let outcome = save_custom_harness_to_dir(dir.path(), &def, Some("ghost-id")).unwrap(); - - // New file created, no old path removed. - assert_eq!(outcome.target_path, dir.path().join("alpha.json")); - assert!( - outcome.removed_old_path.is_none(), - "NotFound old-id must not be reported as removed" - ); - assert!(load_custom_harnesses(dir.path()).len() == 1); - } - - #[test] - fn save_to_dir_roundtrip_with_env_preserves_values() { - let dir = tempfile::tempdir().unwrap(); - let mut env = BTreeMap::new(); - env.insert("MY_KEY".to_string(), "my_value".to_string()); - let def = HarnessDefinition { - id: "env-harness".to_string(), - label: "Env Harness".to_string(), - command: "env-bin".to_string(), - args: vec!["--flag".to_string()], - env, - install_instructions_url: "https://example.com".to_string(), - install_hint: "Install from example.com".to_string(), - }; - - save_custom_harness_to_dir(dir.path(), &def, None).unwrap(); - - let loaded = load_custom_harnesses(dir.path()); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0].args, vec!["--flag"]); - assert_eq!( - loaded[0].env.get("MY_KEY").map(String::as_str), - Some("my_value"), - "env must round-trip through save_custom_harness_to_dir" - ); - } - - // ── B-3: env validation boundary (validate_harness_definition_pub integration) ── - - #[test] - fn validate_rejects_malformed_key_with_equals_sign() { - // BUZZ_AUTH_TAG=x is the documented reserved-key bypass shape: - // the key contains '=' so Command::env would produce - // `BUZZ_AUTH_TAG=x=forged` in the child env. - let mut env = BTreeMap::new(); - env.insert("BUZZ_AUTH_TAG=x".to_string(), "forged".to_string()); - let def = HarnessDefinition { - id: "bad-env".to_string(), - label: "Bad".to_string(), - command: "bad-bin".to_string(), - args: vec![], - env, - install_instructions_url: String::new(), - install_hint: String::new(), - }; - let err = validate_harness_definition_pub(&def).unwrap_err(); - assert!( - err.contains("env var keys must match"), - "malformed key must be rejected: {err}" - ); - assert!( - err.contains("BUZZ_AUTH_TAG"), - "error must name the offending key: {err}" - ); - } - - #[test] - fn validate_rejects_reserved_key_buzz_managed_agent() { - // BUZZ_MANAGED_AGENT and BUZZ_MANAGED_AGENT_START_NONCE are the - // ownership markers — supplying them in a definition must be rejected. - let mut env = BTreeMap::new(); - env.insert( - "BUZZ_MANAGED_AGENT".to_string(), - "fake-instance".to_string(), - ); - let def = HarnessDefinition { - id: "bad-marker".to_string(), - label: "Bad".to_string(), - command: "bad-bin".to_string(), - args: vec![], - env, - install_instructions_url: String::new(), - install_hint: String::new(), - }; - let err = validate_harness_definition_pub(&def).unwrap_err(); - assert!( - err.contains("reserved by Buzz"), - "ownership marker key must be rejected: {err}" - ); - } - - #[test] - fn validate_rejects_reserved_key_case_insensitive() { - // BUZZ_PRIVATE_KEY in any casing must be blocked. - let mut env = BTreeMap::new(); - env.insert("buzz_private_key".to_string(), "secret".to_string()); - let def = HarnessDefinition { - id: "ci-marker".to_string(), - label: "CI".to_string(), - command: "ci-bin".to_string(), - args: vec![], - env, - install_instructions_url: String::new(), - install_hint: String::new(), - }; - let err = validate_harness_definition_pub(&def).unwrap_err(); - assert!( - err.contains("reserved by Buzz"), - "reserved key must be blocked case-insensitively: {err}" - ); - } - - #[test] - fn validate_rejects_nul_byte_in_value() { - // A NUL in a value would cause Command::env to panic at spawn time. - let mut env = BTreeMap::new(); - env.insert("MY_KEY".to_string(), "val\x00ue".to_string()); - let def = HarnessDefinition { - id: "nul-val".to_string(), - label: "NUL".to_string(), - command: "nul-bin".to_string(), - args: vec![], - env, - install_instructions_url: String::new(), - install_hint: String::new(), - }; - let err = validate_harness_definition_pub(&def).unwrap_err(); - assert!( - err.contains("NUL bytes"), - "NUL value must be rejected at validation: {err}" - ); - } - - #[test] - fn validate_rejects_value_over_per_value_size_limit() { - use crate::managed_agents::env_vars::MAX_ENV_VALUE_BYTES; - let mut env = BTreeMap::new(); - // One byte over the per-value cap. - env.insert("BIG_VAL".to_string(), "x".repeat(MAX_ENV_VALUE_BYTES + 1)); - let def = HarnessDefinition { - id: "big-val".to_string(), - label: "Big".to_string(), - command: "big-bin".to_string(), - args: vec![], - env, - install_instructions_url: String::new(), - install_hint: String::new(), - }; - let err = validate_harness_definition_pub(&def).unwrap_err(); - assert!( - err.contains("per-value limit"), - "oversized value must be rejected: {err}" - ); - } - - #[test] - fn validate_accepts_well_formed_env() { - let mut env = BTreeMap::new(); - env.insert("ANTHROPIC_API_KEY".to_string(), "sk-test-123".to_string()); - env.insert("MODEL_VERSION".to_string(), "claude-3".to_string()); - let def = HarnessDefinition { - id: "good-env".to_string(), - label: "Good".to_string(), - command: "good-bin".to_string(), - args: vec![], - env, - install_instructions_url: String::new(), - install_hint: String::new(), - }; - assert!( - validate_harness_definition_pub(&def).is_ok(), - "well-formed definition must pass validation" - ); - } - - // ── Comma-in-args validation (transport-lossiness guard) ───────────────── - - /// A definition whose args contain a literal comma must be rejected at the - /// validation boundary — the comma-delimited `BUZZ_ACP_AGENT_ARGS` - /// transport would silently split it into two args at spawn time. - #[test] - fn validate_rejects_comma_in_args() { - let mut def = make_def("comma-args", "Comma"); - def.args = vec!["--name".to_string(), "a,b".to_string()]; - let err = validate_harness_definition_pub(&def).unwrap_err(); - assert!( - err.contains("comma"), - "error must explain the comma transport limit, got: {err}" - ); - } - - /// Comma-free args pass — including args with spaces and special chars. - #[test] - fn validate_accepts_comma_free_args() { - let mut def = make_def("clean-args", "Clean"); - def.args = vec!["acp".to_string(), "--flag=x y".to_string()]; - assert!(validate_harness_definition_pub(&def).is_ok()); - } - - /// The loader shares the same validator: a hand-authored file with a comma - /// in args is skipped, so the invariant holds regardless of how the - /// definition arrives (UI save or hand-edited JSON). - #[test] - fn load_skips_definition_with_comma_in_args() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("comma.json"), - r#"{"id":"comma-file","label":"Comma","command":"comma-bin","args":["a,b"]}"#, - ) - .unwrap(); - assert!( - load_custom_harnesses(dir.path()).is_empty(), - "comma-in-args definition must be skipped at the loader boundary" - ); - } - - // ── Discovery publish under persist_mutex (stale-snapshot regression) ──── - - /// Discovery's registry publish must re-read the directory at publish time - /// (under the persist mutex), not push a snapshot taken before the auth - /// probes ran. Regression shape: discovery scans dir → user saves harness X - /// (save_and_warm warms the registry with X) → discovery finishes. If - /// discovery published its pre-save snapshot, X would be on disk but - /// unresolvable at spawn until the next discover. - /// - /// Deterministic interleaving: we simulate it by calling the publish seam - /// (`warm_harness_registry_locked`) after a save that happened "during" - /// discovery — the fresh-read semantics mean the just-saved definition - /// survives the publish. - #[test] - fn discovery_publish_after_concurrent_save_keeps_saved_harness() { - let _lock = registry_test_lock(); - let dir = tempfile::tempdir().unwrap(); - - // Discovery "scans" the dir while it is empty (stale snapshot would be []). - let stale_snapshot = load_custom_harnesses(dir.path()); - assert!(stale_snapshot.is_empty()); - - // A save lands mid-discovery (save_and_warm: write + warm). - let def = make_def("mid-save", "Mid Save"); - save_and_warm(dir.path(), &def, None).unwrap(); - assert!(lookup_loaded_harness_by_id("mid-save").is_some()); - - // Discovery publishes — the locked warm re-reads the directory, so the - // just-saved harness must survive (a stale-snapshot publish would - // clobber it). - warm_harness_registry_locked(Some(dir.path())); - assert!( - lookup_loaded_harness_by_id("mid-save").is_some(), - "publish must re-read the directory, not clobber the mid-discovery save" - ); - } - - /// Same shape for delete: a delete landing mid-discovery must not be - /// resurrected by the discovery publish. - #[test] - fn discovery_publish_after_concurrent_delete_keeps_harness_gone() { - let _lock = registry_test_lock(); - let dir = tempfile::tempdir().unwrap(); - - let def = make_def("mid-delete", "Mid Delete"); - save_and_warm(dir.path(), &def, None).unwrap(); - - // Discovery "scans" while the file exists (stale snapshot would contain it). - let stale_snapshot = load_custom_harnesses(dir.path()); - assert_eq!(stale_snapshot.len(), 1); - - // Delete lands mid-discovery. - delete_and_warm(dir.path(), "mid-delete").unwrap(); - assert!(lookup_loaded_harness_by_id("mid-delete").is_none()); - - // Discovery publishes — fresh read keeps it gone. - warm_harness_registry_locked(Some(dir.path())); - assert!( - lookup_loaded_harness_by_id("mid-delete").is_none(), - "publish must not resurrect a harness deleted mid-discovery" - ); - } - - // ── Registry warm path ─────────────────────────────────────────────────── - - /// After `warm_harness_registry_from_dir` the registry contains preset + - /// custom definitions and `lookup_loaded_harness_by_id` resolves them. - #[test] - fn warm_registry_then_lookup_finds_custom_and_preset_entries() { - let _lock = registry_test_lock(); - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("my-custom.json"), - r#"{"id":"my-custom","label":"My Custom","command":"my-custom-bin"}"#, - ) - .unwrap(); - - warm_harness_registry_from_dir(Some(dir.path())); - - // Custom entry must be findable. - let found = lookup_loaded_harness_by_id("my-custom"); - assert!( - found.is_some(), - "warm registry must contain the custom entry" - ); - assert_eq!(found.unwrap().command, "my-custom-bin"); - - // At least one preset entry must be in the registry (e.g. "cursor"). - let preset = lookup_loaded_harness_by_id("cursor"); - assert!( - preset.is_some(), - "warm registry must contain preset entries" - ); - } - - /// `warm_harness_registry_from_dir` with `None` still loads presets. - #[test] - fn warm_registry_with_no_custom_dir_loads_presets_only() { - let _lock = registry_test_lock(); - warm_harness_registry_from_dir(None); - // At least the "cursor" preset must be present. - assert!( - lookup_loaded_harness_by_id("cursor").is_some(), - "presets must be reachable even without a custom dir" - ); - } - - /// `warm_harness_registry_from_dir` followed by `update_loaded_harness_registry` - /// with an empty slice clears the registry (transactional save/delete contract). - #[test] - fn warm_then_clear_registry_empties_lookup() { - let _lock = registry_test_lock(); - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("tmp-agent.json"), - r#"{"id":"tmp-agent","label":"Tmp","command":"tmp-bin"}"#, - ) - .unwrap(); - - warm_harness_registry_from_dir(Some(dir.path())); - assert!(lookup_loaded_harness_by_id("tmp-agent").is_some()); - - // Simulate delete — re-warm with empty dir. - let empty_dir = tempfile::tempdir().unwrap(); - warm_harness_registry_from_dir(Some(empty_dir.path())); - assert!( - lookup_loaded_harness_by_id("tmp-agent").is_none(), - "deleted harness must not appear after re-warm" - ); - } - - // ── Legacy avatarUrl regression (F1) ───────────────────────────────────── - - /// A JSON file that contains a legacy `avatarUrl` field (from pre-BYOH code) - /// must still deserialize without error (unknown-field handling) and the - /// loaded `HarnessDefinition` must NOT carry the URL — the field is absent - /// from the struct so serde drops it. - #[test] - fn legacy_avatar_url_in_json_is_silently_dropped_on_load() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join("legacy.json"), - r#"{ - "id": "legacy-agent", - "label": "Legacy Agent", - "command": "legacy-bin", - "avatarUrl": "https://tracking.example.com/logo.png" - }"#, - ) - .unwrap(); - - let defs = load_custom_harnesses(dir.path()); - // The file must deserialize successfully (serde ignores unknown fields). - assert_eq!(defs.len(), 1, "legacy file with avatarUrl must still load"); - assert_eq!(defs[0].id, "legacy-agent"); - // HarnessDefinition has no avatar_url field — prove the URL cannot - // be routed to a catalog entry by serializing back and checking. - let json = serde_json::to_string(&defs[0]).unwrap(); - assert!( - !json.contains("https://tracking.example.com"), - "serialized HarnessDefinition must not contain the legacy avatar URL" - ); - } - - // ── Preset id reservation ──────────────────────────────────────────────── - - /// All preset ids must be blocked by `check_id_collision`. - #[test] - fn preset_ids_are_reserved_and_cannot_be_used_as_custom_ids() { - // Derived from PRESET_HARNESSES — no hard-coded copy here so this test - // automatically covers any future preset additions. - for id in crate::managed_agents::discovery::preset_harness_ids() { - assert!( - check_id_collision(id).is_err(), - "preset id {id:?} should be rejected by check_id_collision" - ); - } - } -} +#[path = "custom_harnesses_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/custom_harnesses_tests.rs b/desktop/src-tauri/src/managed_agents/custom_harnesses_tests.rs new file mode 100644 index 00000000000..9ec7e64324a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/custom_harnesses_tests.rs @@ -0,0 +1,977 @@ +//! Unit tests for `managed_agents/custom_harnesses.rs`. +//! +//! Kept in a sibling file so `custom_harnesses.rs` stays closer to the +//! 1000-line gate; `#[path]`-included from there. + +use super::*; +use std::fs; + +// ── ID validation ──────────────────────────────────────────────────────── + +#[test] +fn valid_id_lowercase_with_hyphen() { + assert!(is_valid_harness_id("my-agent")); +} + +#[test] +fn valid_id_underscore_start() { + assert!(is_valid_harness_id("_my_agent")); +} + +#[test] +fn valid_id_alphanumeric() { + assert!(is_valid_harness_id("agent42")); +} + +#[test] +fn invalid_id_uppercase() { + assert!(!is_valid_harness_id("MyAgent")); +} + +#[test] +fn invalid_id_starts_with_hyphen() { + assert!(!is_valid_harness_id("-bad-id")); +} + +#[test] +fn invalid_id_empty() { + assert!(!is_valid_harness_id("")); +} + +#[test] +fn invalid_id_path_traversal() { + assert!(!is_valid_harness_id("../etc/passwd")); +} + +// ── Collision check ────────────────────────────────────────────────────── + +#[test] +fn builtin_ids_are_rejected() { + // Tier-1 hard-coded IDs must always be reserved. + for id in &["goose", "claude", "codex", "buzz-agent"] { + assert!(check_id_collision(id).is_err(), "{id} should be rejected"); + } + // Tier-2 preset IDs must also be reserved (derived from PRESET_HARNESSES). + for id in crate::managed_agents::discovery::preset_harness_ids() { + assert!(check_id_collision(id).is_err(), "{id} should be rejected"); + } +} + +#[test] +fn unknown_id_passes_collision_check() { + assert!(check_id_collision("my-custom-agent").is_ok()); +} + +// ── File loading ───────────────────────────────────────────────────────── + +#[test] +fn load_valid_json_returns_definition() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("my-agent.json"), + r#"{"id":"my-agent","label":"My Agent","command":"my-agent-bin"}"#, + ) + .unwrap(); + + let defs = load_custom_harnesses(dir.path()); + assert_eq!(defs.len(), 1); + assert_eq!(defs[0].id, "my-agent"); + assert_eq!(defs[0].label, "My Agent"); + assert_eq!(defs[0].command, "my-agent-bin"); +} + +#[test] +fn load_skips_non_json_files() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("my-agent.toml"), r#"id = "my-agent""#).unwrap(); + + let defs = load_custom_harnesses(dir.path()); + assert_eq!(defs.len(), 0, "non-JSON file should be ignored"); +} + +#[test] +fn load_skips_invalid_json_without_panicking() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("bad.json"), "{ not valid json").unwrap(); + + // Must not panic or propagate an error. + let defs = load_custom_harnesses(dir.path()); + assert_eq!(defs.len(), 0); +} + +#[test] +fn load_skips_definition_with_invalid_id() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("Bad.json"), + r#"{"id":"Bad-Id","label":"Bad","command":"bad"}"#, + ) + .unwrap(); + + let defs = load_custom_harnesses(dir.path()); + assert_eq!( + defs.len(), + 0, + "invalid id should cause the entry to be skipped" + ); +} + +#[test] +fn load_skips_definition_with_empty_command() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("empty-cmd.json"), + r#"{"id":"empty-cmd","label":"Empty","command":""}"#, + ) + .unwrap(); + + let defs = load_custom_harnesses(dir.path()); + assert_eq!( + defs.len(), + 0, + "empty command should cause the entry to be skipped" + ); +} + +#[test] +fn load_skips_definition_with_non_http_install_url() { + // installInstructionsUrl must start with https:// or http://. + // A bare path, javascript: URI, or other scheme is rejected. + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("bad-url.json"), + r#"{"id":"bad-url","label":"Bad","command":"bad-bin","installInstructionsUrl":"file:///etc/passwd"}"#, + ) + .unwrap(); + + let defs = load_custom_harnesses(dir.path()); + assert_eq!( + defs.len(), + 0, + "non-http install URL should cause the entry to be skipped" + ); +} + +#[test] +fn load_accepts_empty_or_https_install_url() { + let dir = tempfile::tempdir().unwrap(); + // Empty URL is fine (optional field). + fs::write( + dir.path().join("no-url.json"), + r#"{"id":"no-url","label":"No URL","command":"no-url-bin"}"#, + ) + .unwrap(); + // https:// is accepted. + fs::write( + dir.path().join("good-url.json"), + r#"{"id":"good-url","label":"Good URL","command":"good-bin","installInstructionsUrl":"https://example.com/install"}"#, + ) + .unwrap(); + + let defs = load_custom_harnesses(dir.path()); + assert_eq!( + defs.len(), + 2, + "empty and https:// URLs must both be accepted" + ); +} + +#[test] +fn load_missing_dir_returns_empty_vec() { + let dir = tempfile::tempdir().unwrap(); + let nonexistent = dir.path().join("does_not_exist"); + + let defs = load_custom_harnesses(&nonexistent); + assert_eq!(defs.len(), 0); +} + +#[test] +fn load_continues_after_one_bad_entry() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("bad.json"), "!!!").unwrap(); + fs::write( + dir.path().join("good.json"), + r#"{"id":"good-one","label":"Good","command":"good-binary"}"#, + ) + .unwrap(); + + let defs = load_custom_harnesses(dir.path()); + assert_eq!(defs.len(), 1, "bad entry skipped, good entry loaded"); + assert_eq!(defs[0].id, "good-one"); +} + +#[test] +fn load_applies_id_collision_check() { + // A custom file whose id shadows a built-in ("goose") must be dropped + // BY THE LOADER — `load_custom_harnesses` is the enforcement boundary + // shared by both the warm path and discovery. This exercises the real + // loader against a real file, not just the helper predicate. + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("goose.json"), + r#"{"id":"goose","label":"Not Goose","command":"goose","args":["--evil"]}"#, + ) + .unwrap(); + assert!( + load_custom_harnesses(dir.path()).is_empty(), + "loader must drop a file shadowing a builtin id" + ); + assert!(check_id_collision("goose").is_err()); + assert!(check_id_collision("custom-goose").is_ok()); +} + +#[test] +fn load_dedups_duplicate_ids_first_file_wins() { + // Two files carrying the same custom id: the loader must keep exactly + // one definition (directory-order first wins; the duplicate is dropped). + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("a.json"), + r#"{"id":"custom-dup","label":"First","command":"first-cmd"}"#, + ) + .unwrap(); + fs::write( + dir.path().join("b.json"), + r#"{"id":"custom-dup","label":"Second","command":"second-cmd"}"#, + ) + .unwrap(); + let loaded = load_custom_harnesses(dir.path()); + assert_eq!( + loaded.len(), + 1, + "loader must dedup duplicate ids within the directory" + ); + assert_eq!(loaded[0].id, "custom-dup"); +} + +// ── Round-trip via save_custom_harness_to_dir (B-4) ───────────────────── +// +// These tests exercise the REAL persistence helper, not raw fs::write. +// They prove: create, same-ID edit (backup-swap), rename (old file removed), +// backup file cleaned up on success. + +fn make_def(id: &str, label: &str) -> HarnessDefinition { + HarnessDefinition { + id: id.to_string(), + label: label.to_string(), + command: format!("{id}-bin"), + args: vec![], + env: BTreeMap::new(), + env_ref: None, + env_unavailable: false, + install_instructions_url: String::new(), + install_hint: String::new(), + } +} + +#[test] +fn save_to_dir_create_writes_file_and_loads_back() { + let dir = tempfile::tempdir().unwrap(); + let def = make_def("my-harness", "My Harness"); + + let outcome = save_custom_harness_to_dir(dir.path(), &def, None).unwrap(); + + assert_eq!(outcome.target_path, dir.path().join("my-harness.json")); + assert!(outcome.removed_old_path.is_none(), "no old file on create"); + + let loaded = load_custom_harnesses(dir.path()); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, "my-harness"); + assert_eq!(loaded[0].label, "My Harness"); +} + +#[test] +fn save_to_dir_same_id_edit_replaces_content() { + let dir = tempfile::tempdir().unwrap(); + let v1 = make_def("my-harness", "V1 Label"); + save_custom_harness_to_dir(dir.path(), &v1, None).unwrap(); + + // Same-ID edit: label changes. + let v2 = make_def("my-harness", "V2 Label"); + let outcome = save_custom_harness_to_dir(dir.path(), &v2, None).unwrap(); + + // No old-path reported (id unchanged). + assert!(outcome.removed_old_path.is_none()); + + let loaded = load_custom_harnesses(dir.path()); + assert_eq!(loaded.len(), 1, "same-id edit must not duplicate entries"); + assert_eq!(loaded[0].label, "V2 Label", "v2 content must be present"); +} + +#[test] +fn save_to_dir_backup_is_cleaned_up_after_same_id_edit() { + let dir = tempfile::tempdir().unwrap(); + let v1 = make_def("my-harness", "V1"); + save_custom_harness_to_dir(dir.path(), &v1, None).unwrap(); + + let v2 = make_def("my-harness", "V2"); + save_custom_harness_to_dir(dir.path(), &v2, None).unwrap(); + + // .bak file must be gone after a successful commit. + let bak = dir.path().join("my-harness.json.bak"); + assert!( + !bak.exists(), + ".bak file must be removed after successful same-id edit" + ); +} + +#[test] +fn save_to_dir_rename_removes_old_file_and_creates_new() { + let dir = tempfile::tempdir().unwrap(); + let old_def = make_def("old-id", "Old"); + save_custom_harness_to_dir(dir.path(), &old_def, None).unwrap(); + + // Rename: new id, old_id supplied. + let new_def = make_def("new-id", "New"); + let outcome = save_custom_harness_to_dir(dir.path(), &new_def, Some("old-id")).unwrap(); + + // The outcome carries the old path that was removed. + let expected_old = dir.path().join("old-id.json"); + assert_eq!( + outcome.removed_old_path, + Some(expected_old.clone()), + "removed_old_path must be the old file" + ); + + // Old file gone, new file present. + assert!(!expected_old.exists(), "old-id.json must be removed"); + let loaded = load_custom_harnesses(dir.path()); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, "new-id"); +} + +#[test] +fn save_to_dir_rename_nonexistent_old_id_is_non_fatal() { + // rename_old_id pointing to a file that does not exist must succeed + // (NotFound is silently ignored by the helper). + let dir = tempfile::tempdir().unwrap(); + let def = make_def("alpha", "Alpha"); + let outcome = save_custom_harness_to_dir(dir.path(), &def, Some("ghost-id")).unwrap(); + + // New file created, no old path removed. + assert_eq!(outcome.target_path, dir.path().join("alpha.json")); + assert!( + outcome.removed_old_path.is_none(), + "NotFound old-id must not be reported as removed" + ); + assert!(load_custom_harnesses(dir.path()).len() == 1); +} + +// ── Env secret projection (save strips → keyring; load hydrates) ───────── +// +// These drive the store-injected `*_with` seams against an in-memory fake so +// they are deterministic and never touch the live OS keyring (the default +// `system-keyring` feature makes the live store real under `cargo test`). +// The fake mirrors `secret_seam_tests::FakeProjectionStore`. + +use crate::managed_agents::secret_projection::{deserialize_env_map, ProjectionStore}; +use std::cell::RefCell; +use std::collections::HashMap; + +/// In-memory projection store: every write succeeds and is recoverable. +struct FakeProjectionStore { + data: RefCell>, +} + +impl FakeProjectionStore { + fn new() -> Self { + Self { + data: RefCell::new(HashMap::new()), + } + } + fn len(&self) -> usize { + self.data.borrow().len() + } +} + +impl ProjectionStore for FakeProjectionStore { + fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String> { + self.data + .borrow_mut() + .insert(key.to_string(), value.to_string()); + Ok(()) + } + fn load_key(&self, key: &str) -> Result, String> { + Ok(self.data.borrow().get(key).cloned()) + } + fn load_all(&self) -> Result>, String> { + Ok(Some(self.data.borrow().clone())) + } + fn store_batch(&self, entries: &HashMap) -> Result<(), String> { + for (k, v) in entries { + self.data.borrow_mut().insert(k.clone(), v.clone()); + } + Ok(()) + } + fn remove_batch(&self, keys: &[&str]) -> Result<(), String> { + for k in keys { + self.data.borrow_mut().remove(*k); + } + Ok(()) + } +} + +/// Projection store whose writes always fail — models a keyring outage that +/// forces the inline `0o600` fallback (`WriteOutcome::KeptInline`). +struct FailingWriteStore; + +impl ProjectionStore for FailingWriteStore { + fn write_and_verify(&self, _key: &str, _value: &str) -> Result<(), String> { + Err("simulated keyring write failure".to_string()) + } + fn load_key(&self, _key: &str) -> Result, String> { + Ok(None) + } + fn load_all(&self) -> Result>, String> { + Ok(Some(HashMap::new())) + } + fn store_batch(&self, _entries: &HashMap) -> Result<(), String> { + Ok(()) + } + fn remove_batch(&self, _keys: &[&str]) -> Result<(), String> { + Ok(()) + } +} + +fn env_harness_def() -> HarnessDefinition { + let mut env = BTreeMap::new(); + env.insert("ANTHROPIC_API_KEY".to_string(), "sk-ant-secret".to_string()); + HarnessDefinition { + id: "env-harness".to_string(), + label: "Env Harness".to_string(), + command: "env-bin".to_string(), + args: vec!["--flag".to_string()], + env, + env_ref: None, + env_unavailable: false, + install_instructions_url: "https://example.com".to_string(), + install_hint: "Install from example.com".to_string(), + } +} + +#[test] +fn save_projects_env_to_keyring_and_leaves_no_plaintext_on_disk() { + let store = FakeProjectionStore::new(); + let dir = tempfile::tempdir().unwrap(); + let def = env_harness_def(); + + let outcome = save_custom_harness_to_dir_with(Some(&store), dir.path(), &def, None).unwrap(); + + let raw = fs::read_to_string(&outcome.target_path).unwrap(); + assert!( + !raw.contains("sk-ant-secret"), + "the secret value must never be written to disk in plaintext" + ); + // The on-disk record carries an env_ref and an empty inline env. + let on_disk: HarnessDefinition = serde_json::from_str(&raw).unwrap(); + assert!( + on_disk.env.is_empty(), + "inline env must be stripped on disk" + ); + let gen = on_disk + .env_ref + .expect("env_ref must point at the projected gen"); + // The keyring fake holds the env verbatim under the harness coordinate. + let stored = store + .load_key(&harness_env_key("env-harness", &gen)) + .unwrap() + .expect("keyring must hold the projected env"); + assert_eq!( + deserialize_env_map(&stored) + .unwrap() + .get("ANTHROPIC_API_KEY"), + Some(&"sk-ant-secret".to_string()) + ); +} + +#[test] +fn save_then_load_with_same_fake_roundtrips_env_values() { + let store = FakeProjectionStore::new(); + let dir = tempfile::tempdir().unwrap(); + let def = env_harness_def(); + + save_custom_harness_to_dir_with(Some(&store), dir.path(), &def, None).unwrap(); + + let loaded = load_custom_harnesses_with(Some(&store), dir.path()); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].args, vec!["--flag"]); + assert_eq!( + loaded[0].env.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("sk-ant-secret"), + "env must hydrate back from the keyring on load" + ); +} + +#[test] +fn save_does_not_mutate_the_caller_definition() { + // The catalog entry the UI keeps after a save must retain the full env for + // the edit round-trip — the seam strips a clone, never the caller's def. + let store = FakeProjectionStore::new(); + let dir = tempfile::tempdir().unwrap(); + let def = env_harness_def(); + + save_custom_harness_to_dir_with(Some(&store), dir.path(), &def, None).unwrap(); + + assert_eq!( + def.env.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("sk-ant-secret"), + "caller's definition must keep its full env after save" + ); + assert!(def.env_ref.is_none(), "caller's def must be untouched"); +} + +#[test] +fn save_empty_env_writes_no_ref_and_no_keyring_entry() { + let store = FakeProjectionStore::new(); + let dir = tempfile::tempdir().unwrap(); + let def = make_def("bare", "Bare"); // env is empty + + let outcome = save_custom_harness_to_dir_with(Some(&store), dir.path(), &def, None).unwrap(); + + assert_eq!( + store.len(), + 0, + "an empty env must not mint a keyring generation" + ); + let on_disk: HarnessDefinition = + serde_json::from_str(&fs::read_to_string(&outcome.target_path).unwrap()).unwrap(); + assert!(on_disk.env.is_empty()); + assert!(on_disk.env_ref.is_none(), "no env means no env_ref on disk"); +} + +#[test] +fn save_keyring_write_failure_keeps_env_inline_with_ref_cleared() { + // A keyring outage must fall back to the inline `0o600` JSON so the harness + // still resolves — with env_ref cleared (inline is authoritative). + let store = FailingWriteStore; + let dir = tempfile::tempdir().unwrap(); + let def = env_harness_def(); + + let outcome = save_custom_harness_to_dir_with(Some(&store), dir.path(), &def, None).unwrap(); + + let on_disk: HarnessDefinition = + serde_json::from_str(&fs::read_to_string(&outcome.target_path).unwrap()).unwrap(); + assert_eq!( + on_disk.env.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("sk-ant-secret"), + "on a keyring write failure the env stays inline as the fallback" + ); + assert!( + on_disk.env_ref.is_none(), + "the ref must be cleared so inline wins on the next hydrate" + ); +} + +/// Inline env survives even without a store (keyless build): the env is written +/// inline to the `0o600` JSON and hydrates straight from disk. +#[test] +fn save_without_store_keeps_env_inline_and_roundtrips() { + let dir = tempfile::tempdir().unwrap(); + let def = env_harness_def(); + + save_custom_harness_to_dir_with::(None, dir.path(), &def, None).unwrap(); + + let loaded = load_custom_harnesses_with::(None, dir.path()); + assert_eq!(loaded.len(), 1); + assert_eq!( + loaded[0].env.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("sk-ant-secret"), + "keyless build must keep env inline and round-trip it" + ); +} + +#[cfg(unix)] +#[test] +fn save_written_file_is_owner_only_0o600() { + use std::os::unix::fs::PermissionsExt; + let store = FakeProjectionStore::new(); + let dir = tempfile::tempdir().unwrap(); + let def = env_harness_def(); + + let outcome = save_custom_harness_to_dir_with(Some(&store), dir.path(), &def, None).unwrap(); + + let mode = fs::metadata(&outcome.target_path) + .unwrap() + .permissions() + .mode(); + assert_eq!( + mode & 0o777, + 0o600, + "harness file must be created owner-only before any bytes hit disk" + ); +} + +// `env_unavailable` marker tests (hydrate/save/rename) live in a sibling file +// so this module stays under the desktop file-size ratchet. +#[path = "env_unavailable_tests.rs"] +mod env_unavailable_tests; + +// ── B-3: env validation boundary (validate_harness_definition_pub integration) ── + +#[test] +fn validate_rejects_malformed_key_with_equals_sign() { + // BUZZ_AUTH_TAG=x is the documented reserved-key bypass shape: + // the key contains '=' so Command::env would produce + // `BUZZ_AUTH_TAG=x=forged` in the child env. + let mut env = BTreeMap::new(); + env.insert("BUZZ_AUTH_TAG=x".to_string(), "forged".to_string()); + let def = HarnessDefinition { + id: "bad-env".to_string(), + label: "Bad".to_string(), + command: "bad-bin".to_string(), + args: vec![], + env, + env_ref: None, + env_unavailable: false, + install_instructions_url: String::new(), + install_hint: String::new(), + }; + let err = validate_harness_definition_pub(&def).unwrap_err(); + assert!( + err.contains("env var keys must match"), + "malformed key must be rejected: {err}" + ); + assert!( + err.contains("BUZZ_AUTH_TAG"), + "error must name the offending key: {err}" + ); +} + +#[test] +fn validate_rejects_reserved_key_buzz_managed_agent() { + // BUZZ_MANAGED_AGENT and BUZZ_MANAGED_AGENT_START_NONCE are the + // ownership markers — supplying them in a definition must be rejected. + let mut env = BTreeMap::new(); + env.insert( + "BUZZ_MANAGED_AGENT".to_string(), + "fake-instance".to_string(), + ); + let def = HarnessDefinition { + id: "bad-marker".to_string(), + label: "Bad".to_string(), + command: "bad-bin".to_string(), + args: vec![], + env, + env_ref: None, + env_unavailable: false, + install_instructions_url: String::new(), + install_hint: String::new(), + }; + let err = validate_harness_definition_pub(&def).unwrap_err(); + assert!( + err.contains("reserved by Buzz"), + "ownership marker key must be rejected: {err}" + ); +} + +#[test] +fn validate_rejects_reserved_key_case_insensitive() { + // BUZZ_PRIVATE_KEY in any casing must be blocked. + let mut env = BTreeMap::new(); + env.insert("buzz_private_key".to_string(), "secret".to_string()); + let def = HarnessDefinition { + id: "ci-marker".to_string(), + label: "CI".to_string(), + command: "ci-bin".to_string(), + args: vec![], + env, + env_ref: None, + env_unavailable: false, + install_instructions_url: String::new(), + install_hint: String::new(), + }; + let err = validate_harness_definition_pub(&def).unwrap_err(); + assert!( + err.contains("reserved by Buzz"), + "reserved key must be blocked case-insensitively: {err}" + ); +} + +#[test] +fn validate_rejects_nul_byte_in_value() { + // A NUL in a value would cause Command::env to panic at spawn time. + let mut env = BTreeMap::new(); + env.insert("MY_KEY".to_string(), "val\x00ue".to_string()); + let def = HarnessDefinition { + id: "nul-val".to_string(), + label: "NUL".to_string(), + command: "nul-bin".to_string(), + args: vec![], + env, + env_ref: None, + env_unavailable: false, + install_instructions_url: String::new(), + install_hint: String::new(), + }; + let err = validate_harness_definition_pub(&def).unwrap_err(); + assert!( + err.contains("NUL bytes"), + "NUL value must be rejected at validation: {err}" + ); +} + +#[test] +fn validate_rejects_value_over_per_value_size_limit() { + use crate::managed_agents::env_vars::MAX_ENV_VALUE_BYTES; + let mut env = BTreeMap::new(); + // One byte over the per-value cap. + env.insert("BIG_VAL".to_string(), "x".repeat(MAX_ENV_VALUE_BYTES + 1)); + let def = HarnessDefinition { + id: "big-val".to_string(), + label: "Big".to_string(), + command: "big-bin".to_string(), + args: vec![], + env, + env_ref: None, + env_unavailable: false, + install_instructions_url: String::new(), + install_hint: String::new(), + }; + let err = validate_harness_definition_pub(&def).unwrap_err(); + assert!( + err.contains("per-value limit"), + "oversized value must be rejected: {err}" + ); +} + +#[test] +fn validate_accepts_well_formed_env() { + let mut env = BTreeMap::new(); + env.insert("ANTHROPIC_API_KEY".to_string(), "sk-test-123".to_string()); + env.insert("MODEL_VERSION".to_string(), "claude-3".to_string()); + let def = HarnessDefinition { + id: "good-env".to_string(), + label: "Good".to_string(), + command: "good-bin".to_string(), + args: vec![], + env, + env_ref: None, + env_unavailable: false, + install_instructions_url: String::new(), + install_hint: String::new(), + }; + assert!( + validate_harness_definition_pub(&def).is_ok(), + "well-formed definition must pass validation" + ); +} + +// ── Comma-in-args validation (transport-lossiness guard) ───────────────── + +/// A definition whose args contain a literal comma must be rejected at the +/// validation boundary — the comma-delimited `BUZZ_ACP_AGENT_ARGS` +/// transport would silently split it into two args at spawn time. +#[test] +fn validate_rejects_comma_in_args() { + let mut def = make_def("comma-args", "Comma"); + def.args = vec!["--name".to_string(), "a,b".to_string()]; + let err = validate_harness_definition_pub(&def).unwrap_err(); + assert!( + err.contains("comma"), + "error must explain the comma transport limit, got: {err}" + ); +} + +/// Comma-free args pass — including args with spaces and special chars. +#[test] +fn validate_accepts_comma_free_args() { + let mut def = make_def("clean-args", "Clean"); + def.args = vec!["acp".to_string(), "--flag=x y".to_string()]; + assert!(validate_harness_definition_pub(&def).is_ok()); +} + +/// The loader shares the same validator: a hand-authored file with a comma +/// in args is skipped, so the invariant holds regardless of how the +/// definition arrives (UI save or hand-edited JSON). +#[test] +fn load_skips_definition_with_comma_in_args() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("comma.json"), + r#"{"id":"comma-file","label":"Comma","command":"comma-bin","args":["a,b"]}"#, + ) + .unwrap(); + assert!( + load_custom_harnesses(dir.path()).is_empty(), + "comma-in-args definition must be skipped at the loader boundary" + ); +} + +// ── Discovery publish under persist_mutex (stale-snapshot regression) ──── + +/// Discovery's registry publish must re-read the directory at publish time +/// (under the persist mutex), not push a snapshot taken before the auth +/// probes ran. Regression shape: discovery scans dir → user saves harness X +/// (save_and_warm warms the registry with X) → discovery finishes. If +/// discovery published its pre-save snapshot, X would be on disk but +/// unresolvable at spawn until the next discover. +/// +/// Deterministic interleaving: we simulate it by calling the publish seam +/// (`warm_harness_registry_locked`) after a save that happened "during" +/// discovery — the fresh-read semantics mean the just-saved definition +/// survives the publish. +#[test] +fn discovery_publish_after_concurrent_save_keeps_saved_harness() { + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + + // Discovery "scans" the dir while it is empty (stale snapshot would be []). + let stale_snapshot = load_custom_harnesses(dir.path()); + assert!(stale_snapshot.is_empty()); + + // A save lands mid-discovery (save_and_warm: write + warm). + let def = make_def("mid-save", "Mid Save"); + save_and_warm(dir.path(), &def, None).unwrap(); + assert!(lookup_loaded_harness_by_id("mid-save").is_some()); + + // Discovery publishes — the locked warm re-reads the directory, so the + // just-saved harness must survive (a stale-snapshot publish would + // clobber it). + warm_harness_registry_locked(Some(dir.path())); + assert!( + lookup_loaded_harness_by_id("mid-save").is_some(), + "publish must re-read the directory, not clobber the mid-discovery save" + ); +} + +/// Same shape for delete: a delete landing mid-discovery must not be +/// resurrected by the discovery publish. +#[test] +fn discovery_publish_after_concurrent_delete_keeps_harness_gone() { + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + + let def = make_def("mid-delete", "Mid Delete"); + save_and_warm(dir.path(), &def, None).unwrap(); + + // Discovery "scans" while the file exists (stale snapshot would contain it). + let stale_snapshot = load_custom_harnesses(dir.path()); + assert_eq!(stale_snapshot.len(), 1); + + // Delete lands mid-discovery. + delete_and_warm(dir.path(), "mid-delete").unwrap(); + assert!(lookup_loaded_harness_by_id("mid-delete").is_none()); + + // Discovery publishes — fresh read keeps it gone. + warm_harness_registry_locked(Some(dir.path())); + assert!( + lookup_loaded_harness_by_id("mid-delete").is_none(), + "publish must not resurrect a harness deleted mid-discovery" + ); +} + +// ── Registry warm path ─────────────────────────────────────────────────── + +/// After `warm_harness_registry_from_dir` the registry contains preset + +/// custom definitions and `lookup_loaded_harness_by_id` resolves them. +#[test] +fn warm_registry_then_lookup_finds_custom_and_preset_entries() { + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("my-custom.json"), + r#"{"id":"my-custom","label":"My Custom","command":"my-custom-bin"}"#, + ) + .unwrap(); + + warm_harness_registry_from_dir(Some(dir.path())); + + // Custom entry must be findable. + let found = lookup_loaded_harness_by_id("my-custom"); + assert!( + found.is_some(), + "warm registry must contain the custom entry" + ); + assert_eq!(found.unwrap().command, "my-custom-bin"); + + // At least one preset entry must be in the registry (e.g. "cursor"). + let preset = lookup_loaded_harness_by_id("cursor"); + assert!( + preset.is_some(), + "warm registry must contain preset entries" + ); +} + +/// `warm_harness_registry_from_dir` with `None` still loads presets. +#[test] +fn warm_registry_with_no_custom_dir_loads_presets_only() { + let _lock = registry_test_lock(); + warm_harness_registry_from_dir(None); + // At least the "cursor" preset must be present. + assert!( + lookup_loaded_harness_by_id("cursor").is_some(), + "presets must be reachable even without a custom dir" + ); +} + +/// `warm_harness_registry_from_dir` followed by `update_loaded_harness_registry` +/// with an empty slice clears the registry (transactional save/delete contract). +#[test] +fn warm_then_clear_registry_empties_lookup() { + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("tmp-agent.json"), + r#"{"id":"tmp-agent","label":"Tmp","command":"tmp-bin"}"#, + ) + .unwrap(); + + warm_harness_registry_from_dir(Some(dir.path())); + assert!(lookup_loaded_harness_by_id("tmp-agent").is_some()); + + // Simulate delete — re-warm with empty dir. + let empty_dir = tempfile::tempdir().unwrap(); + warm_harness_registry_from_dir(Some(empty_dir.path())); + assert!( + lookup_loaded_harness_by_id("tmp-agent").is_none(), + "deleted harness must not appear after re-warm" + ); +} + +// ── Legacy avatarUrl regression (F1) ───────────────────────────────────── + +/// A JSON file that contains a legacy `avatarUrl` field (from pre-BYOH code) +/// must still deserialize without error (unknown-field handling) and the +/// loaded `HarnessDefinition` must NOT carry the URL — the field is absent +/// from the struct so serde drops it. +#[test] +fn legacy_avatar_url_in_json_is_silently_dropped_on_load() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("legacy.json"), + r#"{ + "id": "legacy-agent", + "label": "Legacy Agent", + "command": "legacy-bin", + "avatarUrl": "https://tracking.example.com/logo.png" + }"#, + ) + .unwrap(); + + let defs = load_custom_harnesses(dir.path()); + // The file must deserialize successfully (serde ignores unknown fields). + assert_eq!(defs.len(), 1, "legacy file with avatarUrl must still load"); + assert_eq!(defs[0].id, "legacy-agent"); + // HarnessDefinition has no avatar_url field — prove the URL cannot + // be routed to a catalog entry by serializing back and checking. + let json = serde_json::to_string(&defs[0]).unwrap(); + assert!( + !json.contains("https://tracking.example.com"), + "serialized HarnessDefinition must not contain the legacy avatar URL" + ); +} + +// ── Preset id reservation ──────────────────────────────────────────────── + +/// All preset ids must be blocked by `check_id_collision`. +#[test] +fn preset_ids_are_reserved_and_cannot_be_used_as_custom_ids() { + // Derived from PRESET_HARNESSES — no hard-coded copy here so this test + // automatically covers any future preset additions. + for id in crate::managed_agents::discovery::preset_harness_ids() { + assert!( + check_id_collision(id).is_err(), + "preset id {id:?} should be rejected by check_id_collision" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/dev_service_migration.rs b/desktop/src-tauri/src/managed_agents/dev_service_migration.rs new file mode 100644 index 00000000000..90c1a4dacfa --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/dev_service_migration.rs @@ -0,0 +1,761 @@ +//! Dev-build keyring secrets migration. +//! +//! Extracted from `storage.rs` to keep that module under the desktop +//! file-size ratchet. Copies secret-projection keys into the dev keyring +//! service on debug boots; a no-op in release builds. + +use crate::app_state::keyring_service; +use tauri::Manager; + +/// Marker key for the secrets migration to the dev keyring service. +/// Versioned separately from `_dev_migration_v1` (which covers only nsec keys) +/// so this migration runs even for installs that already completed v1. +#[cfg(debug_assertions)] +const DEV_SECRETS_MIGRATION_MARKER: &str = "_dev_secrets_migration_v2"; + +/// One-time migration of secret-projection keys (env vars, auth tags, provider +/// configs, definition env vars) from the source keyring service to the +/// destination dev service. +/// +/// Called at dev-build boot, AFTER `migrate_agent_keys_to_dev_service` and +/// AFTER `migrate_inline_secrets_to_keyring` (so projection keys are present +/// in the source before we copy them). +/// +/// Source determination: +/// - `buzz-desktop-dev` → source is `buzz-desktop` (production). +/// - `buzz-desktop-dev.` (scoped) → source is `buzz-desktop-dev` +/// (canonical dev), NOT production. +/// +/// `global:env` is only copied when the destination's JSON reference requires +/// it (`global-agent-config.json` is not in `SHARED_AGENT_FILES`). +/// +/// A coordinate present in both source and destination with different values +/// is a conflict; the migration fails closed for that coordinate and logs an +/// error rather than silently overwriting. +/// +/// Idempotent: guarded by `DEV_SECRETS_MIGRATION_MARKER`; skips any key +/// already present in the destination. +#[cfg(debug_assertions)] +pub fn migrate_agent_secrets_to_dev_service(app: &tauri::AppHandle) { + if !cfg!(feature = "system-keyring") { + return; + } + let dest_service = keyring_service(); + if dest_service == "buzz-desktop" { + return; // never run in a release build via this path + } + // Determine source: scoped dev → canonical dev; canonical dev → prod. + let is_scoped = dest_service != "buzz-desktop-dev"; + let src_service = if is_scoped { + "buzz-desktop-dev" + } else { + "buzz-desktop" + }; + + let dest_store = crate::secret_store::SecretStore::shared(dest_service); + + // Read destination blob. If the v2 migration marker is present, we ran + // this migration already — skip entirely. + let dest_map: std::collections::HashMap = match dest_store.load_all_readonly() { + Ok(Some(map)) if map.contains_key(DEV_SECRETS_MIGRATION_MARKER) => { + return; // already done + } + Ok(Some(map)) => map, + Ok(None) => std::collections::HashMap::new(), + Err(e) => { + eprintln!( + "buzz-desktop: keyring-dev-secrets-migration: \ + cannot read dest keyring ({dest_service}): {e}" + ); + return; + } + }; + + let src_store = crate::secret_store::SecretStore::keyring(src_service); + let src_map: std::collections::HashMap = match src_store.load_all_readonly() { + Ok(Some(map)) => map, + Ok(None) => std::collections::HashMap::new(), + Err(e) => { + eprintln!( + "buzz-desktop: keyring-dev-secrets-migration: \ + cannot read src keyring ({src_service}): {e}" + ); + return; + } + }; + + // Determine which projection keys to copy: those present in src, absent + // from dest, OR where src and dest agree (idempotent). Keys present in + // both with DIFFERENT values are conflicts — fail closed for that key. + // + // Exclude `global:env:*` from the copy unless the destination JSON + // references it (global-agent-config.json is not a shared file). + let global_refs = collect_global_env_refs(app); + + // Full blob coordinates referenced by canonical JSON. Used to prove a + // conflict marker is safe to clear when its coordinate is no longer live. + // `None` when either store is unreadable/malformed — in that case liveness + // is unknown and only value-convergence can clear a marker (fail closed). + let live_coords = collect_live_projection_coords(app); + + let DevMigrationPlan { + to_write, + conflict_keys, + write_marker, + conflict_markers_to_clear, + } = plan_dev_secrets_migration(&src_map, &dest_map, &global_refs, live_coords.as_ref()); + + for key in &conflict_keys { + eprintln!( + "buzz-desktop: keyring-dev-secrets-migration: \ + conflict on key {key} between {src_service} and {dest_service}; \ + refusing to overwrite and marking it unavailable until resolved \ + (manual resolution required)" + ); + } + let conflict_count = conflict_keys.len(); + + // Clear resolved conflict markers first (store_all only inserts, so a + // stale marker would otherwise linger and keep a now-healthy coordinate + // unavailable forever). Best-effort: a failed clear just retries next boot. + if !conflict_markers_to_clear.is_empty() { + let keys: Vec<&str> = conflict_markers_to_clear + .iter() + .map(String::as_str) + .collect(); + for key in &keys { + if let Err(e) = dest_store.delete(key) { + eprintln!( + "buzz-desktop: keyring-dev-secrets-migration: \ + could not clear resolved conflict marker {key}: {e}" + ); + } + } + } + + // Nothing to persist (no copyable keys, no conflict markers, and — being + // unclean — no completion marker): skip the write entirely so an unclean + // boot with zero copyable keys does not touch the destination keyring. + if to_write.is_empty() { + if conflict_count > 0 { + eprintln!( + "buzz-desktop: keyring-dev-secrets-migration: \ + {conflict_count} conflict(s), nothing copyable; will retry next boot" + ); + } + return; + } + + if let Err(e) = dest_store.store_all(&to_write) { + eprintln!( + "buzz-desktop: keyring-dev-secrets-migration: \ + cannot write to dest keyring ({dest_service}): {e}" + ); + return; + } + + // Subtract the completion marker and any conflict markers from the copied + // count so only real projection copies are reported. + let conflict_marker_count = conflict_keys.len(); + let copied = to_write.len() - usize::from(write_marker) - conflict_marker_count; + if copied > 0 { + eprintln!( + "buzz-desktop: keyring-dev-secrets-migration: \ + copied {copied} projection key(s) from {src_service} → {dest_service}" + ); + } + if conflict_count > 0 { + eprintln!( + "buzz-desktop: keyring-dev-secrets-migration: \ + {conflict_count} key(s) had conflicts and were NOT copied; \ + marker withheld — migration will retry on the next boot" + ); + } +} + +/// Output of [`plan_dev_secrets_migration`]: the batch to write to the +/// destination keyring, the conflicting coordinates, whether the completion +/// marker is included, and the conflict markers to write/clear so a conflicted +/// coordinate is made unavailable (and cleared once it resolves). +#[cfg(debug_assertions)] +struct DevMigrationPlan { + to_write: std::collections::HashMap, + conflict_keys: Vec, + write_marker: bool, + /// `conflict:` marker keys to REMOVE this run: coordinates that + /// previously carried a conflict marker whose conflict is now provably + /// resolved — either source and destination converged on the same value, or + /// the coordinate is proven no longer live in canonical JSON (nothing + /// references it). A source that merely dropped the coordinate while the + /// destination still holds the conflicting value does NOT clear the marker. + /// Clearing the marker lets the coordinate hydrate normally again. + conflict_markers_to_clear: Vec, +} + +/// Pure decision core of [`migrate_agent_secrets_to_dev_service`]: decide which +/// projection keys to copy, whether to write the completion marker, and which +/// conflict markers to write or clear. +/// +/// A projection key present in both stores with DIFFERENT values is a conflict: +/// it is NOT copied and counts toward `conflict_count`. A `conflict:` +/// marker is written into the batch so `load_secret` fails closed for that +/// coordinate — the conflicted value must not be hydrated or consumed while +/// unresolved. The COMPLETION marker (`DEV_SECRETS_MIGRATION_MARKER`) is +/// included only when `conflict_count == 0`: an unclean run still writes its +/// non-conflicting keys AND the per-coordinate conflict markers (partial +/// progress persists), but withholds the completion marker so the migration is +/// retried on the next boot after the user resolves the conflict. +/// +/// A coordinate that previously carried a conflict marker and no longer +/// conflicts is added to `conflict_markers_to_clear` so the availability block +/// is lifted once the conflict resolves. +/// +/// `global:env:*` keys are copied only when the gen id is in `global_refs` +/// (the destination JSON references it) — `global-agent-config.json` is not a +/// shared file, so an unreferenced global gen must not leak across services. +#[cfg(debug_assertions)] +fn plan_dev_secrets_migration( + src_map: &std::collections::HashMap, + dest_map: &std::collections::HashMap, + global_refs: &std::collections::HashSet, + live_coords: Option<&std::collections::HashSet>, +) -> DevMigrationPlan { + use crate::managed_agents::secret_projection::{conflict_marker_key, is_projection_key}; + + let mut to_write: std::collections::HashMap = std::collections::HashMap::new(); + let mut conflict_keys: Vec = Vec::new(); + for (key, src_val) in src_map { + if !is_projection_key(key) { + continue; // skip non-projection keys (nsec, identity markers, etc.) + } + // Skip global:env:* unless the destination JSON references this gen. + if key.starts_with("global:env:") { + let gen = key.trim_start_matches("global:env:"); + if !global_refs.contains(gen) { + continue; + } + } + match dest_map.get(key) { + None => { + to_write.insert(key.clone(), src_val.clone()); + } + Some(dest_val) if dest_val == src_val => { + // Already identical — idempotent, no action needed. + } + Some(_dest_val) => { + // Conflict: src and dest have different values. Fail closed: + // withhold the copy AND write a conflict marker so the + // coordinate cannot be hydrated until the conflict resolves. + conflict_keys.push(key.clone()); + to_write.insert(conflict_marker_key(key), "1".to_string()); + } + } + } + + // Clear a conflict marker ONLY when the conflict is provably resolved. + // Two proofs qualify: + // 1. Source and destination both hold the coordinate AND agree — the + // values converged, so the destination value can be trusted again. + // 2. The coordinate is proven no longer live in canonical JSON — no + // record references it, so nothing will ever hydrate it and the + // two-cycle GC retires the orphaned generation. + // Every other state RETAINS the marker and keeps the coordinate + // unavailable (fail closed). In particular, source-absent while the + // destination still holds the (conflicting) value and canonical JSON still + // references the coordinate is NOT a resolution — a disappearing source is + // not evidence that the destination value won. When liveness cannot be + // proven (`live_coords` is `None` because a store was unreadable), only + // proof #1 can clear a marker. + let still_conflicting: std::collections::HashSet<&str> = + conflict_keys.iter().map(String::as_str).collect(); + let conflict_markers_to_clear: Vec = dest_map + .keys() + .filter(|k| k.starts_with(NS_CONFLICT_PREFIX)) + .filter(|marker| { + let coord = marker.trim_start_matches(NS_CONFLICT_PREFIX); + // A coordinate re-flagged as conflicting this run is also being + // (re)written as a marker above — never clear it in the same pass. + if still_conflicting.contains(coord) { + return false; + } + let converged = matches!( + (src_map.get(coord), dest_map.get(coord)), + (Some(s), Some(d)) if s == d + ); + let not_live = live_coords.is_some_and(|live| !live.contains(coord)); + converged || not_live + }) + .cloned() + .collect(); + + let write_marker = conflict_keys.is_empty(); + if write_marker { + to_write.insert(DEV_SECRETS_MIGRATION_MARKER.to_string(), "done".to_string()); + } + + DevMigrationPlan { + to_write, + conflict_keys, + write_marker, + conflict_markers_to_clear, + } +} + +/// The `conflict:` namespace prefix, mirrored from `secret_projection` so the +/// planner can recognize existing conflict markers in the destination blob. +#[cfg(debug_assertions)] +const NS_CONFLICT_PREFIX: &str = "conflict:"; + +/// Collect the set of generation IDs referenced by `global-agent-config.json` +/// for the purposes of the dev secrets migration (to decide whether to copy +/// `global:env:*` keys). Returns an empty set when the file is absent or +/// unparseable — in that case no global env refs exist in the destination. +#[cfg(debug_assertions)] +fn collect_global_env_refs(app: &tauri::AppHandle) -> std::collections::HashSet { + let path = match app.path().app_data_dir() { + Ok(d) => d.join("agents/global-agent-config.json"), + Err(_) => return std::collections::HashSet::new(), + }; + let content = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(_) => return std::collections::HashSet::new(), + }; + let v: serde_json::Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return std::collections::HashSet::new(), + }; + let mut refs = std::collections::HashSet::new(); + if let Some(r) = v.get("env_vars_ref").and_then(|r| r.as_str()) { + refs.insert(r.to_string()); + } + refs +} + +/// Collect the full blob coordinates referenced by the canonical JSON stores +/// (`managed-agents.json` + `global-agent-config.json`), reusing the same +/// [`collect_live_refs`] the GC uses so the liveness notion is identical. +/// +/// Returns `None` when either store is unreadable or the JSON is in a state +/// `collect_live_refs` refuses to reason about (malformed, duplicate, or +/// inline+ref ambiguity). A `None` result must be treated as "liveness +/// unknown" — the marker-cleanup caller then declines to clear a marker on +/// liveness grounds and keeps the coordinate unavailable (fail closed). +#[cfg(debug_assertions)] +fn collect_live_projection_coords( + app: &tauri::AppHandle, +) -> Option> { + use crate::managed_agents::secret_projection::collect_live_refs; + + let agents_path = crate::managed_agents::storage::managed_agents_store_path(app).ok()?; + let global_path = app + .path() + .app_data_dir() + .ok()? + .join("agents/global-agent-config.json"); + + // An absent file is a *known* empty liveness set, so default it to the + // store's empty JSON shape (`[]` for the agents array, `{}` for the global + // object). Only an unreadable *existing* file is genuinely unknown → `None`. + let agents_json = read_json_or_default(&agents_path, "[]")?; + let global_json = read_json_or_default(&global_path, "{}")?; + + collect_live_refs(&agents_json, &global_json).map(|live| live.coords) +} + +/// Read a JSON store to a string, substituting `default_empty` for an absent +/// file. Returns `None` only when an existing file cannot be read — a genuine +/// "unknown" state the caller must fail closed on. +#[cfg(debug_assertions)] +fn read_json_or_default(path: &std::path::Path, default_empty: &str) -> Option { + match std::fs::read_to_string(path) { + Ok(s) => Some(s), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(default_empty.to_string()), + Err(_) => None, + } +} + +#[cfg(all(test, debug_assertions))] +mod tests { + use super::*; + use crate::managed_agents::secret_projection::{agent_env_key, global_env_key}; + use std::collections::{HashMap, HashSet}; + + fn map(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn test_clean_migration_includes_marker_and_copies() { + // One projection key present only in src, no conflicts → copy it AND + // write the marker (migration is complete). + let key = agent_env_key("abc", "gen1"); + let src = map(&[(&key, "val")]); + let plan = plan_dev_secrets_migration(&src, &HashMap::new(), &HashSet::new(), None); + + assert!(plan.conflict_keys.is_empty()); + assert!(plan.write_marker, "clean migration must write the marker"); + assert_eq!(plan.to_write.get(&key).map(String::as_str), Some("val")); + assert!(plan.to_write.contains_key(DEV_SECRETS_MIGRATION_MARKER)); + } + + #[test] + fn test_conflict_withholds_marker_but_copies_non_conflicting() { + // Two keys: one conflicts (differs in dest), one is new. The migration + // must copy the new key (partial progress) but WITHHOLD the marker so + // the conflict is retried after manual resolution. + let conflict = agent_env_key("abc", "gen1"); + let fresh = agent_env_key("def", "gen2"); + let src = map(&[(&conflict, "src-val"), (&fresh, "fresh-val")]); + let dest = map(&[(&conflict, "dest-val")]); + + let plan = plan_dev_secrets_migration(&src, &dest, &HashSet::new(), None); + + assert_eq!(plan.conflict_keys, vec![conflict.clone()]); + assert!( + !plan.write_marker, + "a conflicted migration must NOT write the marker — it must retry" + ); + assert!( + !plan.to_write.contains_key(DEV_SECRETS_MIGRATION_MARKER), + "marker must be absent from the write batch on conflict" + ); + assert_eq!( + plan.to_write.get(&fresh).map(String::as_str), + Some("fresh-val"), + "non-conflicting key must still be copied for partial progress" + ); + assert!( + !plan.to_write.contains_key(&conflict), + "conflicting key must NOT be overwritten" + ); + } + + #[test] + fn test_identical_key_is_idempotent_and_clean() { + // A key already identical in dest is not re-copied, and (no conflict) + // the marker is written. + let key = agent_env_key("abc", "gen1"); + let src = map(&[(&key, "same")]); + let dest = map(&[(&key, "same")]); + + let plan = plan_dev_secrets_migration(&src, &dest, &HashSet::new(), None); + + assert!(plan.conflict_keys.is_empty()); + assert!(plan.write_marker); + assert!( + !plan.to_write.contains_key(&key), + "identical key must not be re-written" + ); + // Only the marker is in the batch. + assert_eq!(plan.to_write.len(), 1); + } + + #[test] + fn test_empty_source_writes_only_marker() { + // Nothing to copy, no conflict → clean: the batch is just the marker. + let plan = + plan_dev_secrets_migration(&HashMap::new(), &HashMap::new(), &HashSet::new(), None); + assert!(plan.write_marker); + assert_eq!(plan.to_write.len(), 1); + assert!(plan.to_write.contains_key(DEV_SECRETS_MIGRATION_MARKER)); + } + + #[test] + fn test_unreferenced_global_env_is_skipped() { + // A global:env gen not referenced by the destination JSON must not be + // copied across services (global-agent-config.json is not shared). + let key = global_env_key("gen-unref"); + let src = map(&[(&key, "val")]); + let plan = plan_dev_secrets_migration(&src, &HashMap::new(), &HashSet::new(), None); + + assert!( + !plan.to_write.contains_key(&key), + "unreferenced global:env gen must be skipped" + ); + // Clean (no conflict), so only the marker is present. + assert!(plan.write_marker); + assert_eq!(plan.to_write.len(), 1); + } + + #[test] + fn test_referenced_global_env_is_copied() { + // The same global:env gen IS copied when the destination JSON + // references it. + let key = global_env_key("gen-ref"); + let src = map(&[(&key, "val")]); + let refs: HashSet = ["gen-ref".to_string()].into_iter().collect(); + let plan = plan_dev_secrets_migration(&src, &HashMap::new(), &refs, None); + + assert_eq!(plan.to_write.get(&key).map(String::as_str), Some("val")); + } + + #[test] + fn test_non_projection_key_is_ignored() { + // A non-projection key (e.g. an nsec or identity marker) present only + // in src must never be copied by this migration. + let src = map(&[("some-agent-nsec-key", "secret")]); + let plan = plan_dev_secrets_migration(&src, &HashMap::new(), &HashSet::new(), None); + + assert!(!plan.to_write.contains_key("some-agent-nsec-key")); + assert!(plan.write_marker); // no projection conflict + assert_eq!(plan.to_write.len(), 1); // marker only + } + + #[test] + fn test_conflict_writes_conflict_marker_into_batch() { + // A conflicting coordinate must add a `conflict:` marker to the + // write batch so `load_secret` fails closed for it — the F4 fix that + // makes the conflicted value unavailable, not merely un-marked. + use crate::managed_agents::secret_projection::conflict_marker_key; + let conflict = agent_env_key("abc", "gen1"); + let src = map(&[(&conflict, "src-val")]); + let dest = map(&[(&conflict, "dest-val")]); + + let plan = plan_dev_secrets_migration(&src, &dest, &HashSet::new(), None); + + assert_eq!(plan.conflict_keys, vec![conflict.clone()]); + assert!( + plan.to_write.contains_key(&conflict_marker_key(&conflict)), + "a conflict must write its conflict marker into the batch" + ); + assert!( + !plan.to_write.contains_key(&conflict), + "the conflicted value itself must NOT be copied" + ); + assert!(!plan.write_marker, "completion marker withheld on conflict"); + } + + #[test] + fn test_resolved_conflict_marker_is_cleared() { + // A conflict marker present in dest whose coordinate no longer + // conflicts (values now agree) must be scheduled for clearing so the + // availability block is lifted. + use crate::managed_agents::secret_projection::conflict_marker_key; + let coord = agent_env_key("abc", "gen1"); + let marker = conflict_marker_key(&coord); + // src and dest now AGREE on the coordinate — the conflict is resolved. + let src = map(&[(&coord, "agreed")]); + let dest = map(&[(&coord, "agreed"), (&marker, "1")]); + + let plan = plan_dev_secrets_migration(&src, &dest, &HashSet::new(), None); + + assert!( + plan.conflict_keys.is_empty(), + "coordinate no longer conflicts" + ); + assert!( + plan.conflict_markers_to_clear.contains(&marker), + "a resolved conflict's stale marker must be cleared" + ); + } + + #[test] + fn test_persisting_conflict_marker_is_not_cleared() { + // A conflict that STILL conflicts must keep its marker (not clear it), + // so the coordinate stays unavailable across the retry. + use crate::managed_agents::secret_projection::conflict_marker_key; + let coord = agent_env_key("abc", "gen1"); + let marker = conflict_marker_key(&coord); + let src = map(&[(&coord, "src-val")]); + let dest = map(&[(&coord, "dest-val"), (&marker, "1")]); + + let plan = plan_dev_secrets_migration(&src, &dest, &HashSet::new(), None); + + assert_eq!(plan.conflict_keys, vec![coord.clone()]); + assert!( + !plan.conflict_markers_to_clear.contains(&marker), + "a still-conflicting coordinate's marker must NOT be cleared" + ); + assert!( + plan.to_write.contains_key(&marker), + "the (re)written marker keeps the coordinate unavailable" + ); + } + + #[test] + fn test_marker_retained_when_source_gone_but_dest_coord_still_live() { + // F4: a disappearing SOURCE coordinate is not evidence the destination + // won the conflict. If dest still holds the (conflicting) value AND + // canonical JSON still references the coordinate, the marker must be + // RETAINED so `load_secret` keeps failing closed. This is the fail-open + // arm Thufir flagged: the old code cleared any marker whose coordinate + // left the conflict set (source disappearance counted as resolution). + use crate::managed_agents::secret_projection::conflict_marker_key; + let coord = agent_env_key("abc", "gen1"); + let marker = conflict_marker_key(&coord); + // Source no longer holds the coordinate; dest still holds the old value + // plus the marker. + let src = map(&[]); + let dest = map(&[(&coord, "dest-val"), (&marker, "1")]); + // Canonical JSON STILL references the coordinate's generation → live. + let live: HashSet = [coord.clone()].into_iter().collect(); + + let plan = plan_dev_secrets_migration(&src, &dest, &HashSet::new(), Some(&live)); + + assert!( + plan.conflict_keys.is_empty(), + "source is gone, so this run detects no fresh conflict" + ); + assert!( + !plan.conflict_markers_to_clear.contains(&marker), + "marker must be RETAINED: source disappearance is not resolution while \ + the destination coordinate is still live" + ); + } + + #[test] + fn test_marker_cleared_when_coord_no_longer_live() { + // F4 positive bound: once the coordinate is proven no longer referenced + // by canonical JSON, nothing can hydrate it, so the stale marker is + // safe to clear (the two-cycle GC retires the orphaned generation). + use crate::managed_agents::secret_projection::conflict_marker_key; + let coord = agent_env_key("abc", "gen1"); + let marker = conflict_marker_key(&coord); + let src = map(&[]); + let dest = map(&[(&coord, "dest-val"), (&marker, "1")]); + // Canonical JSON references NO coordinates → coord is not live. + let live: HashSet = HashSet::new(); + + let plan = plan_dev_secrets_migration(&src, &dest, &HashSet::new(), Some(&live)); + + assert!( + plan.conflict_markers_to_clear.contains(&marker), + "a marker whose coordinate is proven no longer live must be cleared" + ); + } + + #[test] + fn test_marker_retained_when_liveness_unknown_and_values_disagree() { + // F4: when liveness cannot be proven (`live_coords` is None because a + // store was unreadable) and the values still disagree, only value + // convergence (proof #1) may clear a marker — so it is RETAINED. + use crate::managed_agents::secret_projection::conflict_marker_key; + let coord = agent_env_key("abc", "gen1"); + let marker = conflict_marker_key(&coord); + let src = map(&[(&coord, "src-val")]); + let dest = map(&[(&coord, "dest-val"), (&marker, "1")]); + + // liveness unknown → None. src/dest disagree AND this coord conflicts + // again this run, so it is re-marked and never cleared. + let plan = plan_dev_secrets_migration(&src, &dest, &HashSet::new(), None); + + assert!( + !plan.conflict_markers_to_clear.contains(&marker), + "with liveness unknown and values disagreeing, the marker must be retained" + ); + } + + // ── F4 end-to-end: a PLANNER-PRODUCED marker drives hydration refusal ──── + // + // The other F4 tests hand-seed `conflict:` and prove `load_secret` + // fails closed on it. This one closes the loop from the ORIGIN: run the + // real planner on a conflicting src/dest, apply its `to_write` batch + // verbatim (marker included) into a store, then hydrate a record pointing + // at the conflicted generation. It proves the whole chain — + // planner → keyring batch → hydration → spawn refusal — with no + // hand-placed marker anywhere in the test. + #[test] + fn test_planner_conflict_marker_drives_hydration_refusal() { + use crate::managed_agents::secret_projection::ProjectionStore; + use crate::managed_agents::secret_seam::hydrate_all_secrets_for_records; + use crate::managed_agents::storage::spawn_key_refusal; + use crate::managed_agents::ManagedAgentRecord; + use std::cell::RefCell; + + // Minimal in-memory store; hydration only reads via `load_key`. + struct FakeStore(RefCell>); + impl ProjectionStore for FakeStore { + fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String> { + self.0.borrow_mut().insert(key.into(), value.into()); + Ok(()) + } + fn load_key(&self, key: &str) -> Result, String> { + Ok(self.0.borrow().get(key).cloned()) + } + fn load_all(&self) -> Result>, String> { + Ok(Some(self.0.borrow().clone())) + } + fn store_batch(&self, entries: &HashMap) -> Result<(), String> { + for (k, v) in entries { + self.0.borrow_mut().insert(k.clone(), v.clone()); + } + Ok(()) + } + fn remove_batch(&self, keys: &[&str]) -> Result<(), String> { + for k in keys { + self.0.borrow_mut().remove(*k); + } + Ok(()) + } + } + + let pubkey = "abc"; + let coord = agent_env_key(pubkey, "gen_live"); + // Source and destination disagree on the same coordinate → conflict. + let src = map(&[(&coord, r#"{"ANTHROPIC_API_KEY":"src-value"}"#)]); + let dest = map(&[(&coord, r#"{"ANTHROPIC_API_KEY":"dest-value"}"#)]); + + // Run the REAL planner. It flags the conflict, withholds the completion + // marker, and emits the `conflict:` marker in its write batch. + let plan = plan_dev_secrets_migration(&src, &dest, &HashSet::new(), None); + assert_eq!(plan.conflict_keys, vec![coord.clone()]); + assert!( + !plan.write_marker, + "a conflict withholds the completion marker" + ); + + // Apply the planner's batch verbatim (the conflict marker rides along), + // plus the destination value the migration left untouched — the exact + // post-migration keyring state for an unresolved conflict. + let store = FakeStore(RefCell::new(HashMap::new())); + store.store_batch(&plan.to_write).unwrap(); + store + .write_and_verify(&coord, r#"{"ANTHROPIC_API_KEY":"dest-value"}"#) + .unwrap(); + + // Hydrate a record that references the conflicted generation. + let mut records = vec![{ + let mut r: ManagedAgentRecord = serde_json::from_str(&format!( + r#"{{ + "pubkey": "{pubkey}", + "name": "test-agent", + "private_key_nsec": "nsec1realkey", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }}"# + )) + .expect("instance record"); + r.env_vars_ref = Some("gen_live".to_string()); + r + }]; + + let unavailable = hydrate_all_secrets_for_records(&store, &mut records); + + assert_eq!( + unavailable, + vec![pubkey.to_string()], + "the planner's marker must surface the instance as unavailable" + ); + assert!( + records[0].secrets_unavailable, + "a planner-produced conflict marker must set secrets_unavailable" + ); + assert!( + records[0].env_vars.is_empty(), + "the untrusted destination value must NOT hydrate" + ); + assert!( + spawn_key_refusal(&records[0]).is_some(), + "spawn must refuse the instance while the planner's conflict stands" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index d86e5f33f05..9e20a559160 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -192,6 +192,8 @@ pub(crate) fn preset_harness_definitions( command: preset.command.to_string(), args: preset.args.iter().map(|arg| arg.to_string()).collect(), env: Default::default(), + env_ref: None, + env_unavailable: false, install_instructions_url: preset.install_instructions_url.to_string(), install_hint: preset.install_hint.to_string(), }, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 6fe6a77521b..341728df4e8 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -18,7 +18,6 @@ fn resolves_known_avatar_for_bare_command() { assert_eq!(avatar_url, GOOSE_AVATAR_URL); } - #[test] fn resolves_known_avatar_for_command_paths_and_aliases() { assert_eq!( @@ -38,12 +37,10 @@ fn resolves_known_avatar_for_command_paths_and_aliases() { Some(CLAUDE_CODE_AVATAR_URL.to_string()) ); } - #[test] fn returns_none_for_unknown_commands() { assert!(managed_agent_avatar_url("custom-agent").is_none()); } - #[test] fn default_agent_command_resolves_bundled_buzz_agent() { // The default must be bundled buzz-agent, never bare `goose` on a stock Windows install. @@ -191,24 +188,9 @@ fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agent crate::managed_agents::AgentDefinition { id: id.to_string(), display_name: id.to_string(), - avatar_url: None, - system_prompt: String::new(), runtime: runtime.map(str::to_string), - model: None, - provider: None, - name_pool: Vec::new(), - is_builtin: false, is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - env_vars: std::collections::BTreeMap::new(), - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: "2026-06-09T00:00:00Z".to_string(), - updated_at: "2026-06-09T00:00:00Z".to_string(), + ..Default::default() } } @@ -283,9 +265,12 @@ fn record_with( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } - #[test] fn record_agent_command_own_runtime_wins_over_persona() { // A record with its own materialized runtime never consults the @@ -1623,6 +1608,8 @@ fn deleted_harness_summary_display_and_spawn_sentence_agree() { command: "doomed-bin".to_string(), args: vec![], env: Default::default(), + env_ref: None, + env_unavailable: false, install_instructions_url: String::new(), install_hint: String::new(), }; @@ -1767,6 +1754,8 @@ fn harness_def( command: command.to_string(), args: vec![], env: Default::default(), + env_ref: None, + env_unavailable: false, install_instructions_url: String::new(), install_hint: String::new(), } diff --git a/desktop/src-tauri/src/managed_agents/effective_config/mod.rs b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs index e079c76a1d0..50fe5642745 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs @@ -314,5 +314,85 @@ impl EffectiveConfigResult { } } +/// A human-readable identifier for a record in a refusal message: the pubkey +/// for a keyed instance, the slug/name for a key-less definition. +fn record_ref(record: &ManagedAgentRecord) -> String { + if record.pubkey.is_empty() { + record.slug.clone().unwrap_or_else(|| record.name.clone()) + } else { + record.pubkey.clone() + } +} + +/// Fail-closed availability gate for every side-effecting consumer of an +/// agent's effective secrets — model discovery, card minting, profile +/// signing, snapshot export, and destructive global saves. +/// +/// The three effective-secret tiers are checked in precedence order and the +/// first unavailable one refuses: +/// +/// 1. **Global** — `global_load` is the strict [`load_global_agent_config`] +/// result. When a committed `env_vars_ref` cannot be resolved the loader +/// returns `Err`; this gate propagates it as a refusal rather than letting +/// the caller fall back to `unwrap_or_default()` (which would drop the +/// global env and run/sign/export with a silently-incomplete config). On +/// success the loaded config is returned so the caller reuses it without a +/// second load. +/// 2. **Instance** — the record's own `secrets_unavailable` (an +/// `env_vars`/`auth_tag`/`provider_config` ref that failed to hydrate). +/// 3. **Definition** — for a linked instance, the definition's +/// `secrets_unavailable`. +/// +/// This mirrors the spawn/deploy boundaries (`spawn_key_refusal`, the +/// definition inline check, and `load_global_agent_config(app)?`) so every +/// external-effect path fails closed on the exact same conditions. +/// +/// [`load_global_agent_config`]: super::global_config::load_global_agent_config +pub fn require_effective_secrets_available( + record: &ManagedAgentRecord, + definitions: &[AgentDefinition], + global_load: Result, +) -> Result { + // Global tier: an unresolvable committed ref is a hard refusal. + let global = global_load.map_err(|e| { + format!( + "agent {} cannot proceed: the global agent config has one or more \ + secrets that could not be loaded from the keyring ({e}). Refusing \ + with missing secrets; retry once the keyring is reachable.", + record_ref(record) + ) + })?; + + // Instance tier: the record's own secret refs failed to hydrate. + if record.secrets_unavailable { + return Err(format!( + "agent {} cannot proceed: one or more of its secrets (env vars, \ + auth tag, or provider config) could not be loaded from the \ + keyring. Refusing with missing secrets; retry once the keyring is \ + reachable.", + record_ref(record) + )); + } + + // Definition tier: a linked instance whose definition's env could not hydrate. + if let Some(pid) = record.persona_id.as_deref() { + if definitions + .iter() + .find(|d| d.id == pid) + .map(|d| d.secrets_unavailable) + .unwrap_or(false) + { + return Err(format!( + "agent {} cannot proceed: its definition ({pid}) has one or more \ + secrets that could not be loaded from the keyring. Refusing with \ + missing secrets; retry once the keyring is reachable.", + record_ref(record) + )); + } + } + + Ok(global) +} + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index c8e437809ce..d0df89c1fea 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -28,6 +28,7 @@ fn definition( parallelism: None, created_at: "".to_string(), updated_at: "".to_string(), + secrets_unavailable: false, } } @@ -92,6 +93,10 @@ fn record( definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } @@ -874,3 +879,93 @@ fn linked_record_with_legacy_bytes_inherits_global_not_mesh() { assert_eq!(cfg.model.value.as_deref(), Some("gpt-5")); assert_eq!(cfg.relay_mesh_model_id(), None); } + +// ── F3: require_effective_secrets_available — the single gate every ──────── +// side-effecting caller (model discovery, card mint, profile sign, +// global save, snapshot export) consumes. Covers all three tiers. + +#[test] +fn gate_ok_when_all_tiers_available() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", None, None, "")]; + let global = global(Some("gpt-5"), Some("openai")); + let out = require_effective_secrets_available(&rec, &defs, Ok(global.clone())); + assert!(out.is_ok(), "healthy config must pass the gate"); + assert_eq!( + out.unwrap().model, + global.model, + "the gate returns the loaded global for the caller to reuse" + ); +} + +#[test] +fn gate_refuses_when_global_loader_errs() { + // The strict loader Err (a global env_vars_ref that could not hydrate) must + // become a refusal, not a silent unwrap_or_default fall-through. + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", None, None, "")]; + let out = require_effective_secrets_available( + &rec, + &defs, + Err("global env_vars unavailable: keyring outage".to_string()), + ); + let err = out.expect_err("global loader Err must refuse"); + assert!( + err.contains("global agent config") && err.contains("keyring outage"), + "refusal must name the global tier and carry the loader cause: {err}" + ); +} + +#[test] +fn gate_refuses_when_instance_secrets_unavailable() { + let mut rec = record(None, None, None, None); + rec.secrets_unavailable = true; + let out = require_effective_secrets_available(&rec, &[], Ok(global(None, None))); + let err = out.expect_err("instance-unavailable must refuse"); + assert!( + err.contains(&rec.pubkey), + "refusal must identify the instance: {err}" + ); +} + +#[test] +fn gate_refuses_when_linked_definition_secrets_unavailable() { + let rec = record(Some("d1"), None, None, None); + let mut def = definition("d1", None, None, ""); + def.secrets_unavailable = true; + let out = require_effective_secrets_available(&rec, &[def], Ok(global(None, None))); + let err = out.expect_err("definition-unavailable must refuse"); + assert!( + err.contains("definition (d1)"), + "refusal must identify the definition tier: {err}" + ); +} + +#[test] +fn gate_ignores_unavailable_definition_that_is_not_linked() { + // A definition the record is NOT linked to must not gate the record — only + // its own linked definition matters. + let rec = record(Some("d1"), None, None, None); + let mut other = definition("d2", None, None, ""); + other.secrets_unavailable = true; + let linked = definition("d1", None, None, ""); + let out = require_effective_secrets_available(&rec, &[linked, other], Ok(global(None, None))); + assert!( + out.is_ok(), + "an unlinked definition's unavailability must not gate this record" + ); +} + +#[test] +fn gate_global_tier_checked_before_instance_tier() { + // Precedence: a global loader Err refuses even when the instance is also + // unavailable — the global message must win (it is checked first). + let mut rec = record(None, None, None, None); + rec.secrets_unavailable = true; + let out = require_effective_secrets_available(&rec, &[], Err("global boom".to_string())); + let err = out.expect_err("must refuse"); + assert!( + err.contains("global agent config"), + "global tier is checked first: {err}" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/env_unavailable_tests.rs b/desktop/src-tauri/src/managed_agents/env_unavailable_tests.rs new file mode 100644 index 00000000000..43678349a2f --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/env_unavailable_tests.rs @@ -0,0 +1,267 @@ +//! `env_unavailable` marker tests: hydrate sets it on a keyring outage, the +//! save path preserves the live ref rather than erasing it, and the rename +//! path is refused. Extracted from `custom_harnesses_tests.rs` via `#[path]` +//! so that file stays under the desktop file-size ratchet; `super::*` resolves +//! the parent test helpers (`FakeProjectionStore`, `env_harness_def`, …) and +//! `super::super::*` the module under test. + +use super::super::*; +use super::{env_harness_def, make_def, FakeProjectionStore}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +/// Projection store whose writes succeed but every read fails — models a +/// keyring that accepts writes but cannot be read back (the outage that leaves +/// a live `env_ref` unhydratable, setting `env_unavailable`). +struct FailingReadStore; + +impl ProjectionStore for FailingReadStore { + fn write_and_verify(&self, _key: &str, _value: &str) -> Result<(), String> { + Ok(()) + } + fn load_key(&self, _key: &str) -> Result, String> { + Err("simulated keyring read failure".to_string()) + } + fn load_all(&self) -> Result>, String> { + Err("simulated keyring read failure".to_string()) + } + fn store_batch(&self, _entries: &HashMap) -> Result<(), String> { + Ok(()) + } + fn remove_batch(&self, _keys: &[&str]) -> Result<(), String> { + Ok(()) + } +} + +// ── env_unavailable marker: hydrate sets it; save preserves the ref ─────── +// +// A harness whose `env_ref` points at a keyring generation that cannot be read +// back is a keyring OUTAGE, not a user-cleared env. `hydrate_harness_env` marks +// it `env_unavailable`; the save path must preserve that live ref rather than +// erase it (the TS form round-trips neither the ref nor the marker and seeds +// `env` from the empty catalog entry, so a naive empty-env save would strand +// the pointer). The rename path has no safe outcome and is refused. + +/// Persist an `env`-bearing harness through the fake, then return the on-disk +/// definition (ref present, inline env stripped) and the projected generation. +fn saved_with_projected_env(store: &FakeProjectionStore, dir: &Path) -> HarnessDefinition { + save_custom_harness_to_dir_with(Some(store), dir, &env_harness_def(), None).unwrap(); + let raw = fs::read_to_string(dir.join("env-harness.json")).unwrap(); + serde_json::from_str(&raw).unwrap() +} + +#[test] +fn hydrate_sets_unavailable_when_ref_generation_is_missing() { + // env_ref points at a generation that was never written — the keyring is + // reachable (Ok(None)) but the entry is gone. This must fail closed. + let dir = tempfile::tempdir().unwrap(); + let store = FakeProjectionStore::new(); + let mut on_disk = saved_with_projected_env(&store, dir.path()); + assert!(on_disk.env_ref.is_some(), "guard: a ref must exist"); + + // Drop the projected blob so the ref dangles, then rehydrate from empty. + let empty = FakeProjectionStore::new(); + on_disk.env.clear(); + hydrate_harness_env(&empty, &mut on_disk); + + assert!( + on_disk.env_unavailable, + "a ref whose generation is absent from the keyring must set env_unavailable" + ); + assert!(on_disk.env.is_empty(), "no value could be hydrated"); +} + +#[test] +fn hydrate_sets_unavailable_when_keyring_read_errors() { + // The ref exists but the keyring read itself errors (transient outage). + let dir = tempfile::tempdir().unwrap(); + let store = FakeProjectionStore::new(); + let mut on_disk = saved_with_projected_env(&store, dir.path()); + on_disk.env.clear(); + + hydrate_harness_env(&FailingReadStore, &mut on_disk); + + assert!( + on_disk.env_unavailable, + "a keyring read error on a live ref must set env_unavailable" + ); +} + +#[test] +fn hydrate_sets_unavailable_when_stored_value_is_malformed() { + // The blob exists but its bytes are not a valid env map — deserialize fails. + let dir = tempfile::tempdir().unwrap(); + let store = FakeProjectionStore::new(); + let mut on_disk = saved_with_projected_env(&store, dir.path()); + let gen = on_disk.env_ref.clone().unwrap(); + + // Corrupt the stored generation, then rehydrate from empty inline. + store + .write_and_verify(&harness_env_key("env-harness", &gen), "not json {{{") + .unwrap(); + on_disk.env.clear(); + hydrate_harness_env(&store, &mut on_disk); + + assert!( + on_disk.env_unavailable, + "malformed keyring bytes on a live ref must set env_unavailable" + ); + assert!(on_disk.env.is_empty(), "a malformed blob yields no env"); +} + +#[test] +fn hydrate_leaves_available_when_ref_resolves_cleanly() { + // Positive control: a healthy ref hydrates its env and never marks + // unavailable — so the negative assertions above are meaningful. + let dir = tempfile::tempdir().unwrap(); + let store = FakeProjectionStore::new(); + let mut on_disk = saved_with_projected_env(&store, dir.path()); + + hydrate_harness_env(&store, &mut on_disk); + + assert!( + !on_disk.env_unavailable, + "a healthy ref must never set env_unavailable" + ); + assert_eq!( + on_disk.env.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("sk-ant-secret"), + "a healthy ref must hydrate its env" + ); +} + +#[test] +fn hydrate_leaves_available_for_ref_less_empty_env() { + // A genuinely-empty harness (no ref) is available, not unavailable — the + // marker distinguishes an outage from an intentionally-empty env. + let mut def = make_def("bare", "Bare"); // no env, no ref + hydrate_harness_env(&FailingReadStore, &mut def); + assert!( + !def.env_unavailable, + "an env-less record has nothing to hydrate and must stay available" + ); +} + +#[test] +fn save_preserves_ref_when_persisted_record_is_env_unavailable() { + // The pointer-loss fix: an on-disk record with a live-but-unhydratable ref + // must keep that ref across a same-id save whose incoming env is empty + // (exactly the TS-form round-trip: no ref, no marker, empty catalog env). + let dir = tempfile::tempdir().unwrap(); + let store = FakeProjectionStore::new(); + let on_disk = saved_with_projected_env(&store, dir.path()); + let live_ref = on_disk.env_ref.clone().expect("a ref must be persisted"); + + // The keyring can no longer read the projected generation — the record is + // now env_unavailable. The incoming save carries an empty env and no ref. + let mut incoming = env_harness_def(); + incoming.env.clear(); + incoming.env_ref = None; + save_custom_harness_to_dir_with(Some(&FailingReadStore), dir.path(), &incoming, None).unwrap(); + + let after: HarnessDefinition = + serde_json::from_str(&fs::read_to_string(dir.path().join("env-harness.json")).unwrap()) + .unwrap(); + assert_eq!( + after.env_ref.as_deref(), + Some(live_ref.as_str()), + "an empty-env save over an unavailable record must preserve the live ref" + ); +} + +#[test] +fn save_clears_ref_when_persisted_record_is_healthy() { + // The inverse of the preserve case: a genuine user-clear of a HEALTHY + // ref-backed record (the on-disk ref hydrates fine) must clear the ref. + // Break `persisted_unavailable_env_ref` to always return the ref and this + // fails — proving the preserve path keys on unavailability, not on any ref. + let dir = tempfile::tempdir().unwrap(); + let store = FakeProjectionStore::new(); + let on_disk = saved_with_projected_env(&store, dir.path()); + assert!(on_disk.env_ref.is_some(), "guard: a healthy ref must exist"); + + // Same store (ref resolves cleanly), incoming env empty → true user-clear. + let mut incoming = env_harness_def(); + incoming.env.clear(); + incoming.env_ref = None; + save_custom_harness_to_dir_with(Some(&store), dir.path(), &incoming, None).unwrap(); + + let after: HarnessDefinition = + serde_json::from_str(&fs::read_to_string(dir.path().join("env-harness.json")).unwrap()) + .unwrap(); + assert!( + after.env_ref.is_none(), + "clearing a healthy record's env must clear the ref, not preserve it" + ); +} + +#[test] +fn rename_of_env_unavailable_record_is_refused() { + // A rename cannot re-read the ref under the new id (the key embeds the id) + // and carrying it forward would strand it at an unwritten coordinate, so + // the save refuses rather than silently erasing or mis-pointing the ref. + let dir = tempfile::tempdir().unwrap(); + let store = FakeProjectionStore::new(); + let on_disk = saved_with_projected_env(&store, dir.path()); + assert!(on_disk.env_ref.is_some(), "guard: old id has a live ref"); + + // Rename env-harness → renamed-harness while the keyring cannot read back. + let mut renamed = env_harness_def(); + renamed.id = "renamed-harness".to_string(); + renamed.env.clear(); + renamed.env_ref = None; + let result = save_custom_harness_to_dir_with( + Some(&FailingReadStore), + dir.path(), + &renamed, + Some("env-harness"), + ); + let Err(err) = result else { + panic!("a rename of an unavailable record must be refused"); + }; + assert!( + err.contains("env-harness") && err.contains("unavailable"), + "the refusal must name the old id and the reason: {err}" + ); + // The old file must be untouched — no partial rename left behind. + assert!( + dir.path().join("env-harness.json").exists(), + "the refused rename must not remove the old file" + ); + assert!( + !dir.path().join("renamed-harness.json").exists(), + "the refused rename must not create the new file" + ); +} + +#[test] +fn rename_of_healthy_record_succeeds_and_reprojects() { + // A rename of a HEALTHY ref-backed record works: the TS form round-trips + // the fully-hydrated env, so strip re-projects it under the new id. Guards + // that the refusal above is scoped to the unavailable case only. + let dir = tempfile::tempdir().unwrap(); + let store = FakeProjectionStore::new(); + saved_with_projected_env(&store, dir.path()); + + // The form re-submits the full env (hydrated) under the new id. + let mut renamed = env_harness_def(); + renamed.id = "renamed-harness".to_string(); + let outcome = + save_custom_harness_to_dir_with(Some(&store), dir.path(), &renamed, Some("env-harness")) + .expect("a healthy rename must succeed"); + + assert_eq!( + outcome.removed_old_path, + Some(dir.path().join("env-harness.json")), + "the old file must be removed on a successful rename" + ); + let after = load_custom_harnesses_with(Some(&store), dir.path()); + assert_eq!(after.len(), 1); + assert_eq!(after[0].id, "renamed-harness"); + assert_eq!( + after[0].env.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("sk-ant-secret"), + "the renamed record must carry its env under the new id" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/global_config/mod.rs b/desktop/src-tauri/src/managed_agents/global_config/mod.rs index 162f447981b..d7a7551cc54 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -30,8 +30,13 @@ use tauri::AppHandle; use crate::managed_agents::env_vars::{ validate_user_env_keys, DERIVED_PROVIDER_MODEL_ENV_KEYS, MAX_ENV_VALUE_BYTES, }; +use crate::managed_agents::secret_projection::{ + cancel_gc_candidacy, deserialize_env_map, global_env_key, load_secret, serialize_env_map, + write_secret, WriteOutcome, +}; use crate::managed_agents::storage::{atomic_write_json_restricted, managed_agents_base_dir}; use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; +use crate::secret_store::SecretStore; /// The global agent configuration record. /// @@ -53,6 +58,15 @@ pub struct GlobalAgentConfig { #[serde(default)] pub env_vars: BTreeMap, + /// Keyring generation reference for `env_vars`. When present and `env_vars` + /// is empty, the env map is stored in the keyring under + /// `global:env:`. When absent with empty `env_vars`, the env + /// map is intentionally empty. + /// + /// Inline (`env_vars` non-empty) takes precedence over any ref. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env_vars_ref: Option, + /// Global fallback provider (e.g. `"databricks_v2"`, `"anthropic"`). /// /// Used only when neither the agent record nor the linked persona specifies @@ -178,9 +192,30 @@ fn global_config_path(app: &AppHandle) -> Result { Ok(managed_agents_base_dir(app)?.join("global-agent-config.json")) } +fn global_config_secret_store() -> Option<&'static SecretStore> { + if cfg!(feature = "system-keyring") { + Some(SecretStore::shared(crate::app_state::keyring_service())) + } else { + None + } +} + /// Load the global agent config from disk. /// /// Returns the default (all-empty) config if the file does not exist yet. +/// Hydrates `env_vars` from the keyring when an `env_vars_ref` is present +/// and `env_vars` is empty (inline fallback: non-empty `env_vars` wins). +/// +/// # Fail-closed on unavailability +/// +/// Returns `Err` (rather than a silently-empty config) when the record +/// carries an `env_vars_ref` but the keyring entry is missing/unreachable or +/// its bytes fail to deserialize. Global env applies to ALL agents; silently +/// dropping it would spawn every agent with an incomplete env under the exact +/// keyring-failure the gen-ref protocol exists to fail closed on. Spawn/deploy +/// gates propagate this `Err` to refuse the launch; display/readiness callers +/// choose degraded-empty via `.unwrap_or_default()` and reflect the failure as +/// not-ready. Absent file and the no-ref empty case remain `Ok(default)`. pub fn load_global_agent_config(app: &AppHandle) -> Result { let path = global_config_path(app)?; if !path.exists() { @@ -188,7 +223,36 @@ pub fn load_global_agent_config(app: &AppHandle) -> Result match deserialize_env_map(&serialized) { + Ok(map) => config.env_vars = map, + // Ref present, bytes retrieved, but unparseable — fail closed. + Err(e) => { + return Err(format!( + "global env_vars unavailable: deserialize failed: {e}" + )) + } + }, + Ok(None) => {} // no ref: intentionally empty + // Ref present but keyring entry missing/unreachable — fail closed. + Err(e) => return Err(e), + } + } + } + // else: inline is authoritative. + + Ok(config) } /// Save the global agent config to disk. @@ -196,11 +260,56 @@ pub fn load_global_agent_config(app: &AppHandle) -> Result Result<(), String> { let mut config = config.clone(); strip_empty_env_vars(&mut config); normalize_global_config_fields(&mut config); + // Persist env_vars to keyring (generation-reference protocol). + if let Some(store) = global_config_secret_store() { + // Cross-process transaction lock: hold across the generation write and + // the atomic JSON commit below so a second Desktop process's GC cannot + // delete the just-written generation before its ref lands in JSON. The + // lock releases when `_txn` drops at end of function. Callers never hold + // it already (boot migration releases its agent-store span before + // calling here), so this does not nest. Keyed by the SAME canonical + // store directory as the agent-store saves and GC (via + // `acquire_secret_txn_lock`) so every mutator of the shared keyring + // blob serializes on one inode — global-agent-config.json is not itself + // shared, but it writes the same blob the agent store does. + let _txn = crate::managed_agents::storage::acquire_secret_txn_lock(app)?; + let inline_env = if !config.env_vars.is_empty() { + serialize_env_map(&config.env_vars).ok() + } else { + None + }; + match write_secret( + store, + global_env_key, + inline_env.as_deref(), + "global env_vars", + ) { + WriteOutcome::Persisted { gen } => { + cancel_gc_candidacy(store, &global_env_key(&gen)); + config.env_vars.clear(); + config.env_vars_ref = Some(gen); + } + WriteOutcome::KeptInline { .. } => { + config.env_vars_ref = None; + } + WriteOutcome::Nothing => { + config.env_vars_ref = None; + } + } + + let path = global_config_path(app)?; + let payload = serde_json::to_vec_pretty(&config) + .map_err(|e| format!("failed to serialize global agent config: {e}"))?; + return atomic_write_json_restricted(&path, &payload); + } + let path = global_config_path(app)?; let payload = serde_json::to_vec_pretty(&config) .map_err(|e| format!("failed to serialize global agent config: {e}"))?; diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 553596e226c..8ae4bd03bcf 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -267,6 +267,7 @@ fn roundtrip_serialization() { provider: Some("anthropic".to_string()), model: Some("claude-opus-4".to_string()), preferred_runtime: Some("claude".to_string()), + env_vars_ref: None, }; let json = serde_json::to_string(&config).expect("serialize"); let back: GlobalAgentConfig = serde_json::from_str(&json).expect("deserialize"); @@ -352,6 +353,10 @@ fn bare_record() -> ManagedAgentRecord { definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } @@ -377,6 +382,7 @@ fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefini parallelism: None, created_at: "".to_string(), updated_at: "".to_string(), + secrets_unavailable: false, } } @@ -592,6 +598,7 @@ fn populated_global_config_round_trips() { provider: Some("anthropic".to_string()), model: Some("claude-opus-4-5".to_string()), preferred_runtime: None, + env_vars_ref: None, }; let json = serde_json::to_string(&original).expect("serialization must not fail"); let decoded: GlobalAgentConfig = @@ -638,6 +645,7 @@ fn record_runtime_wins_over_persona_runtime_for_command_resolution() { parallelism: None, created_at: "".to_string(), updated_at: "".to_string(), + secrets_unavailable: false, }; let cmd = crate::managed_agents::record_agent_command(&record, &[persona]); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index c6ccd3709c0..2be06fe0245 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -12,6 +12,10 @@ mod backend; pub(crate) mod config_bridge; pub(crate) mod custom_harnesses; mod definition_validation; +#[cfg(debug_assertions)] +mod dev_service_migration; +#[cfg(debug_assertions)] +pub(crate) use dev_service_migration::migrate_agent_secrets_to_dev_service; mod discovery; pub(crate) mod effective_config; mod env_vars; @@ -34,6 +38,8 @@ pub mod retention; mod runtime; mod runtime_commands; mod runtime_types; +pub(crate) mod secret_projection; +pub(crate) mod secret_seam; pub(crate) mod snapshot_avatar; pub(crate) mod spawn_snapshot; pub(crate) mod storage; @@ -52,6 +58,7 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { } pub use backend::*; +pub(crate) use custom_harnesses::{effective_secrets_unavailable, unavailable_harness_id}; pub(crate) use definition_validation::{ validate_agent_definition_text, validate_managed_agent_definition_text, }; diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index cbef171f6fd..afeb647c2fe 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -444,6 +444,7 @@ fn make_persona(id: &str, display_name: &str) -> AgentDefinition { parallelism: None, created_at: String::new(), updated_at: String::new(), + secrets_unavailable: false, } } @@ -502,6 +503,10 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index e1691575b11..2487a12b50e 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -117,6 +117,10 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } @@ -146,6 +150,7 @@ mod tests { parallelism: None, created_at: String::new(), updated_at: String::new(), + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index de396f45c0f..e40af6cca60 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -202,6 +202,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } @@ -161,6 +165,7 @@ pub(super) fn sample_persona() -> AgentDefinition { parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), + secrets_unavailable: false, } } @@ -388,6 +393,7 @@ fn content_matches_nip_ap_vector() { parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), + secrets_unavailable: false, }; let event = build_persona_event(&record) .unwrap() @@ -419,6 +425,7 @@ fn round_trip_minimal_persona() { parallelism: None, created_at: "2025-01-01T00:00:00Z".to_string(), updated_at: "2025-01-01T00:00:00Z".to_string(), + secrets_unavailable: false, }; let builder = build_persona_event(&record).unwrap(); @@ -516,6 +523,7 @@ fn quad_absent_definition_hash_stable_across_activation() { parallelism: None, created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), + secrets_unavailable: false, }; let live = persona_event_content(&record); // The reserved-era projection: identical fields, quad hardcoded off. @@ -560,6 +568,7 @@ fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDef parallelism: content.parallelism, created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 9bf7ab74b01..5aefec68eb6 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -131,6 +131,7 @@ fn built_in_persona_records(now: &str) -> Vec { parallelism: None, created_at: now.to_string(), updated_at: now.to_string(), + secrets_unavailable: false, }) .collect() } diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 387b4d72c65..1d8d9fe0336 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -28,6 +28,7 @@ fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { parallelism: None, created_at: "2026-03-19T00:00:00Z".to_string(), updated_at: "2026-03-19T00:00:00Z".to_string(), + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c072448ff13..a0f2529cb21 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -132,21 +132,9 @@ pub(crate) fn resolve_effective_harness_descriptor( // Look up the harness definition once — used for both args and env. // Resolution order: record.runtime → persona.runtime → "". - let harness_def = { - let runtime_id = record - .runtime - .as_deref() - .or_else(|| { - record.persona_id.as_deref().and_then(|pid| { - personas - .iter() - .find(|p| p.id == pid) - .and_then(|p| p.runtime.as_deref()) - }) - }) - .unwrap_or(""); - crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) - }; + let harness_def = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id( + crate::managed_agents::custom_harnesses::effective_runtime_id(record, personas), + ); // Args: explicit non-empty instance args win; otherwise use definition args. let args = { @@ -192,21 +180,9 @@ pub(crate) fn resolve_effective_agent_env( // Look up the harness definition for definition-level env (preset/custom). // Same resolution logic as spawn_agent_child: record runtime id first, then // persona runtime id, then nothing. - let harness_def = { - let runtime_id = record - .runtime - .as_deref() - .or_else(|| { - record.persona_id.as_deref().and_then(|pid| { - personas - .iter() - .find(|p| p.id == pid) - .and_then(|p| p.runtime.as_deref()) - }) - }) - .unwrap_or(""); - crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) - }; + let harness_def = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id( + crate::managed_agents::custom_harnesses::effective_runtime_id(record, personas), + ); resolve_effective_agent_env_with_def(record, personas, runtime, global, harness_def) } @@ -697,7 +673,6 @@ mod tests { "requirements should include NormalizedField(provider); got {reqs:?}" ); } - #[test] fn buzz_agent_missing_model_returns_not_ready_with_normalized_field() { let env = make_env( @@ -715,7 +690,6 @@ mod tests { field: "model".to_string() })); } - #[test] fn buzz_agent_missing_anthropic_key_returns_not_ready_with_env_key() { let env = make_env( @@ -731,7 +705,6 @@ mod tests { key: "ANTHROPIC_API_KEY".to_string() })); } - #[test] fn buzz_agent_missing_openai_key_returns_not_ready() { let env = make_env( @@ -1530,8 +1503,11 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, }; - let runtime = known_acp_runtime_exact("buzz-agent"); let effective = resolve_effective_agent_env(&record, &[], runtime, &Default::default()); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b1c342e9955..3b2f7393200 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -8,8 +8,8 @@ use crate::{ managed_agents::{ append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, missing_command_message, normalize_agent_args, open_log_file, resolve_command, - spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, ManagedAgentSummary, + spawn_key_refusal, unavailable_definition_id, KnownAcpRuntime, ManagedAgentPairRuntime, + ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentSummary, }, util::now_iso, }; @@ -68,34 +68,8 @@ mod lifecycle; use lifecycle::kill_stale_tracked_processes_with; pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes}; -/// Classify an agent's persona against the live catalog for the Agents-menu -/// drift indicator. Returns `(out_of_date, orphaned)`. -/// -/// Drift basis is the RECORD's `persona_source_version`, never the engram: -/// - persona_id set + persona present: out_of_date when the snapshot hash -/// differs from the persona's current content hash. -/// - persona_id set + persona gone: orphaned (no current hash to respawn into, -/// so never out_of_date — we must not tell the user to respawn into nothing). -/// - no persona_id: neither — a hand-built agent has no persona to drift from. -fn persona_drift_state( - record: &ManagedAgentRecord, - personas: &[crate::managed_agents::types::AgentDefinition], -) -> (bool, bool) { - let Some(persona_id) = record.persona_id.as_deref() else { - return (false, false); - }; - let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { - return (false, true); - }; - let current = crate::managed_agents::persona_events::persona_content_hash( - &crate::managed_agents::persona_events::persona_event_content(persona), - ); - let out_of_date = record - .persona_source_version - .as_deref() - .is_some_and(|pinned| pinned != current); - (out_of_date, false) -} +mod drift; +pub(crate) use drift::persona_drift_state; /// Resolve the runtime-pair key this record maps to for the active /// workspace: always the active workspace relay (the legacy per-record relay @@ -195,6 +169,16 @@ pub fn build_managed_agent_summary( let (persona_out_of_date, persona_orphaned) = persona_drift_state(record, personas); + // Degraded-empty on load failure is deliberate here. Among the consumers + // that gate a *launch decision* on the global config, this is the lone one + // that degrades instead of failing closed — its siblings all block the + // launch when a global env ref cannot be resolved: the readiness rows + // (`status_for*`, which capture `global_unavailable` and force + // local_setup=false) and the spawn/deploy gates (`spawn_agent_child`, + // `deploy`, which propagate with `?`). This summary is not a launch gate: + // it only derives the effective model/provider/prompt for display, so on + // load failure the fields read as unresolved — the correct degraded view, + // which never authorizes a launch. let global_for_summary = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config( @@ -419,9 +403,26 @@ pub fn spawn_agent_child( // frozen record snapshot. Mirrors the model resolution below. let personas = super::load_personas(app).unwrap_or_default(); let teams = super::load_teams(app).unwrap_or_default(); - // Load global config once; used for runtime_metadata_env_vars (model/provider fallback) - // and for the env-var merge at spawn time. - let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + // Fail-closed on unavailable definition secrets: mirrors `spawn_key_refusal` + // and the global-config gate below — refuse rather than launch empty. + if let Some(pid) = unavailable_definition_id(record, &personas) { + return Err(format!( + "agent {} cannot start: definition ({pid}) secrets unavailable from keyring", + record.pubkey + )); + } + // Fail-closed on an unavailable harness env projection: the effective + // harness carries an `env_ref` that could not be hydrated (missing, + // unreadable, or malformed), so its definition env would layer in empty. + // The harness tier of the instance/definition/global fail-closed gate. + if let Some(hid) = crate::managed_agents::unavailable_harness_id(record, &personas) { + return Err(format!( + "agent {} cannot start: harness ({hid}) env unavailable from keyring", + record.pubkey + )); + } + // Load global config; fail closed if a ref cannot be resolved. + let global = crate::managed_agents::load_global_agent_config(app)?; // Resolve model/provider/prompt ONCE, here, at the shared spawn boundary — // the single source both the env writes below and the spawn-config snapshot @@ -992,5 +993,7 @@ pub fn start_managed_agent_process( #[cfg(test)] mod test_fixtures; +#[cfg(test)] +mod definition_tier_tests; #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime/definition_tier_tests.rs b/desktop/src-tauri/src/managed_agents/runtime/definition_tier_tests.rs new file mode 100644 index 00000000000..8f635f752fe --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/definition_tier_tests.rs @@ -0,0 +1,170 @@ +//! Definition-tier fail-closed tests: verify that a linked instance's spawn +//! and readiness gate correctly refuse when the definition's secrets are +//! unavailable (env_vars_ref present but could not be hydrated from keyring). +use super::test_fixtures::fixture; +use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; + +fn pin_persona(record: &mut ManagedAgentRecord, persona: &crate::managed_agents::AgentDefinition) { + record.persona_id = Some(persona.id.clone()); +} + +fn persona_v( + id: &str, + prompt: &str, + env: &[(&str, &str)], +) -> crate::managed_agents::AgentDefinition { + use std::collections::BTreeMap; + crate::managed_agents::AgentDefinition { + id: id.to_string(), + display_name: id.to_string(), + avatar_url: None, + system_prompt: prompt.to_string(), + runtime: Some("goose".to_string()), + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: env + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>(), + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + secrets_unavailable: false, + } +} + +/// The pure definition-unavailable predicate that `spawn_agent_child`, +/// `runtime_status`, and `unkeyable_failed_status` all consult before reaching +/// any AppHandle-dependent path. +/// +/// `spawn_agent_child` gates on: +/// if let Some(pid) = unavailable_definition_id(record, personas) { → Err } +/// +/// This mirrors the test shape of `orphaned_linked_instance_returns_error` in +/// the same file: we test the predicate directly without a full AppHandle. +#[test] +fn definition_unavailable_spawn_predicate_fires() { + // A definition whose env_vars_ref could not be hydrated carries + // secrets_unavailable=true. Any linked instance must be refused at spawn. + let mut def = persona_v("def-slug", "prompt", &[("ANTHROPIC_API_KEY", "secret")]); + def.secrets_unavailable = true; + + let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + pin_persona(&mut record, &def); + assert_eq!(record.persona_id.as_deref(), Some("def-slug")); + + let personas = std::slice::from_ref(&def); + assert_eq!( + crate::managed_agents::unavailable_definition_id(&record, personas), + Some("def-slug"), + "spawn must detect unavailable definition and name it via the shared predicate" + ); +} + +#[test] +fn definition_available_spawn_predicate_does_not_fire() { + // Same setup but the definition is available — the predicate must return None. + let mut def = persona_v("def-slug", "prompt", &[("ANTHROPIC_API_KEY", "secret")]); + def.secrets_unavailable = false; + + let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + pin_persona(&mut record, &def); + + let personas = std::slice::from_ref(&def); + assert_eq!( + crate::managed_agents::unavailable_definition_id(&record, personas), + None, + "spawn must not refuse a linked instance whose definition is available" + ); +} + +#[test] +fn unlinked_record_never_matches_a_definition() { + // A record with no persona_id is not linked to any definition; even an + // unavailable definition in the slice must not make the predicate fire. + let def = { + let mut d = persona_v("def-slug", "prompt", &[("ANTHROPIC_API_KEY", "secret")]); + d.secrets_unavailable = true; + d + }; + let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + record.persona_id = None; + + assert_eq!( + crate::managed_agents::unavailable_definition_id(&record, std::slice::from_ref(&def)), + None, + "an unlinked record must never match a definition's unavailability" + ); +} + +#[test] +fn linked_definition_absent_from_slice_yields_none() { + // A record linked to a persona that is not present in the slice must yield + // None rather than treating the missing definition as unavailable. + let unavailable_other = persona_v( + "some-other-slug", + "prompt", + &[("ANTHROPIC_API_KEY", "secret")], + ); + let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + record.persona_id = Some("linked-but-missing".to_string()); + + assert_eq!( + crate::managed_agents::unavailable_definition_id( + &record, + std::slice::from_ref(&unavailable_other) + ), + None, + "a definition absent from the slice must not fire the predicate" + ); +} + +#[test] +fn definition_unavailable_propagates_via_to_definition_view() { + // `to_definition_view` must carry the definition record's + // secrets_unavailable flag into the AgentDefinition shape so that + // spawn/readiness callers get accurate availability state. + let mut def_record: ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "", + "relay_url": "", + "slug": "def-slug", + "name": "Def", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": "p", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }"#, + ) + .expect("definition fixture"); + def_record.secrets_unavailable = true; + + let view = def_record + .to_definition_view() + .expect("definition must produce a view"); + assert!( + view.secrets_unavailable, + "to_definition_view must propagate secrets_unavailable from the record" + ); + + // And the inverse: available record → available view. + def_record.secrets_unavailable = false; + let view2 = def_record.to_definition_view().expect("second view"); + assert!( + !view2.secrets_unavailable, + "to_definition_view must propagate false when the definition is available" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/drift.rs b/desktop/src-tauri/src/managed_agents/runtime/drift.rs new file mode 100644 index 00000000000..1f4b2cfbc01 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/drift.rs @@ -0,0 +1,32 @@ +//! Persona drift classification for the Agents-menu drift indicator. + +use crate::managed_agents::{types::AgentDefinition, ManagedAgentRecord}; + +/// Classify an agent's persona against the live catalog for the Agents-menu +/// drift indicator. Returns `(out_of_date, orphaned)`. +/// +/// Drift basis is the RECORD's `persona_source_version`, never the engram: +/// - persona_id set + persona present: out_of_date when the snapshot hash +/// differs from the persona's current content hash. +/// - persona_id set + persona gone: orphaned (no current hash to respawn into, +/// so never out_of_date — we must not tell the user to respawn into nothing). +/// - no persona_id: neither — a hand-built agent has no persona to drift from. +pub(crate) fn persona_drift_state( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], +) -> (bool, bool) { + let Some(persona_id) = record.persona_id.as_deref() else { + return (false, false); + }; + let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { + return (false, true); + }; + let current = crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(persona), + ); + let out_of_date = record + .persona_source_version + .as_deref() + .is_some_and(|pinned| pinned != current); + (out_of_date, false) +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index 9836d983ed3..65051c29943 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -89,5 +89,9 @@ pub(super) fn fixture( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 762b0fe2a61..b4567d3dec4 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -290,6 +290,7 @@ fn persona_with_provider( parallelism: None, created_at: "2026-06-09T00:00:00Z".to_string(), updated_at: "2026-06-09T00:00:00Z".to_string(), + secrets_unavailable: false, } } @@ -301,7 +302,6 @@ fn persona_with_provider( // provider — reach the agent on the next spawn without delete+recreate. // The merge assertions are load-bearing: they witness the credential refresh // that the old create-time env baking silently blocked. - use crate::managed_agents::env_vars::{live_persona_env, merged_user_env}; use std::collections::BTreeMap; diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b19..8d5a3f7438e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -7,9 +7,9 @@ use super::{ load_global_agent_config, load_managed_agents, load_personas, managed_agent_runtime_log_path, process_is_running, record_agent_command, resolve_effective_agent_env, save_managed_agents, spawn_agent_child, terminate_process, terminate_untracked_pair_runtime, - write_agent_runtime_receipt, AgentReadiness, BackendKind, ManagedAgentPairRuntime, - ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle, ManagedAgentRuntimeReceipt, - ManagedAgentRuntimeStatus, + unavailable_definition_id, unavailable_harness_id, write_agent_runtime_receipt, AgentReadiness, + BackendKind, ManagedAgentPairRuntime, ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle, + ManagedAgentRuntimeReceipt, ManagedAgentRuntimeStatus, }; use crate::app_state::AppState; @@ -23,7 +23,13 @@ fn status_for( requested_relay_url: Option, ) -> ManagedAgentRuntimeStatus { let personas = load_personas(app).unwrap_or_default(); - let global = load_global_agent_config(app).unwrap_or_default(); + // A global env_vars ref that cannot be resolved makes spawn refuse (see the + // fail-closed gate in `spawn_agent_child`). Reflect that here so a + // secrets-unavailable agent reads not-ready instead of advertising a + // local_setup it cannot honor. + let global_result = load_global_agent_config(app); + let global_unavailable = global_result.is_err(); + let global = global_result.unwrap_or_default(); status_for_with( app, record, @@ -33,6 +39,7 @@ fn status_for( StatusInputs { personas: &personas, global: &global, + global_unavailable, }, ) } @@ -42,6 +49,10 @@ fn status_for( struct StatusInputs<'a> { personas: &'a [super::AgentDefinition], global: &'a super::GlobalAgentConfig, + /// The global config's env_vars ref could not be resolved from the keyring. + /// Forces `local_setup` false: spawn refuses (see `spawn_agent_child`), so + /// advertising readiness would promise a launch the agent cannot honor. + global_unavailable: bool, } fn status_for_with( @@ -52,11 +63,31 @@ fn status_for_with( requested_relay_url: Option, inputs: StatusInputs<'_>, ) -> ManagedAgentRuntimeStatus { - let StatusInputs { personas, global } = inputs; + let StatusInputs { + personas, + global, + global_unavailable, + } = inputs; let command = record_agent_command(record, personas); let metadata = super::known_acp_runtime(&command); let effective = resolve_effective_agent_env(record, personas, metadata, global); - let local_setup = matches!(agent_readiness(&effective), AgentReadiness::Ready); + // `secrets_unavailable` means at least one keyring ref exists but the + // entry is missing/unreadable. Spawn will refuse via `spawn_key_refusal`, + // so `local_setup` must also be false — the agent cannot start even if the + // effective env happens to satisfy the runtime's credential check. + // `global_unavailable` is the same failure at the global tier: spawn refuses + // in `spawn_agent_child` when the global env_vars ref cannot be resolved. + // `definition_unavailable` is the third tier: a linked instance whose + // definition's env could not be hydrated is refused at spawn time. + // `harness_unavailable` is the fourth tier: the effective harness's own + // `env_ref` could not be hydrated, which `spawn_agent_child` also refuses. + let definition_unavailable = unavailable_definition_id(record, personas).is_some(); + let harness_unavailable = unavailable_harness_id(record, personas).is_some(); + let local_setup = !record.secrets_unavailable + && !global_unavailable + && !definition_unavailable + && !harness_unavailable + && matches!(agent_readiness(&effective), AgentReadiness::Ready); ManagedAgentRuntimeStatus { pubkey: key.pubkey.clone(), relay_url: key.relay_url.clone(), @@ -145,7 +176,9 @@ pub fn list_managed_agent_runtimes( // on every status event — load the per-row status inputs once, outside // the locks, instead of hitting disk per row while holding them. let personas = load_personas(&app).unwrap_or_default(); - let global = load_global_agent_config(&app).unwrap_or_default(); + let global_result = load_global_agent_config(&app); + let global_unavailable = global_result.is_err(); + let global = global_result.unwrap_or_default(); let state = app.state::(); let _transition = state .managed_agent_runtime_transition @@ -188,6 +221,7 @@ pub fn list_managed_agent_runtimes( StatusInputs { personas: &personas, global: &global, + global_unavailable, }, ); emit_status(&app, &status); @@ -207,6 +241,7 @@ pub fn list_managed_agent_runtimes( StatusInputs { personas: &personas, global: &global, + global_unavailable, }, )) })); @@ -430,15 +465,22 @@ fn unkeyable_failed_status( error: String, personas: &[super::AgentDefinition], global: &super::GlobalAgentConfig, + global_unavailable: bool, ) -> ManagedAgentRuntimeStatus { let command = record_agent_command(record, personas); let metadata = super::known_acp_runtime(&command); let effective = resolve_effective_agent_env(record, personas, metadata, global); + let definition_unavailable = unavailable_definition_id(record, personas).is_some(); + let harness_unavailable = unavailable_harness_id(record, personas).is_some(); ManagedAgentRuntimeStatus { pubkey: record.pubkey.clone(), relay_url: requested.clone(), requested_relay_url: Some(requested), - local_setup: matches!(agent_readiness(&effective), AgentReadiness::Ready), + local_setup: !record.secrets_unavailable + && !global_unavailable + && !definition_unavailable + && !harness_unavailable + && matches!(agent_readiness(&effective), AgentReadiness::Ready), lifecycle: ManagedAgentRuntimeLifecycle::Failed, pid: None, error: Some(error), @@ -497,7 +539,9 @@ pub async fn reconcile_managed_agent_runtimes( // restart flows. tokio::task::spawn_blocking(move || { let personas = load_personas(&app).unwrap_or_default(); - let global = load_global_agent_config(&app).unwrap_or_default(); + let global_result = load_global_agent_config(&app); + let global_unavailable = global_result.is_err(); + let global = global_result.unwrap_or_default(); let mut rows = Vec::new(); for probe in probes { match probe { @@ -523,6 +567,7 @@ pub async fn reconcile_managed_agent_runtimes( StatusInputs { personas: &personas, global: &global, + global_unavailable, }, ); status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; @@ -548,6 +593,7 @@ pub async fn reconcile_managed_agent_runtimes( StatusInputs { personas: &personas, global: &global, + global_unavailable, }, ); status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; @@ -555,7 +601,12 @@ pub async fn reconcile_managed_agent_runtimes( status } Err(_) => unkeyable_failed_status( - &record, requested, error, &personas, &global, + &record, + requested, + error, + &personas, + &global, + global_unavailable, ), }; rows.push(status); @@ -569,148 +620,5 @@ pub async fn reconcile_managed_agent_runtimes( } #[cfg(test)] -mod tests { - use super::*; - - fn payload( - relay_url: &str, - lifecycle: ManagedAgentRuntimeLifecycle, - error: Option<&str>, - ) -> super::super::ManagedAgentRuntimeLifecycleObserverPayload { - super::super::ManagedAgentRuntimeLifecycleObserverPayload { - pubkey: "aa".repeat(32), - relay_url: relay_url.into(), - start_nonce: "test-generation".into(), - lifecycle, - error: error.map(str::to_owned), - } - } - - fn record_with_relay(relay_url: &str) -> super::super::ManagedAgentRecord { - serde_json::from_str(&format!( - r#"{{ - "pubkey": "{}", - "name": "pin-test", - "relay_url": "{relay_url}", - "acp_command": "buzz-acp", - "agent_command": "goose", - "agent_args": [], - "mcp_command": "", - "turn_timeout_seconds": 320, - "system_prompt": "", - "created_at": "2026-01-01T00:00:00Z", - "updated_at": "2026-01-01T00:00:00Z" - }}"#, - "aa".repeat(32) - )) - .unwrap() - } - - #[test] - fn legacy_relay_pin_is_ignored_for_fan_out() { - // Zero-touch cutover (#2122): a record carrying a creation-era - // `relay_url` pin must fan out exactly like an unpinned one — the - // stored field is parsed but never consulted. See - // `effective_agent_relay_url`. - let unpinned = record_with_relay(""); - let pinned = record_with_relay("wss://one.example"); - for record in [&unpinned, &pinned] { - assert_eq!( - crate::relay::effective_agent_relay_url(&record.relay_url, "wss://two.example"), - "wss://two.example" - ); - } - } - - #[test] - fn unkeyable_relay_degrades_to_failed_row() { - // A requested URL that cannot form a pair key must still yield a - // Failed row keyed by the raw requested string, so one bad community - // never aborts the rest of the reconcile batch. - let record = record_with_relay(""); - let status = unkeyable_failed_status( - &record, - "not a url".to_string(), - "relay access probe timed out".to_string(), - &[], - &super::super::GlobalAgentConfig::default(), - ); - assert!(matches!( - status.lifecycle, - ManagedAgentRuntimeLifecycle::Failed - )); - assert_eq!(status.relay_url, "not a url"); - assert_eq!(status.requested_relay_url.as_deref(), Some("not a url")); - assert_eq!(status.pubkey, record.pubkey); - assert_eq!( - status.error.as_deref(), - Some("relay access probe timed out") - ); - assert!(status.pid.is_none()); - } - - #[test] - fn runtime_key_rejects_non_hex_pubkeys() { - assert!(ManagedAgentRuntimeKey::new("../not-a-key", "wss://relay.example").is_err()); - assert!(ManagedAgentRuntimeKey::new("gg".repeat(32), "wss://relay.example").is_err()); - } - - #[test] - fn runtime_key_canonicalizes_hex_pubkeys() { - let key = ManagedAgentRuntimeKey::new("AA".repeat(32), "wss://relay.example").unwrap(); - assert_eq!(key.pubkey, "aa".repeat(32)); - } - - #[test] - fn observer_lifecycle_key_preserves_exact_canonical_pair() { - let first = payload( - "WSS://Relay.Example:443/", - ManagedAgentRuntimeLifecycle::Ready, - None, - ); - let key = observer_lifecycle_key(&first.pubkey, &first).unwrap(); - assert_eq!(key.pubkey, first.pubkey); - assert_eq!(key.relay_url, "wss://relay.example"); - - let other = payload( - "wss://other.example", - ManagedAgentRuntimeLifecycle::Ready, - None, - ); - assert_ne!(key, observer_lifecycle_key(&other.pubkey, &other).unwrap()); - } - - #[test] - fn observer_lifecycle_rejects_cross_agent_and_desktop_states() { - let ready = payload( - "wss://relay.example", - ManagedAgentRuntimeLifecycle::Ready, - None, - ); - assert!(observer_lifecycle_key(&"bb".repeat(32), &ready).is_err()); - - let stopped = payload( - "wss://relay.example", - ManagedAgentRuntimeLifecycle::Stopped, - None, - ); - assert!(observer_lifecycle_key(&stopped.pubkey, &stopped).is_err()); - } - - #[test] - fn observer_lifecycle_enforces_failed_error_contract() { - let failed = payload( - "wss://relay.example", - ManagedAgentRuntimeLifecycle::Failed, - None, - ); - assert!(observer_lifecycle_key(&failed.pubkey, &failed).is_err()); - - let ready_with_error = payload( - "wss://relay.example", - ManagedAgentRuntimeLifecycle::Ready, - Some("unexpected"), - ); - assert!(observer_lifecycle_key(&ready_with_error.pubkey, &ready_with_error).is_err()); - } -} +#[path = "runtime_commands_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands_tests.rs b/desktop/src-tauri/src/managed_agents/runtime_commands_tests.rs new file mode 100644 index 00000000000..0bfe6c82e7c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_commands_tests.rs @@ -0,0 +1,554 @@ +//! Runtime-command status tests: relay-pin fan-out, and the fail-closed +//! `local_setup` gates (global/definition/harness tiers). +//! +//! Extracted from `runtime_commands.rs` via `#[path]` so that module stays +//! under the desktop file-size ratchet; `super::*` resolves against it, +//! matching the `readiness_goose_file_config_tests.rs` convention. + +use super::*; + +fn payload( + relay_url: &str, + lifecycle: ManagedAgentRuntimeLifecycle, + error: Option<&str>, +) -> super::super::ManagedAgentRuntimeLifecycleObserverPayload { + super::super::ManagedAgentRuntimeLifecycleObserverPayload { + pubkey: "aa".repeat(32), + relay_url: relay_url.into(), + start_nonce: "test-generation".into(), + lifecycle, + error: error.map(str::to_owned), + } +} + +fn record_with_relay(relay_url: &str) -> super::super::ManagedAgentRecord { + serde_json::from_str(&format!( + r#"{{ + "pubkey": "{}", + "name": "pin-test", + "relay_url": "{relay_url}", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": "", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }}"#, + "aa".repeat(32) + )) + .unwrap() +} + +#[test] +fn legacy_relay_pin_is_ignored_for_fan_out() { + // Zero-touch cutover (#2122): a record carrying a creation-era + // `relay_url` pin must fan out exactly like an unpinned one — the + // stored field is parsed but never consulted. See + // `effective_agent_relay_url`. + let unpinned = record_with_relay(""); + let pinned = record_with_relay("wss://one.example"); + for record in [&unpinned, &pinned] { + assert_eq!( + crate::relay::effective_agent_relay_url(&record.relay_url, "wss://two.example"), + "wss://two.example" + ); + } +} + +#[test] +fn unkeyable_relay_degrades_to_failed_row() { + // A requested URL that cannot form a pair key must still yield a + // Failed row keyed by the raw requested string, so one bad community + // never aborts the rest of the reconcile batch. + let record = record_with_relay(""); + let status = unkeyable_failed_status( + &record, + "not a url".to_string(), + "relay access probe timed out".to_string(), + &[], + &super::super::GlobalAgentConfig::default(), + false, + ); + assert!(matches!( + status.lifecycle, + ManagedAgentRuntimeLifecycle::Failed + )); + assert_eq!(status.relay_url, "not a url"); + assert_eq!(status.requested_relay_url.as_deref(), Some("not a url")); + assert_eq!(status.pubkey, record.pubkey); + assert_eq!( + status.error.as_deref(), + Some("relay access probe timed out") + ); + assert!(status.pid.is_none()); +} + +#[test] +fn runtime_key_rejects_non_hex_pubkeys() { + assert!(ManagedAgentRuntimeKey::new("../not-a-key", "wss://relay.example").is_err()); + assert!(ManagedAgentRuntimeKey::new("gg".repeat(32), "wss://relay.example").is_err()); +} + +#[test] +fn runtime_key_canonicalizes_hex_pubkeys() { + let key = ManagedAgentRuntimeKey::new("AA".repeat(32), "wss://relay.example").unwrap(); + assert_eq!(key.pubkey, "aa".repeat(32)); +} + +#[test] +fn observer_lifecycle_key_preserves_exact_canonical_pair() { + let first = payload( + "WSS://Relay.Example:443/", + ManagedAgentRuntimeLifecycle::Ready, + None, + ); + let key = observer_lifecycle_key(&first.pubkey, &first).unwrap(); + assert_eq!(key.pubkey, first.pubkey); + assert_eq!(key.relay_url, "wss://relay.example"); + + let other = payload( + "wss://other.example", + ManagedAgentRuntimeLifecycle::Ready, + None, + ); + assert_ne!(key, observer_lifecycle_key(&other.pubkey, &other).unwrap()); +} + +#[test] +fn observer_lifecycle_rejects_cross_agent_and_desktop_states() { + let ready = payload( + "wss://relay.example", + ManagedAgentRuntimeLifecycle::Ready, + None, + ); + assert!(observer_lifecycle_key(&"bb".repeat(32), &ready).is_err()); + + let stopped = payload( + "wss://relay.example", + ManagedAgentRuntimeLifecycle::Stopped, + None, + ); + assert!(observer_lifecycle_key(&stopped.pubkey, &stopped).is_err()); +} + +#[test] +fn observer_lifecycle_enforces_failed_error_contract() { + let failed = payload( + "wss://relay.example", + ManagedAgentRuntimeLifecycle::Failed, + None, + ); + assert!(observer_lifecycle_key(&failed.pubkey, &failed).is_err()); + + let ready_with_error = payload( + "wss://relay.example", + ManagedAgentRuntimeLifecycle::Ready, + Some("unexpected"), + ); + assert!(observer_lifecycle_key(&ready_with_error.pubkey, &ready_with_error).is_err()); +} + +#[test] +fn secrets_unavailable_forces_local_setup_false() { + // When `secrets_unavailable` is true on a record, `local_setup` must + // be false regardless of what `agent_readiness` would return for the + // effective env — spawn will refuse, and the UI must reflect that. + let mut record = record_with_relay(""); + record.secrets_unavailable = true; + + let status = unkeyable_failed_status( + &record, + "wss://relay.example".to_string(), + "test error".to_string(), + &[], + &super::super::GlobalAgentConfig::default(), + false, + ); + assert!( + !status.local_setup, + "local_setup must be false when secrets_unavailable is true" + ); +} + +#[test] +fn global_unavailable_forces_local_setup_false() { + // When the global env_vars ref cannot be resolved from the keyring, + // spawn refuses in `spawn_agent_child`. A record with fully-available + // per-agent secrets must still read not-ready so the UI never + // advertises a local_setup the agent cannot honor. + let record = record_with_relay(""); + assert!( + !record.secrets_unavailable, + "guard: this test isolates the global-tier gate, not the record gate" + ); + + let status = unkeyable_failed_status( + &record, + "wss://relay.example".to_string(), + "test error".to_string(), + &[], + &super::super::GlobalAgentConfig::default(), + true, // global_unavailable + ); + assert!( + !status.local_setup, + "local_setup must be false when the global config is unavailable" + ); +} + +fn linked_record() -> super::super::ManagedAgentRecord { + let mut rec = record_with_relay(""); + rec.persona_id = Some("def-slug".to_string()); + rec +} + +/// A linked record whose effective env satisfies the `buzz-agent` readiness +/// gate, so `agent_readiness` returns `Ready`. This makes the linked +/// definition's `secrets_unavailable` flag the SOLE remaining input to +/// `local_setup` — without a Ready baseline, `local_setup` is false for +/// unrelated reasons (empty env) and the definition gate cannot be observed. +/// The command resolves to `buzz-agent` (persona runtime absent → +/// `default_agent_command`), and these three non-reserved env vars survive +/// into the effective env via the record's own `env_vars` layer. +fn ready_linked_record() -> super::super::ManagedAgentRecord { + let mut rec = linked_record(); + rec.env_vars + .insert("BUZZ_AGENT_PROVIDER".into(), "anthropic".into()); + rec.env_vars + .insert("BUZZ_AGENT_MODEL".into(), "claude-opus-4-5".into()); + rec.env_vars + .insert("ANTHROPIC_API_KEY".into(), "sk-test".into()); + rec +} + +fn available_definition() -> super::super::AgentDefinition { + let mut d = super::super::AgentDefinition { + id: "def-slug".to_string(), + display_name: "Def".to_string(), + system_prompt: "prompt".to_string(), + created_at: "2026-01-01".to_string(), + updated_at: "2026-01-01".to_string(), + ..Default::default() + }; + d.secrets_unavailable = false; + d +} + +fn unavailable_definition() -> super::super::AgentDefinition { + let mut d = available_definition(); + d.secrets_unavailable = true; + d +} + +/// Positive control for the two tests below: with a Ready baseline and an +/// AVAILABLE definition, `local_setup` is true. This is what makes the +/// negative assertions meaningful — it proves the `false` outcomes there +/// are caused by the definition-unavailable gate, not by the record failing +/// readiness for some other reason. If the readiness recipe ever drifts and +/// this record stops being Ready, this test fails first and loudly. +#[test] +fn ready_linked_record_with_available_definition_is_local_setup_true() { + let record = ready_linked_record(); + let def = available_definition(); + + let status = unkeyable_failed_status( + &record, + "wss://relay.example".to_string(), + "test error".to_string(), + std::slice::from_ref(&def), + &super::super::GlobalAgentConfig::default(), + false, + ); + assert!( + status.local_setup, + "a Ready record linked to an available definition must show local_setup=true" + ); +} + +#[test] +fn definition_unavailable_forces_local_setup_false() { + // A linked instance whose definition's env_vars ref could not be + // hydrated must show local_setup=false — spawn will refuse, so + // advertising readiness would promise a launch we cannot honor. The + // record is otherwise Ready (see the positive control above), so the + // only thing forcing false here is the definition-unavailable gate: + // remove `&& !definition_unavailable` from `local_setup` and this fails. + let record = ready_linked_record(); + let def = unavailable_definition(); + + let status = unkeyable_failed_status( + &record, + "wss://relay.example".to_string(), + "test error".to_string(), + std::slice::from_ref(&def), + &super::super::GlobalAgentConfig::default(), + false, + ); + assert!( + !status.local_setup, + "local_setup must be false when the linked definition is unavailable" + ); +} + +#[test] +fn unlinked_instance_ignores_definition_unavailability() { + // An agent with no persona_id is not linked to any definition; an + // unavailable definition in the slice must not force its local_setup + // false. The record is Ready, so local_setup stays true — proving the + // gate keys on persona_id and does not blindly scan the slice. Break + // the predicate to match any unavailable definition and this fails. + let mut record = ready_linked_record(); + record.persona_id = None; + + let def = unavailable_definition(); // must be invisible to the unlinked record + let status = unkeyable_failed_status( + &record, + "wss://relay.example".to_string(), + "test error".to_string(), + std::slice::from_ref(&def), + &super::super::GlobalAgentConfig::default(), + false, + ); + assert!( + status.local_setup, + "an unlinked record must ignore an unavailable definition and stay Ready" + ); +} + +// ── Harness-tier gate: an unavailable harness env forces local_setup=false ── +// +// These mirror the definition-tier trio above but exercise the fourth tier: +// the effective harness's own `env_ref` could not be hydrated +// (`env_unavailable`), so `spawn_agent_child` refuses and the status row +// must not advertise a launch it cannot honor. The harness is resolved from +// the in-process registry, so each test holds `registry_test_lock()` and +// restores an empty registry on the way out. + +use crate::managed_agents::custom_harnesses::{ + registry_test_lock, update_loaded_harness_registry, HarnessDefinition, +}; + +/// A custom harness whose command is the always-known `buzz-agent` runtime, +/// so readiness evaluates the same recipe `ready_harness_record` satisfies. +/// `env_unavailable` is the single toggled input across the tests below. +fn harness_def(env_unavailable: bool) -> HarnessDefinition { + HarnessDefinition { + id: "my-harness".to_string(), + label: "My Harness".to_string(), + command: "buzz-agent".to_string(), + args: vec![], + env: std::collections::BTreeMap::new(), + env_ref: env_unavailable.then(|| "gen-1".to_string()), + env_unavailable, + install_instructions_url: String::new(), + install_hint: String::new(), + } +} + +/// A record pinned to the `my-harness` runtime whose own env vars satisfy +/// the buzz-agent readiness gate — so the harness-unavailable gate is the +/// sole remaining input to `local_setup`. +fn ready_harness_record() -> super::super::ManagedAgentRecord { + let mut rec = record_with_relay(""); + rec.runtime = Some("my-harness".to_string()); + rec.env_vars + .insert("BUZZ_AGENT_PROVIDER".into(), "anthropic".into()); + rec.env_vars + .insert("BUZZ_AGENT_MODEL".into(), "claude-opus-4-5".into()); + rec.env_vars + .insert("ANTHROPIC_API_KEY".into(), "sk-test".into()); + rec +} + +#[test] +fn ready_record_with_available_harness_is_local_setup_true() { + // Positive control: a Ready record whose pinned harness is AVAILABLE + // must show local_setup=true — proving the `false` outcome below comes + // from the harness gate, not from the record failing readiness. + let _lock = registry_test_lock(); + update_loaded_harness_registry(vec![harness_def(false)]); + + let status = unkeyable_failed_status( + &ready_harness_record(), + "wss://relay.example".to_string(), + "test error".to_string(), + &[], + &super::super::GlobalAgentConfig::default(), + false, + ); + + update_loaded_harness_registry(vec![]); + assert!( + status.local_setup, + "a Ready record on an available harness must show local_setup=true" + ); +} + +#[test] +fn harness_unavailable_forces_local_setup_false() { + // A record whose effective harness carries an unhydratable env_ref must + // show local_setup=false — spawn refuses via `unavailable_harness_id`. + // The record is otherwise Ready (see the positive control above), so the + // only thing forcing false here is the harness gate: remove + // `&& !harness_unavailable` from `local_setup` and this fails. + let _lock = registry_test_lock(); + update_loaded_harness_registry(vec![harness_def(true)]); + + let status = unkeyable_failed_status( + &ready_harness_record(), + "wss://relay.example".to_string(), + "test error".to_string(), + &[], + &super::super::GlobalAgentConfig::default(), + false, + ); + + update_loaded_harness_registry(vec![]); + assert!( + !status.local_setup, + "local_setup must be false when the effective harness env is unavailable" + ); +} + +#[test] +fn record_on_a_different_harness_ignores_unavailable_one() { + // A record pinned to a DIFFERENT (available) runtime must not be forced + // false by an unavailable harness elsewhere in the registry — the gate + // consults only the harness a spawn would actually launch. + let _lock = registry_test_lock(); + update_loaded_harness_registry(vec![harness_def(true)]); + + let mut record = ready_harness_record(); + record.runtime = Some("buzz-agent".to_string()); // builtin, always available + + let status = unkeyable_failed_status( + &record, + "wss://relay.example".to_string(), + "test error".to_string(), + &[], + &super::super::GlobalAgentConfig::default(), + false, + ); + + update_loaded_harness_registry(vec![]); + assert!( + status.local_setup, + "a record on a different, available harness must stay Ready" + ); +} + +#[test] +fn harness_unavailable_spawn_predicate_fires() { + // The direct spawn binding (mirrors `definition_unavailable_spawn_predicate_fires`): + // `spawn_agent_child` gates on `unavailable_harness_id(record, personas)` + // before any AppHandle-dependent path, so this asserts the exact predicate + // that gate consults fires for a spawn-shaped record. Deleting the harness + // env_unavailable set or the predicate filter makes this fail. + let _lock = registry_test_lock(); + update_loaded_harness_registry(vec![harness_def(true)]); + + let fired = crate::managed_agents::unavailable_harness_id(&ready_harness_record(), &[]); + + update_loaded_harness_registry(vec![]); + assert_eq!( + fired.as_deref(), + Some("my-harness"), + "spawn must detect the unavailable harness and name it via the shared predicate" + ); +} + +#[test] +fn harness_available_spawn_predicate_does_not_fire() { + // Inverse of the above: an available harness must not fire the spawn gate. + let _lock = registry_test_lock(); + update_loaded_harness_registry(vec![harness_def(false)]); + + let fired = crate::managed_agents::unavailable_harness_id(&ready_harness_record(), &[]); + + update_loaded_harness_registry(vec![]); + assert_eq!( + fired, None, + "spawn must not refuse a record whose effective harness is available" + ); +} + +// ── effective_secrets_unavailable: the restart-eligibility predicate ───────── +// +// The restart-eligibility boundaries (post-install bounce, global-config +// bounce) gate on this single predicate, which ORs the three +// registry-derivable tiers. These tests prove each tier flips it independently +// and a fully-healthy record does not, so the restart flows fail closed on ANY +// unavailable tier — not just the harness one. + +use crate::managed_agents::effective_secrets_unavailable; + +#[test] +fn effective_secrets_unavailable_false_for_healthy_record() { + // Positive control: a linked record with an available definition and no + // harness pin (builtin) has no unavailable tier — the predicate is false, + // so the negative-tier assertions below are meaningful. + let _lock = registry_test_lock(); + update_loaded_harness_registry(vec![]); + + let record = linked_record(); + let def = available_definition(); + + let out = effective_secrets_unavailable(&record, std::slice::from_ref(&def)); + + assert!( + !out, + "a record with all tiers available must not be flagged unavailable" + ); +} + +#[test] +fn effective_secrets_unavailable_true_for_instance_tier() { + // Instance tier: the record's own secrets_unavailable set (its env_vars_ref + // failed to hydrate). Mutation check: drop `record.secrets_unavailable` + // from the OR and this fails. + let _lock = registry_test_lock(); + update_loaded_harness_registry(vec![]); + + let mut record = linked_record(); + record.secrets_unavailable = true; + let def = available_definition(); + + assert!( + effective_secrets_unavailable(&record, std::slice::from_ref(&def)), + "an instance whose own secrets are unavailable must fire the predicate" + ); +} + +#[test] +fn effective_secrets_unavailable_true_for_definition_tier() { + // Definition tier: a linked persona whose secrets_unavailable is set. + // Mutation check: drop the `unavailable_definition_id(..).is_some()` arm. + let _lock = registry_test_lock(); + update_loaded_harness_registry(vec![]); + + let record = linked_record(); + let def = unavailable_definition(); + + assert!( + effective_secrets_unavailable(&record, std::slice::from_ref(&def)), + "a record linked to a definition with unavailable secrets must fire the predicate" + ); +} + +#[test] +fn effective_secrets_unavailable_true_for_harness_tier() { + // Harness tier: the effective harness carries an unhydratable env_ref. + // Mutation check: drop the `unavailable_harness_id(..).is_some()` arm. + let _lock = registry_test_lock(); + update_loaded_harness_registry(vec![harness_def(true)]); + + let out = effective_secrets_unavailable(&ready_harness_record(), &[]); + + update_loaded_harness_registry(vec![]); + assert!( + out, + "a record whose effective harness env is unavailable must fire the predicate" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/secret_projection.rs b/desktop/src-tauri/src/managed_agents/secret_projection.rs new file mode 100644 index 00000000000..1d0a1e5e3ab --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/secret_projection.rs @@ -0,0 +1,950 @@ +//! Immutable generation-reference protocol for persisted secrets. +//! +//! # Overview +//! +//! All secrets (agent env vars, auth tags, provider configs, global env vars, +//! definition env vars) are stored as **immutable generations** in the existing +//! [`SecretStore`] keyring blob. Each save creates a new generation entry under +//! a unique ID; the stripped JSON record carries a non-secret `*_ref` field +//! pointing at the live generation. The **atomic JSON write** is the commit +//! point. +//! +//! ## Coordinates +//! +//! ```text +//! global:env: +//! agent::env: +//! agent::auth_tag: +//! agent::provider_config: (entire BackendKind::Provider.config blob) +//! definition::env: (durable AgentDefinition.id / slug) +//! ``` +//! +//! ## Save protocol +//! +//! 1. Write new generation to blob + raw read-back verify. +//! 2. Mark the new generation as GC-safe-to-candidate by REMOVING any prior +//! candidate mark it might have inherited — not applicable here since `gen` +//! is new. +//! 3. Atomically commit JSON carrying the new `*_ref` (the JSON write is THE +//! commit point). +//! 4. The old generation is NOT deleted here. Eager deletion before the JSON +//! commit could orphan a committed secret on write failure; retirement is +//! left entirely to the two-cycle GC below. +//! +//! ## GC (two-cycle) +//! +//! - Sweep 1 (`mark_gc_candidates`): parse both raw stores, enumerate all +//! live `*_ref` values, then mark any generation in our namespaces that is +//! NOT referenced as a candidate (stored as `_candidate` = "1" in the +//! blob). +//! - Sweep 2 (`delete_gc_candidates`): re-parse both raw stores to confirm a +//! candidate is still unreferenced; only then delete it. +//! - A save in flight cancels its generation's candidacy before the JSON commit. +//! - GC is a no-op when either store is absent, unreadable, or changed between +//! reference collection and blob mutation. +//! +//! ## Empty vs unavailable +//! +//! - **No `*_ref` field** = field is intentionally empty/absent → agent runs. +//! - **`*_ref` present but blob entry missing/unreadable** = unavailable → +//! fail closed, nsec-style refusal. +//! +//! ## Inline fallback +//! +//! When a keyring write fails (Windows TooLong / backend error), the value +//! stays inline in the `0o600` JSON with a named warning. Inline and `*_ref` +//! are mutually exclusive in a healthy record; inline is authoritative when +//! both are present (takes priority over any stale ref during hydration). + +use std::collections::{BTreeMap, HashMap}; + +use serde_json::Value as JsonValue; + +use crate::secret_store::SecretStore; + +// ── GC candidate suffix ──────────────────────────────────────────────────── +// +// A GC candidate key is the generation key with this suffix appended. +// Example: `agent:abc:env:gen1` → `agent:abc:env:gen1_candidate` +// +// The value is always "1"; presence is the signal. +const GC_CANDIDATE_SUFFIX: &str = "_candidate"; + +// ── Secret-shape namespace prefixes ─────────────────────────────────────── +const NS_GLOBAL_ENV: &str = "global:env:"; +const NS_AGENT_ENV: &str = "agent:"; +const NS_DEFINITION_ENV: &str = "definition:"; + +// ── Dev-migration conflict marker ────────────────────────────────────────── +// +// The dev secrets migration copies projection generations from the source +// keyring service into the dev service. A coordinate present in BOTH with +// DIFFERENT values is a conflict it refuses to resolve. Withholding the +// completion marker only schedules a retry — it does NOT stop the destination's +// (possibly wrong) value from being hydrated and consumed during the retry +// window. To make a conflicted coordinate genuinely unavailable, the migration +// writes a `conflict:` marker; `load_secret` fails closed whenever +// a coordinate carries one, so hydration sets `secrets_unavailable` and every +// downstream gate (spawn, deploy, readiness, the effective-secret gate) refuses +// until a later migration clears the marker. +const NS_CONFLICT: &str = "conflict:"; + +// ── Per-namespace sub-part constants ────────────────────────────────────── +const PART_ENV: &str = ":env:"; +const PART_AUTH_TAG: &str = ":auth_tag:"; +const PART_PROVIDER_CONFIG: &str = ":provider_config:"; + +// ── Generation ID ───────────────────────────────────────────────────────── + +/// Generate a new unique generation ID for a blob key. +/// +/// Uses UUIDv4 without hyphens: compact, URL-safe, and collision-resistant. +pub fn new_gen_id() -> String { + uuid::Uuid::new_v4().simple().to_string() +} + +// ── Blob key constructors ───────────────────────────────────────────────── + +pub fn global_env_key(gen: &str) -> String { + format!("{NS_GLOBAL_ENV}{gen}") +} + +pub fn agent_env_key(pubkey: &str, gen: &str) -> String { + format!("{NS_AGENT_ENV}{pubkey}{PART_ENV}{gen}") +} + +pub fn agent_auth_tag_key(pubkey: &str, gen: &str) -> String { + format!("{NS_AGENT_ENV}{pubkey}{PART_AUTH_TAG}{gen}") +} + +pub fn agent_provider_config_key(pubkey: &str, gen: &str) -> String { + format!("{NS_AGENT_ENV}{pubkey}{PART_PROVIDER_CONFIG}{gen}") +} + +pub fn definition_env_key(slug: &str, gen: &str) -> String { + format!("{NS_DEFINITION_ENV}{slug}{PART_ENV}{gen}") +} + +/// Returns true if `key` is in one of our secret-projection namespaces +/// (including GC candidate markers). +pub fn is_projection_key(key: &str) -> bool { + key.starts_with(NS_GLOBAL_ENV) + || (key.starts_with(NS_AGENT_ENV) + && (key.contains(PART_ENV) + || key.contains(PART_AUTH_TAG) + || key.contains(PART_PROVIDER_CONFIG))) + || (key.starts_with(NS_DEFINITION_ENV) && key.contains(PART_ENV)) +} + +// ── KeyStore trait extension ─────────────────────────────────────────────── + +/// The subset of [`SecretStore`] operations the projection logic needs. +/// +/// Abstracted for unit testing (can be backed by a [`FakeSecretStore`]). +pub trait ProjectionStore { + fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String>; + fn load_key(&self, key: &str) -> Result, String>; + fn load_all(&self) -> Result>, String>; + fn store_batch(&self, entries: &HashMap) -> Result<(), String>; + fn remove_batch(&self, keys: &[&str]) -> Result<(), String>; + + /// Commit `entries` in a single blob mutation, then confirm each key holds + /// its value with a durable, cache-bypassing read — the batched analogue of + /// [`write_and_verify`](Self::write_and_verify). + /// + /// The default verifies via [`load_key`](Self::load_key), which is exact for + /// stores with no cache/durable split (the test fakes). [`SecretStore`] + /// overrides it with a raw keychain read so a backend that acknowledges a + /// write it did not persist is still caught, exactly as the per-key path + /// does. + fn store_batch_verified(&self, entries: &HashMap) -> Result<(), String> { + self.store_batch(entries)?; + for (key, value) in entries { + match self.load_key(key)? { + Some(ref stored) if stored == value => {} + _ => return Err(format!("keyring read-back verify failed for {key}")), + } + } + Ok(()) + } +} + +impl ProjectionStore for SecretStore { + fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String> { + self.store(key, value)?; + match self.verify_stored_raw(key, value) { + Ok(true) => Ok(()), + Ok(false) => Err(format!("keyring read-back verify failed for {key}")), + Err(e) => Err(format!("keyring read-back verify error for {key}: {e}")), + } + } + + fn load_key(&self, key: &str) -> Result, String> { + self.load(key) + } + + fn load_all(&self) -> Result>, String> { + self.load_all_readonly() + } + + fn store_batch(&self, entries: &HashMap) -> Result<(), String> { + self.store_all(entries) + } + + fn remove_batch(&self, keys: &[&str]) -> Result<(), String> { + for key in keys { + self.delete(key)?; + } + Ok(()) + } + + /// Override the default so verification bypasses the in-process cache: after + /// `store_all` advances the cache to the written state, a cached `load` + /// would pass even when the OS keychain write silently failed. + /// [`verify_stored_raw`](SecretStore::verify_stored_raw) reads the raw blob + /// direct from the backend, proving the round-trip the same way the per-key + /// `write_and_verify` does. + fn store_batch_verified(&self, entries: &HashMap) -> Result<(), String> { + self.store_all(entries)?; + for (key, value) in entries { + match self.verify_stored_raw(key, value) { + Ok(true) => {} + Ok(false) => return Err(format!("keyring read-back verify failed for {key}")), + Err(e) => return Err(format!("keyring read-back verify error for {key}: {e}")), + } + } + Ok(()) + } +} + +// ── Projection outcome ───────────────────────────────────────────────────── + +/// Result of writing a secret to the keyring and verifying it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WriteOutcome { + /// Written and read-back verified. JSON `*_ref` should be set to the new gen. + Persisted { gen: String }, + /// Keyring write failed (backend error or Windows TooLong). Value must stay + /// inline in `0o600` JSON with a warning; ref field must be cleared. + KeptInline { reason: String }, + /// The value was empty/None — no write attempted, no ref set. + Nothing, +} + +// ── Write-with-verify ───────────────────────────────────────────────────── + +/// Attempt to write `value` to the keyring under a new generation key +/// `blob_key(new_gen_id())`. +/// +/// Returns [`WriteOutcome::Persisted`] on success, [`WriteOutcome::KeptInline`] +/// on any failure, or [`WriteOutcome::Nothing`] when `value` is `None`. +pub fn write_secret( + store: &S, + coord_key_fn: impl FnOnce(&str) -> String, + value: Option<&str>, + context: &str, +) -> WriteOutcome { + let Some(v) = value else { + return WriteOutcome::Nothing; + }; + let gen = new_gen_id(); + let key = coord_key_fn(&gen); + match store.write_and_verify(&key, v) { + Ok(()) => WriteOutcome::Persisted { gen }, + Err(e) => { + eprintln!( + "buzz-desktop: keyring write failed for {context} ({e}); \ + keeping inline in 0o600 JSON (retry on next boot)" + ); + WriteOutcome::KeptInline { reason: e } + } + } +} + +// ── Batched write-with-reuse ─────────────────────────────────────────────── + +/// One field's contribution to a batched secret save. +/// +/// The coordinate builder is `&dyn Fn` so a single [`write_secrets_batched`] +/// call can carry fields from different namespaces (env / auth_tag / +/// provider_config) without monomorphizing over each closure type. +pub struct FieldSave<'a> { + /// Builds the full blob coordinate for a given generation id. + pub coord_key_fn: &'a dyn Fn(&str) -> String, + /// The serialized inline value, or `None` when the field is empty/absent. + pub value: Option<&'a str>, + /// The generation currently referenced on disk, if any — the reuse anchor. + pub existing_ref: Option<&'a str>, + /// Human-readable context for diagnostics. + pub context: &'a str, +} + +/// Persist several secret fields in a SINGLE blob mutation, reusing the live +/// generation for any field whose bytes are unchanged. +/// +/// Returns one [`WriteOutcome`] per input field, in order, so the seam applies +/// the same per-field record mutation it always has: +/// +/// - `value == None` → [`WriteOutcome::Nothing`] (no write). +/// - `value` byte-equal to what `existing_ref` already stores → +/// [`WriteOutcome::Persisted`] with the SAME generation and **no write**: no +/// new UUID is minted, no blob mutation happens, and the generation never +/// becomes GC-eligible, so a metadata-only save is free of churn. +/// - `value` changed (or no prior ref, or the prior value is unreadable) → a +/// fresh generation is staged and committed with every other changed field in +/// ONE [`store_batch_verified`](ProjectionStore::store_batch_verified). On +/// success each is [`WriteOutcome::Persisted`] with its new gen; if the single +/// mutation fails, ALL staged fields become [`WriteOutcome::KeptInline`] +/// together (the blob write is atomic — there is no torn partial state). +/// +/// # Why the old generation is safe +/// +/// Like [`write_secret`], this never deletes a prior generation: a changed field +/// mints a NEW coordinate and leaves the old one untouched, so a subsequent +/// failed JSON commit still finds the on-disk ref's generation live. Retirement +/// stays with the two-cycle GC. +/// +/// # Why no `cancel_gc_candidacy` +/// +/// The per-field seam cancelled candidacy after each write. That is redundant on +/// this path and is deliberately dropped so the save is exactly one mutation: +/// every save holds the cross-process transaction lock, under which GC cannot +/// run, and a freshly-minted UUID generation has never been observed by a sweep +/// (so it carries no candidate marker), while a REUSED generation is a live JSON +/// ref that `mark_gc_candidates` skips by construction. Neither a new nor a +/// reused generation can hold a candidate marker at save time, so there is +/// nothing to cancel. (The boot-migration path keeps its cancel: it is +/// contract-frozen and its semantics are proven elsewhere.) +pub fn write_secrets_batched( + store: &S, + fields: &[FieldSave<'_>], +) -> Vec { + let mut outcomes: Vec> = Vec::with_capacity(fields.len()); + let mut batch: HashMap = HashMap::new(); + // (field index, freshly-minted gen) for each field staged into `batch`, + // finalized after the one write resolves. + let mut pending: Vec<(usize, String)> = Vec::new(); + + for (idx, field) in fields.iter().enumerate() { + let Some(value) = field.value else { + outcomes.push(Some(WriteOutcome::Nothing)); + continue; + }; + // Gen-reuse: if the live ref already stores these exact bytes, keep the + // generation and write nothing. A load error or absent value falls + // through to a fresh write (which, on a real outage, fails closed to + // KeptInline) — never a silent reuse of an unverified generation. + if let Some(existing) = field.existing_ref { + if matches!( + store.load_key(&(field.coord_key_fn)(existing)), + Ok(Some(ref stored)) if stored == value + ) { + outcomes.push(Some(WriteOutcome::Persisted { + gen: existing.to_string(), + })); + continue; + } + } + let gen = new_gen_id(); + batch.insert((field.coord_key_fn)(&gen), value.to_string()); + pending.push((idx, gen)); + outcomes.push(None); // finalized after the batched write + } + + if !batch.is_empty() { + match store.store_batch_verified(&batch) { + Ok(()) => { + for (idx, gen) in pending { + outcomes[idx] = Some(WriteOutcome::Persisted { gen }); + } + } + Err(e) => { + let contexts: Vec<&str> = pending + .iter() + .map(|(idx, _)| fields[*idx].context) + .collect(); + eprintln!( + "buzz-desktop: batched keyring write failed ({e}); keeping \ + inline in 0o600 JSON (retry on next boot): {}", + contexts.join(", ") + ); + for (idx, _) in pending { + outcomes[idx] = Some(WriteOutcome::KeptInline { reason: e.clone() }); + } + } + } + } + + outcomes + .into_iter() + .map(|o| o.expect("every field assigned an outcome")) + .collect() +} + +// ── Load-with-availability ───────────────────────────────────────────────── + +/// The conflict-marker key for a projection coordinate: `conflict:`. +pub fn conflict_marker_key(coord: &str) -> String { + format!("{NS_CONFLICT}{coord}") +} + +/// Load a secret from the keyring given its `ref_gen` from JSON. +/// +/// Returns: +/// - `Ok(Some(value))` — entry found and loaded. +/// - `Ok(None)` — no `ref_gen` in the record (field intentionally empty). +/// - `Err(msg)` — `ref_gen` is present but the entry is unavailable → fail +/// closed. The caller must refuse agent start/save. +/// +/// # Conflict marker (fail closed) +/// +/// When the coordinate carries a `conflict:` marker (written by the dev +/// secrets migration for a coordinate whose source and destination values +/// disagree), the value is treated as UNAVAILABLE regardless of what the blob +/// currently holds. The destination value cannot be trusted while unresolved, +/// so hydration must set `secrets_unavailable` and every downstream consumer +/// must refuse — this is the fail-closed replacement for the marker-withhold + +/// retry behavior that used to leave the conflicted value hydratable. +pub fn load_secret( + store: &S, + ref_gen: Option<&str>, + coord_key_fn: impl FnOnce(&str) -> String, + context: &str, +) -> Result, String> { + let Some(gen) = ref_gen else { + return Ok(None); // intentionally empty + }; + let key = coord_key_fn(gen); + // Fail closed on an unresolved dev-migration conflict for this coordinate. + // Only a definitive `Ok(None)` (marker absent) proceeds to the value read. + // + // A marker-read `Err(_)` MUST also fail closed: `SecretStore::load_blob` + // caches a successful read but never caches an error, so a transient + // marker-read failure could be followed immediately by a *successful* + // value read that hydrates a known-conflicted credential. Falling through + // on `Err` would therefore re-open exactly the window the marker exists to + // close, so a marker read we cannot complete is treated as "conflict + // status unknown" → unavailable. + match store.load_key(&conflict_marker_key(&key)) { + Ok(Some(_)) => { + return Err(format!( + "secret unavailable: {context} ref {gen} has an unresolved \ + dev-migration conflict at {key}; refusing to hydrate a \ + potentially-wrong value until the conflict is resolved" + )) + } + Err(e) => { + return Err(format!( + "secret unavailable: {context} ref {gen} conflict-marker read \ + at {key} failed ({e}); refusing to hydrate until the marker \ + can be checked (a later cached-success value read must not \ + bypass an unresolved conflict)" + )) + } + Ok(None) => {} // no marker — proceed to the normal load + } + match store.load_key(&key) { + Ok(Some(v)) => Ok(Some(v)), + Ok(None) => Err(format!( + "secret unavailable: {context} ref {gen} not found in keyring \ + (keyring may be unreachable or entry was deleted)" + )), + Err(e) => Err(format!( + "secret unavailable: {context} ref {gen} keyring error: {e}" + )), + } +} + +// ── Delete helpers ───────────────────────────────────────────────────────── +// +// Old generations are NEVER deleted eagerly on save: deleting a prior +// generation before the atomic JSON commit could orphan a secret if the write +// fails, leaving disk referencing a generation that no longer exists. All +// retirement of unreferenced generations happens through the two-cycle GC +// below (`mark_gc_candidates` + `delete_gc_candidates`). + +// ── GC snapshot ─────────────────────────────────────────────────────────── + +/// The live-reference snapshot collected from both JSON stores by +/// [`collect_live_refs`]. +#[derive(Debug, Default)] +pub struct LiveRefs { + /// All validated ref gen ids referenced by any live JSON record. Used by + /// the sweeps to decide whether a blob key's generation is still + /// referenced (and therefore must not be marked/deleted). + pub gen_ids: std::collections::HashSet, + /// The full expected blob coordinate for every live ref + /// (e.g. `agent::env:`, `global:env:`). Every one of + /// these MUST be present in the blob before GC may delete anything: a + /// dangling live ref (its coordinate missing/unreadable) means the store + /// is in a degraded state where an older unreferenced generation could be + /// the only recoverable payload for that field, so BOTH sweeps no-op until + /// the reference resolves. + pub coords: std::collections::HashSet, +} + +/// Returns `true` when every live-ref coordinate is present as a key in the +/// loaded blob. A single missing coordinate means a committed reference is +/// dangling (its keyring entry was deleted or is unreadable), so GC must not +/// delete anything this cycle — an older, unreferenced generation could be the +/// only recoverable payload for that field. +fn all_live_coords_present(live: &LiveRefs, blob: &HashMap) -> bool { + live.coords.iter().all(|coord| blob.contains_key(coord)) +} + +/// Collect all live generation refs from both raw JSON stores, validating as +/// it goes. +/// +/// Returns `None` — which makes the GC sweep a **no-op** for this cycle — when +/// either store is missing/unreadable OR the JSON is in an ambiguous state the +/// GC must not make a deletion decision against: +/// +/// - **Malformed coordinate:** a `*_ref` that is empty or contains a `:` +/// (the gen id is the last `:`-segment of a blob key, so an embedded `:` +/// would make the reference un-matchable against the blob and could leave a +/// still-referenced generation unprotected). +/// - **Duplicate coordinate:** the same gen id referenced by two different +/// coordinates. Generation ids are fresh UUIDs, so a collision means the JSON +/// is corrupt; protecting only one of the two would let the sweep delete a +/// live secret. +/// - **Inline + ref conflict:** a record (or global) that carries BOTH a +/// non-empty inline value AND a `*_ref` for the same field. Inline is +/// authoritative on load, so the ref is being ignored — but the state is +/// ambiguous enough that the GC must not reason about which generation is +/// live. Skipping the whole sweep is the fail-safe choice. +/// - **Unidentifiable record:** a record carrying a `*_ref` whose owning +/// coordinate cannot be reconstructed (an instance with no pubkey, or a +/// definition with no slug). The full coordinate is required for the +/// blob-existence check, so an unbuildable one no-ops the sweep. +/// +/// The returned [`LiveRefs`] carries every validated gen id AND the full +/// coordinate each ref points at. The sweeps additionally require every +/// coordinate to exist in the loaded blob before deleting anything. +pub fn collect_live_refs(agents_json: &str, global_json: &str) -> Option { + let mut live = LiveRefs::default(); + + // Parse agents store (array of records). + let agents: Vec = serde_json::from_str(agents_json).ok()?; + for record in &agents { + collect_refs_from_record(record, &mut live)?; + } + + // Parse global config. Global carries a single `env_vars` / `env_vars_ref` + // pair with the same inline-precedence contract as a record field. + let global: JsonValue = serde_json::from_str(global_json).ok()?; + collect_ref_field( + &global, + "env_vars_ref", + /* inline_non_empty */ object_field_non_empty(&global, "env_vars"), + &mut live, + |gen| Some(global_env_key(gen)), + )?; + + Some(live) +} + +/// Validate and collect the three secret refs of one agent/definition record. +/// Returns `None` on any malformed/duplicate coordinate, inline+ref conflict, +/// or a ref on a record whose owning coordinate cannot be reconstructed. +fn collect_refs_from_record(record: &JsonValue, live: &mut LiveRefs) -> Option<()> { + let pubkey = record + .get("pubkey") + .and_then(JsonValue::as_str) + .unwrap_or(""); + let slug = record.get("slug").and_then(JsonValue::as_str); + let is_definition = pubkey.is_empty(); + + // env_vars: object, non-empty inline. Instance → agent::env:; + // definition (no pubkey) → definition::env:. + collect_ref_field( + record, + "env_vars_ref", + object_field_non_empty(record, "env_vars"), + live, + |gen| { + if is_definition { + slug.map(|s| definition_env_key(s, gen)) + } else { + Some(agent_env_key(pubkey, gen)) + } + }, + )?; + // auth_tag: string, non-empty inline. Instance-only coordinate. + collect_ref_field( + record, + "auth_tag_ref", + string_field_non_empty(record, "auth_tag"), + live, + |gen| (!is_definition).then(|| agent_auth_tag_key(pubkey, gen)), + )?; + // provider config: BackendKind::Provider.config, non-null inline. + // Instance-only coordinate. + collect_ref_field( + record, + "provider_config_ref", + provider_config_inline_present(record), + live, + |gen| (!is_definition).then(|| agent_provider_config_key(pubkey, gen)), + )?; + Some(()) +} + +/// Validate a single `(inline, ref)` field pair and record both the ref gen id +/// and its full blob coordinate into `live`. +/// +/// Returns `None` on an inline+ref conflict, a malformed ref, a duplicate ref +/// gen id, or a ref whose `coord_fn` cannot reconstruct the owning coordinate +/// (unidentifiable record) — every one no-ops the sweep as the fail-safe. +fn collect_ref_field( + record: &JsonValue, + ref_field: &str, + inline_non_empty: bool, + live: &mut LiveRefs, + coord_fn: impl FnOnce(&str) -> Option, +) -> Option<()> { + let ref_val = record.get(ref_field).and_then(JsonValue::as_str); + match ref_val { + Some(r) => { + // Inline present alongside a ref → ambiguous; no-op the sweep. + if inline_non_empty { + return None; + } + // Malformed coordinate: empty, or an embedded `:` that would break + // last-segment gen extraction against the blob. + if r.is_empty() || r.contains(':') { + return None; + } + // The full coordinate must be reconstructible — an instance ref + // with no pubkey, or a definition env ref with no slug, is + // un-checkable against the blob, so no-op the sweep. + let coord = coord_fn(r)?; + // Duplicate coordinate: a gen id must reference exactly one thing. + if !live.gen_ids.insert(r.to_string()) { + return None; + } + live.coords.insert(coord); + Some(()) + } + None => Some(()), + } +} + +/// True when `record[field]` is a JSON object with at least one entry. +fn object_field_non_empty(record: &JsonValue, field: &str) -> bool { + record + .get(field) + .and_then(JsonValue::as_object) + .is_some_and(|m| !m.is_empty()) +} + +/// True when `record[field]` is a non-empty JSON string. +fn string_field_non_empty(record: &JsonValue, field: &str) -> bool { + record + .get(field) + .and_then(JsonValue::as_str) + .is_some_and(|s| !s.is_empty()) +} + +/// True when the record's backend is a provider whose `config` is present and +/// not JSON `null` — i.e. an inline provider-config value that has not been +/// stripped into the keyring. +fn provider_config_inline_present(record: &JsonValue) -> bool { + let Some(backend) = record.get("backend").and_then(JsonValue::as_object) else { + return false; + }; + if backend.get("type").and_then(JsonValue::as_str) != Some("provider") { + return false; + } + matches!(backend.get("config"), Some(c) if !c.is_null()) +} + +// ── Two-cycle GC ────────────────────────────────────────────────────────── + +/// First GC sweep: mark unreferenced projection generations as candidates. +/// +/// Reads both JSON stores (raw bytes for stability) and the current blob state. +/// Any generation key in our namespaces that is NOT in the live ref set AND +/// does not have an in-flight save cancelling its candidacy is marked as a +/// candidate by writing `_candidate = "1"` into the blob. +/// +/// GC is a no-op when: +/// - Either JSON store is absent or unreadable. +/// - The blob is unreachable. +/// - The JSON store content changed between `collect_live_refs` call and blob +/// mutation (this is checked by comparing the read content before and after +/// — but since we can't hold a lock across the reads, we use a snapshot +/// approach: re-read JSON after acquiring the blob lock implicitly via +/// `store_batch`). The current impl re-reads JSON inside `mark_gc_candidates` +/// to ensure stability. +pub fn mark_gc_candidates( + store: &S, + agents_json_path: &std::path::Path, + global_json_path: &std::path::Path, +) { + let (agents_content, global_content) = + match read_both_json_stores(agents_json_path, global_json_path) { + Some(pair) => pair, + None => return, + }; + + let live_refs = match collect_live_refs(&agents_content, &global_content) { + Some(refs) => refs, + None => { + eprintln!( + "buzz-desktop: GC sweep 1: could not collect live refs (malformed JSON), skipping" + ); + return; + } + }; + + // Read current blob to find projection keys. + let blob = match store.load_all() { + Ok(Some(map)) => map, + Ok(None) => return, // no blob yet — nothing to GC + Err(e) => { + eprintln!("buzz-desktop: GC sweep 1: keyring unavailable ({e}), skipping"); + return; + } + }; + + // Fail-safe: every live ref's full coordinate MUST exist in the blob. A + // dangling live ref means the store is degraded — an older unreferenced + // generation could be the only recoverable payload for that field — so no + // marking happens this cycle until the reference resolves. + if !all_live_coords_present(&live_refs, &blob) { + eprintln!( + "buzz-desktop: GC sweep 1: a live ref's blob entry is missing — \ + store is degraded, skipping to protect recoverable generations" + ); + return; + } + + // Find projection generation keys that are: + // 1. In our namespaces (not candidate markers themselves). + // 2. Not referenced by any live JSON record. + // 3. Not already a candidate marker (suffix _candidate). + let mut to_mark: HashMap = HashMap::new(); + for key in blob.keys() { + if key.ends_with(GC_CANDIDATE_SUFFIX) { + continue; // skip existing candidate markers + } + if !is_projection_key(key) { + continue; // not ours + } + // Extract gen from the key — the gen is the last `:` segment. + let gen = match key.rsplit(':').next() { + Some(g) if !g.is_empty() => g, + _ => continue, + }; + if live_refs.gen_ids.contains(gen) { + continue; // referenced by a live record — do NOT mark + } + // Unreferenced generation — mark it as a candidate. + let candidate_key = format!("{key}{GC_CANDIDATE_SUFFIX}"); + to_mark.insert(candidate_key, "1".to_string()); + } + + if to_mark.is_empty() { + return; + } + + // Re-read JSON to verify it hasn't changed since our snapshot. + // If it has changed, abort GC for this cycle. + let (agents_after, global_after) = + match read_both_json_stores(agents_json_path, global_json_path) { + Some(pair) => pair, + None => { + eprintln!("buzz-desktop: GC sweep 1: JSON stores changed mid-sweep, skipping"); + return; + } + }; + if agents_after != agents_content || global_after != global_content { + eprintln!("buzz-desktop: GC sweep 1: JSON stores changed mid-sweep, skipping"); + return; + } + + if let Err(e) = store.store_batch(&to_mark) { + eprintln!("buzz-desktop: GC sweep 1: could not write candidate markers ({e})"); + } else { + eprintln!( + "buzz-desktop: GC sweep 1: marked {} generation(s) as GC candidates", + to_mark.len() + ); + } +} + +/// Second GC sweep: delete candidate generations that are STILL unreferenced. +/// +/// Re-parses both JSON stores before deleting anything. Any candidate whose +/// generation is now referenced (i.e. a save committed between sweep 1 and 2) +/// is skipped. GC is a no-op when either store is unreadable. +pub fn delete_gc_candidates( + store: &S, + agents_json_path: &std::path::Path, + global_json_path: &std::path::Path, +) { + let (agents_content, global_content) = + match read_both_json_stores(agents_json_path, global_json_path) { + Some(pair) => pair, + None => return, + }; + + let live_refs = match collect_live_refs(&agents_content, &global_content) { + Some(refs) => refs, + None => { + eprintln!("buzz-desktop: GC sweep 2: could not collect live refs, skipping"); + return; + } + }; + + let blob = match store.load_all() { + Ok(Some(map)) => map, + Ok(None) => return, + Err(e) => { + eprintln!("buzz-desktop: GC sweep 2: keyring unavailable ({e}), skipping"); + return; + } + }; + + // Same fail-safe as sweep 1: a dangling live ref blocks ALL deletion this + // cycle so an unreferenced generation that may be the last recoverable + // payload survives until the reference resolves. + if !all_live_coords_present(&live_refs, &blob) { + eprintln!( + "buzz-desktop: GC sweep 2: a live ref's blob entry is missing — \ + store is degraded, skipping to protect recoverable generations" + ); + return; + } + + // Find candidate markers whose base generation is still unreferenced. + let mut to_delete: Vec = Vec::new(); + for key in blob.keys() { + let Some(base_key) = key.strip_suffix(GC_CANDIDATE_SUFFIX) else { + continue; // not a candidate marker + }; + if !is_projection_key(base_key) { + continue; + } + // Re-verify: extract gen from base_key. + let gen = match base_key.rsplit(':').next() { + Some(g) if !g.is_empty() => g, + _ => continue, + }; + if live_refs.gen_ids.contains(gen) { + // A save committed between sweep 1 and 2 — keep it. + continue; + } + // Still unreferenced — schedule both the generation and its candidate + // marker for deletion. + to_delete.push(base_key.to_string()); + to_delete.push(key.clone()); // the _candidate marker + } + + if to_delete.is_empty() { + return; + } + + // Re-check JSON one last time before deleting. + let (agents_final, global_final) = + match read_both_json_stores(agents_json_path, global_json_path) { + Some(pair) => pair, + None => { + eprintln!("buzz-desktop: GC sweep 2: JSON stores changed, skipping"); + return; + } + }; + if agents_final != agents_content || global_final != global_content { + eprintln!("buzz-desktop: GC sweep 2: JSON stores changed mid-sweep, skipping"); + return; + } + + let keys_ref: Vec<&str> = to_delete.iter().map(String::as_str).collect(); + match store.remove_batch(&keys_ref) { + Ok(()) => { + eprintln!( + "buzz-desktop: GC sweep 2: deleted {} stale generation(s)", + to_delete.len() / 2 + ); + } + Err(e) => { + eprintln!("buzz-desktop: GC sweep 2: delete failed ({e})"); + } + } +} + +/// Cancel GC candidacy for `gen_key` (called before the JSON commit of a save). +/// +/// Removes the `_candidate` marker if present. Best-effort: failure +/// is logged but does not block the save. +pub fn cancel_gc_candidacy(store: &S, gen_key: &str) { + let candidate_key = format!("{gen_key}{GC_CANDIDATE_SUFFIX}"); + if let Err(e) = store.remove_batch(&[&candidate_key]) { + eprintln!("buzz-desktop: could not cancel GC candidacy for {gen_key}: {e}"); + } +} + +fn read_both_json_stores( + agents_path: &std::path::Path, + global_path: &std::path::Path, +) -> Option<(String, String)> { + // Resolve symlinks before reading so concurrent atomic-write renames at the + // real target path don't confuse us. + let agents_resolved = + std::fs::canonicalize(agents_path).unwrap_or_else(|_| agents_path.to_path_buf()); + let global_resolved = + std::fs::canonicalize(global_path).unwrap_or_else(|_| global_path.to_path_buf()); + + let agents = match std::fs::read_to_string(&agents_resolved) { + Ok(s) => s, + Err(_) => { + // File absent on first launch is OK — treat as empty array. + if !agents_path.exists() { + "[]".to_string() + } else { + return None; + } + } + }; + let global = match std::fs::read_to_string(&global_resolved) { + Ok(s) => s, + Err(_) => { + if !global_path.exists() { + "{}".to_string() + } else { + return None; + } + } + }; + Some((agents, global)) +} + +// ── Env-map serialization helpers ───────────────────────────────────────── + +/// Serialize an env map to a compact JSON string for keyring storage. +pub fn serialize_env_map(env: &BTreeMap) -> Result { + serde_json::to_string(env).map_err(|e| format!("env_map serialize: {e}")) +} + +/// Deserialize an env map from a JSON string loaded from the keyring. +pub fn deserialize_env_map(s: &str) -> Result, String> { + serde_json::from_str(s).map_err(|e| format!("env_map deserialize: {e}")) +} + +/// Serialize a provider config value for keyring storage. +pub fn serialize_provider_config(config: &serde_json::Value) -> Result { + serde_json::to_string(config).map_err(|e| format!("provider_config serialize: {e}")) +} + +/// Deserialize a provider config from keyring storage. +pub fn deserialize_provider_config(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| format!("provider_config deserialize: {e}")) +} + +#[cfg(test)] +#[path = "secret_projection_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "secret_projection_batched_tests.rs"] +mod batched_tests; diff --git a/desktop/src-tauri/src/managed_agents/secret_projection_batched_tests.rs b/desktop/src-tauri/src/managed_agents/secret_projection_batched_tests.rs new file mode 100644 index 00000000000..e2980414d24 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/secret_projection_batched_tests.rs @@ -0,0 +1,354 @@ +//! Coverage for [`write_secrets_batched`] — the metadata-save fast path that +//! reuses a live generation when a field's bytes are unchanged and commits +//! every changed field in one blob mutation. +//! +//! Split out of `secret_projection_tests.rs` so each test file stays under the +//! desktop file-size ratchet; both share the `secret_projection` module via +//! `use super::*`. + +use super::*; +use std::cell::RefCell; + +/// A store that counts blob mutations (`store_batch`) and per-key writes +/// (`write_and_verify`) so tests can assert the exact I/O a save performs. +struct CountingStore { + data: RefCell>, + batch_mutations: std::cell::Cell, + single_writes: std::cell::Cell, +} + +impl CountingStore { + fn new() -> Self { + Self { + data: RefCell::new(HashMap::new()), + batch_mutations: std::cell::Cell::new(0), + single_writes: std::cell::Cell::new(0), + } + } + fn with_entry(self, key: &str, value: &str) -> Self { + self.data + .borrow_mut() + .insert(key.to_string(), value.to_string()); + self + } + fn get(&self, key: &str) -> Option { + self.data.borrow().get(key).cloned() + } + fn keys(&self) -> Vec { + self.data.borrow().keys().cloned().collect() + } +} + +impl ProjectionStore for CountingStore { + fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String> { + self.single_writes.set(self.single_writes.get() + 1); + self.data + .borrow_mut() + .insert(key.to_string(), value.to_string()); + Ok(()) + } + fn load_key(&self, key: &str) -> Result, String> { + Ok(self.data.borrow().get(key).cloned()) + } + fn load_all(&self) -> Result>, String> { + Ok(Some(self.data.borrow().clone())) + } + fn store_batch(&self, entries: &HashMap) -> Result<(), String> { + self.batch_mutations.set(self.batch_mutations.get() + 1); + for (k, v) in entries { + self.data.borrow_mut().insert(k.clone(), v.clone()); + } + Ok(()) + } + fn remove_batch(&self, keys: &[&str]) -> Result<(), String> { + for k in keys { + self.data.borrow_mut().remove(*k); + } + Ok(()) + } +} + +/// A store whose blob write always fails — a denied keychain prompt or a +/// transient outage — so the batched save must fail closed to KeptInline. +struct FailingBatchStore; + +impl ProjectionStore for FailingBatchStore { + fn write_and_verify(&self, _key: &str, _value: &str) -> Result<(), String> { + Err("write failed".to_string()) + } + fn load_key(&self, _key: &str) -> Result, String> { + Ok(None) + } + fn load_all(&self) -> Result>, String> { + Ok(None) + } + fn store_batch(&self, _entries: &HashMap) -> Result<(), String> { + Err("blob write failed".to_string()) + } + fn remove_batch(&self, _keys: &[&str]) -> Result<(), String> { + Ok(()) + } +} + +#[test] +fn test_write_secrets_batched_reuses_gen_when_bytes_unchanged() { + // A field whose live ref already stores these exact bytes must keep its + // generation and perform NO write — the metadata-only-save fast path. + let store = CountingStore::new().with_entry(&agent_env_key("abc", "gen1"), r#"{"K":"v"}"#); + let env_key = |gen: &str| agent_env_key("abc", gen); + let outcomes = write_secrets_batched( + &store, + &[FieldSave { + coord_key_fn: &env_key, + value: Some(r#"{"K":"v"}"#), + existing_ref: Some("gen1"), + context: "agent:abc env_vars", + }], + ); + assert_eq!( + outcomes[0], + WriteOutcome::Persisted { + gen: "gen1".to_string() + }, + "unchanged bytes must reuse the existing generation" + ); + assert_eq!( + store.batch_mutations.get(), + 0, + "no blob mutation for an unchanged field" + ); + assert_eq!(store.keys().len(), 1, "no new generation key was created"); +} + +#[test] +fn test_write_secrets_batched_new_gen_when_bytes_changed() { + let store = CountingStore::new().with_entry(&agent_env_key("abc", "gen1"), r#"{"K":"old"}"#); + let env_key = |gen: &str| agent_env_key("abc", gen); + let outcomes = write_secrets_batched( + &store, + &[FieldSave { + coord_key_fn: &env_key, + value: Some(r#"{"K":"new"}"#), + existing_ref: Some("gen1"), + context: "agent:abc env_vars", + }], + ); + let new_gen = match &outcomes[0] { + WriteOutcome::Persisted { gen } => gen.clone(), + other => panic!("expected Persisted, got {other:?}"), + }; + assert_ne!( + new_gen, "gen1", + "changed bytes must mint a fresh generation" + ); + assert_eq!(store.batch_mutations.get(), 1); + assert_eq!( + store.get(&agent_env_key("abc", "gen1")), + Some(r#"{"K":"old"}"#.to_string()), + "the old generation must survive — a failed JSON commit still needs it" + ); + assert_eq!( + store.get(&agent_env_key("abc", &new_gen)), + Some(r#"{"K":"new"}"#.to_string()) + ); +} + +#[test] +fn test_write_secrets_batched_single_mutation_for_multiple_changed_fields() { + // Three changed fields across three namespaces must commit in EXACTLY one + // blob mutation and zero per-key writes. + let store = CountingStore::new(); + let env_key = |gen: &str| agent_env_key("abc", gen); + let auth_key = |gen: &str| agent_auth_tag_key("abc", gen); + let pc_key = |gen: &str| agent_provider_config_key("abc", gen); + let outcomes = write_secrets_batched( + &store, + &[ + FieldSave { + coord_key_fn: &env_key, + value: Some(r#"{"K":"v"}"#), + existing_ref: None, + context: "agent:abc env_vars", + }, + FieldSave { + coord_key_fn: &auth_key, + value: Some("auth-tag"), + existing_ref: None, + context: "agent:abc auth_tag", + }, + FieldSave { + coord_key_fn: &pc_key, + value: Some(r#"{"host":"x"}"#), + existing_ref: None, + context: "agent:abc provider_config", + }, + ], + ); + assert!( + outcomes + .iter() + .all(|o| matches!(o, WriteOutcome::Persisted { .. })), + "all three fields persist" + ); + assert_eq!( + store.batch_mutations.get(), + 1, + "three changed fields must be ONE blob mutation, not three" + ); + assert_eq!( + store.single_writes.get(), + 0, + "the batched path must not fall back to per-key writes" + ); + assert_eq!(store.keys().len(), 3, "one generation per field"); +} + +#[test] +fn test_write_secrets_batched_mixed_reuse_and_change_is_one_mutation() { + // One unchanged field (reuse, no write) + one changed field (new gen): + // still exactly one mutation, and the unchanged field keeps its gen. + let store = CountingStore::new() + .with_entry(&agent_env_key("abc", "gen_env"), r#"{"E":"same"}"#) + .with_entry(&agent_auth_tag_key("abc", "gen_auth"), "old-auth"); + let env_key = |gen: &str| agent_env_key("abc", gen); + let auth_key = |gen: &str| agent_auth_tag_key("abc", gen); + let outcomes = write_secrets_batched( + &store, + &[ + FieldSave { + coord_key_fn: &env_key, + value: Some(r#"{"E":"same"}"#), + existing_ref: Some("gen_env"), + context: "agent:abc env_vars", + }, + FieldSave { + coord_key_fn: &auth_key, + value: Some("new-auth"), + existing_ref: Some("gen_auth"), + context: "agent:abc auth_tag", + }, + ], + ); + assert_eq!( + outcomes[0], + WriteOutcome::Persisted { + gen: "gen_env".to_string() + }, + "unchanged env reuses its gen" + ); + let new_auth = match &outcomes[1] { + WriteOutcome::Persisted { gen } => gen.clone(), + other => panic!("expected Persisted, got {other:?}"), + }; + assert_ne!(new_auth, "gen_auth"); + assert_eq!( + store.batch_mutations.get(), + 1, + "only the changed field triggers the single mutation" + ); +} + +#[test] +fn test_write_secrets_batched_nothing_on_empty_value() { + let store = CountingStore::new(); + let env_key = |gen: &str| agent_env_key("abc", gen); + let outcomes = write_secrets_batched( + &store, + &[FieldSave { + coord_key_fn: &env_key, + value: None, + existing_ref: None, + context: "agent:abc env_vars", + }], + ); + assert_eq!(outcomes[0], WriteOutcome::Nothing); + assert_eq!( + store.batch_mutations.get(), + 0, + "an empty field writes nothing" + ); +} + +#[test] +fn test_write_secrets_batched_all_kept_inline_on_write_failure() { + // The single blob write is atomic: if it fails, every staged field becomes + // KeptInline together — no torn partial state where some fields persisted. + let store = FailingBatchStore; + let env_key = |gen: &str| agent_env_key("abc", gen); + let auth_key = |gen: &str| agent_auth_tag_key("abc", gen); + let outcomes = write_secrets_batched( + &store, + &[ + FieldSave { + coord_key_fn: &env_key, + value: Some(r#"{"K":"v"}"#), + existing_ref: None, + context: "agent:abc env_vars", + }, + FieldSave { + coord_key_fn: &auth_key, + value: Some("auth"), + existing_ref: None, + context: "agent:abc auth_tag", + }, + ], + ); + assert!( + outcomes + .iter() + .all(|o| matches!(o, WriteOutcome::KeptInline { .. })), + "a failed atomic write keeps ALL staged fields inline: {outcomes:?}" + ); +} + +#[test] +fn test_write_secrets_batched_changes_when_prior_gen_unreadable() { + // existing_ref points at a gen whose value is absent (outage/deleted). Reuse + // must NOT fire on an unverified generation — fall through to a fresh write. + let store = CountingStore::new(); + let env_key = |gen: &str| agent_env_key("abc", gen); + let outcomes = write_secrets_batched( + &store, + &[FieldSave { + coord_key_fn: &env_key, + value: Some(r#"{"K":"v"}"#), + existing_ref: Some("gen_missing"), + context: "agent:abc env_vars", + }], + ); + match &outcomes[0] { + WriteOutcome::Persisted { gen } => assert_ne!(gen, "gen_missing"), + other => panic!("expected fresh Persisted, got {other:?}"), + } + assert_eq!(store.batch_mutations.get(), 1); +} + +#[test] +fn test_store_batch_verified_default_catches_silent_write_loss() { + // A store whose store_batch is a no-op (acknowledges without persisting) + // must be caught by the verify pass, not reported as success. + struct LyingStore; + impl ProjectionStore for LyingStore { + fn write_and_verify(&self, _k: &str, _v: &str) -> Result<(), String> { + Ok(()) + } + fn load_key(&self, _k: &str) -> Result, String> { + Ok(None) // nothing was actually stored + } + fn load_all(&self) -> Result>, String> { + Ok(None) + } + fn store_batch(&self, _entries: &HashMap) -> Result<(), String> { + Ok(()) // lies: acknowledges but persists nothing + } + fn remove_batch(&self, _keys: &[&str]) -> Result<(), String> { + Ok(()) + } + } + let mut entries = HashMap::new(); + entries.insert("global:env:gen1".to_string(), "sk-secret".to_string()); + assert!( + LyingStore.store_batch_verified(&entries).is_err(), + "verify must reject a write the backend did not persist" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/secret_projection_tests.rs b/desktop/src-tauri/src/managed_agents/secret_projection_tests.rs new file mode 100644 index 00000000000..fd3ccd058f7 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/secret_projection_tests.rs @@ -0,0 +1,823 @@ +use super::*; +use std::cell::RefCell; + +// ── FakeProjectionStore ────────────────────────────────────────────── + +struct FakeProjectionStore { + reachable: bool, + fail_verify: bool, + data: RefCell>, +} + +impl FakeProjectionStore { + fn reachable() -> Self { + Self { + reachable: true, + fail_verify: false, + data: RefCell::new(HashMap::new()), + } + } + fn unreachable() -> Self { + Self { + reachable: false, + fail_verify: false, + data: RefCell::new(HashMap::new()), + } + } + fn verify_fails() -> Self { + Self { + reachable: true, + fail_verify: true, + data: RefCell::new(HashMap::new()), + } + } + fn with_entry(self, key: &str, value: &str) -> Self { + self.data + .borrow_mut() + .insert(key.to_string(), value.to_string()); + self + } + fn get(&self, key: &str) -> Option { + self.data.borrow().get(key).cloned() + } + fn keys(&self) -> Vec { + self.data.borrow().keys().cloned().collect() + } +} + +impl ProjectionStore for FakeProjectionStore { + fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String> { + if !self.reachable { + return Err("unreachable".to_string()); + } + if self.fail_verify { + return Err("verify failed".to_string()); + } + self.data + .borrow_mut() + .insert(key.to_string(), value.to_string()); + Ok(()) + } + + fn load_key(&self, key: &str) -> Result, String> { + if !self.reachable { + return Err("unreachable".to_string()); + } + Ok(self.data.borrow().get(key).cloned()) + } + + fn load_all(&self) -> Result>, String> { + if !self.reachable { + return Err("unreachable".to_string()); + } + if self.data.borrow().is_empty() { + Ok(None) + } else { + Ok(Some(self.data.borrow().clone())) + } + } + + fn store_batch(&self, entries: &HashMap) -> Result<(), String> { + if !self.reachable { + return Err("unreachable".to_string()); + } + for (k, v) in entries { + self.data.borrow_mut().insert(k.clone(), v.clone()); + } + Ok(()) + } + + fn remove_batch(&self, keys: &[&str]) -> Result<(), String> { + if !self.reachable { + return Err("unreachable".to_string()); + } + let mut data = self.data.borrow_mut(); + for k in keys { + data.remove(*k); + } + Ok(()) + } +} + +// ── is_projection_key ──────────────────────────────────────────────── + +#[test] +fn test_is_projection_key_global_env() { + assert!(is_projection_key("global:env:abc123")); + assert!(!is_projection_key("global:model")); + assert!(!is_projection_key("identity")); + assert!(!is_projection_key("agent:abc123")); // no sub-part +} + +#[test] +fn test_is_projection_key_agent_namespaces() { + assert!(is_projection_key("agent:abc123:env:gen1")); + assert!(is_projection_key("agent:abc123:auth_tag:gen1")); + assert!(is_projection_key("agent:abc123:provider_config:gen1")); + assert!(is_projection_key("agent:abc123:env:gen1_candidate")); + assert!(!is_projection_key("agent:abc123")); // nsec key — not ours +} + +#[test] +fn test_is_projection_key_definition() { + assert!(is_projection_key("definition:my-slug:env:gen1")); + assert!(!is_projection_key("definition:my-slug")); // no part +} + +// ── write_secret ────────────────────────────────────────────────────── + +#[test] +fn test_write_secret_nothing_on_empty_value() { + let store = FakeProjectionStore::reachable(); + let outcome = write_secret(&store, global_env_key, None, "test"); + assert_eq!(outcome, WriteOutcome::Nothing); + assert!(store.keys().is_empty()); +} + +#[test] +fn test_write_secret_persisted_on_success() { + let store = FakeProjectionStore::reachable(); + let outcome = write_secret( + &store, + global_env_key, + Some("sk-ant-api03-secret"), + "global:env", + ); + match &outcome { + WriteOutcome::Persisted { gen } => { + let key = global_env_key(gen); + assert_eq!(store.get(&key), Some("sk-ant-api03-secret".to_string())); + } + other => panic!("expected Persisted, got {other:?}"), + } +} + +#[test] +fn test_write_secret_kept_inline_on_verify_failure() { + let store = FakeProjectionStore::verify_fails(); + let outcome = write_secret(&store, global_env_key, Some("sk-ant-secret"), "global:env"); + assert!(matches!(outcome, WriteOutcome::KeptInline { .. })); +} + +#[test] +fn test_write_secret_kept_inline_on_unreachable() { + let store = FakeProjectionStore::unreachable(); + let outcome = write_secret(&store, global_env_key, Some("value"), "test"); + assert!(matches!(outcome, WriteOutcome::KeptInline { .. })); +} + +// ── load_secret ─────────────────────────────────────────────────────── + +#[test] +fn test_load_secret_none_when_no_ref() { + let store = FakeProjectionStore::reachable(); + let result = load_secret(&store, None, global_env_key, "test"); + assert_eq!(result, Ok(None)); +} + +#[test] +fn test_load_secret_ok_when_entry_present() { + let store = FakeProjectionStore::reachable().with_entry("global:env:gen1", "sk-secret"); + let result = load_secret(&store, Some("gen1"), global_env_key, "global:env"); + assert_eq!(result, Ok(Some("sk-secret".to_string()))); +} + +#[test] +fn test_load_secret_err_when_ref_present_but_missing() { + let store = FakeProjectionStore::reachable(); + let result = load_secret(&store, Some("gen1"), global_env_key, "global:env"); + assert!(result.is_err(), "expected unavailable error"); +} + +#[test] +fn test_load_secret_err_when_keyring_unreachable() { + let store = FakeProjectionStore::unreachable(); + let result = load_secret(&store, Some("gen1"), global_env_key, "global:env"); + assert!(result.is_err()); +} + +/// A [`ProjectionStore`] whose conflict-marker read fails transiently while +/// the value read succeeds — the exact production shape Thufir flagged: the +/// real `SecretStore::load_blob` caches a successful read but never caches an +/// error, so a first (marker) read can error and an immediately-following +/// (value) read can succeed against a warm cache. The value is present and +/// KNOWN-conflicted; `load_secret` must fail closed on the marker-read `Err` +/// rather than fall through and hydrate it. +struct MarkerReadErrStore { + value_key: String, + value: String, +} + +impl ProjectionStore for MarkerReadErrStore { + fn write_and_verify(&self, _key: &str, _value: &str) -> Result<(), String> { + unreachable!("load_secret never writes") + } + fn load_key(&self, key: &str) -> Result, String> { + if key.starts_with("conflict:") { + // Transient backend failure on the marker read only. + return Err("transient marker read failure".to_string()); + } + if key == self.value_key { + // The value read succeeds — a known-conflicted credential. + return Ok(Some(self.value.clone())); + } + Ok(None) + } + fn load_all(&self) -> Result>, String> { + unreachable!("load_secret never calls load_all") + } + fn store_batch(&self, _entries: &HashMap) -> Result<(), String> { + unreachable!("load_secret never writes") + } + fn remove_batch(&self, _keys: &[&str]) -> Result<(), String> { + unreachable!("load_secret never deletes") + } +} + +#[test] +fn test_load_secret_fails_closed_when_marker_read_errors() { + // F4: a transient conflict-marker read `Err` must be treated as "conflict + // status unknown" → unavailable, even though the value read would succeed. + // Falling through would hydrate a known-conflicted value the moment the + // marker check flaked. + let value_key = global_env_key("gen1"); + let store = MarkerReadErrStore { + value_key, + value: "sk-known-conflicted".to_string(), + }; + let result = load_secret(&store, Some("gen1"), global_env_key, "global:env"); + assert!( + result.is_err(), + "a marker-read Err must fail closed, not fall through to the value read" + ); + let msg = result.unwrap_err(); + assert!( + msg.contains("conflict-marker read") && msg.contains("failed"), + "error must name the marker-read failure as the refusal cause, got: {msg}" + ); +} + +// ── Two-cycle GC tests ──────────────────────────────────────────────── + +fn make_agents_json(env_ref: Option<&str>) -> String { + if let Some(r) = env_ref { + format!( + r#"[{{"pubkey":"abc","name":"test","env_vars_ref":"{r}","created_at":"2026","updated_at":"2026"}}]"# + ) + } else { + r#"[{"pubkey":"abc","name":"test","created_at":"2026","updated_at":"2026"}]"#.to_string() + } +} + +fn make_global_json(env_ref: Option<&str>) -> String { + if let Some(r) = env_ref { + format!(r#"{{"env_vars_ref":"{r}"}}"#) + } else { + "{}".to_string() + } +} + +#[test] +fn test_collect_live_refs_extracts_refs() { + let agents = make_agents_json(Some("gen1")); + let global = make_global_json(Some("gen2")); + let refs = collect_live_refs(&agents, &global).unwrap(); + assert!(refs.gen_ids.contains("gen1")); + assert!(refs.gen_ids.contains("gen2")); +} + +#[test] +fn test_collect_live_refs_empty_when_no_refs() { + let agents = make_agents_json(None); + let global = make_global_json(None); + let refs = collect_live_refs(&agents, &global).unwrap(); + assert!(refs.gen_ids.is_empty()); +} + +#[test] +fn test_collect_live_refs_none_on_malformed_json() { + let result = collect_live_refs("not json", "{}"); + assert!(result.is_none()); +} + +// ── F5b: collect_live_refs validates, not just collects ────────────────── +// +// Any ambiguity in the JSON makes the whole sweep a no-op (returns None) — +// a partial deletion decision could orphan a live secret. + +#[test] +fn test_collect_live_refs_none_on_empty_coordinate() { + // An empty ref string is a malformed coordinate — it cannot match any + // blob generation, so the sweep must not run. + let agents = r#"[{"pubkey":"abc","name":"t","env_vars_ref":"","created_at":"2026","updated_at":"2026"}]"#; + assert!(collect_live_refs(agents, "{}").is_none()); +} + +#[test] +fn test_collect_live_refs_none_on_embedded_colon_coordinate() { + // A gen id is the last `:`-segment of a blob key. An embedded `:` in the + // ref would make it un-matchable, so the coordinate is malformed. + let agents = r#"[{"pubkey":"abc","name":"t","env_vars_ref":"gen:evil","created_at":"2026","updated_at":"2026"}]"#; + assert!(collect_live_refs(agents, "{}").is_none()); +} + +#[test] +fn test_collect_live_refs_none_on_duplicate_gen_id() { + // Two coordinates referencing the same gen id — impossible for fresh + // UUIDs, so the JSON is corrupt and the sweep must not run. + let agents = r#"[ + {"pubkey":"a","name":"t","env_vars_ref":"gen1","created_at":"2026","updated_at":"2026"}, + {"pubkey":"b","name":"t","env_vars_ref":"gen1","created_at":"2026","updated_at":"2026"} + ]"#; + assert!(collect_live_refs(agents, "{}").is_none()); +} + +#[test] +fn test_collect_live_refs_none_on_duplicate_across_field_and_global() { + // Same gen id in an agent env ref and the global env ref. + let agents = r#"[{"pubkey":"a","name":"t","env_vars_ref":"gen1","created_at":"2026","updated_at":"2026"}]"#; + let global = r#"{"env_vars_ref":"gen1"}"#; + assert!(collect_live_refs(agents, global).is_none()); +} + +#[test] +fn test_collect_live_refs_none_on_inline_plus_ref_conflict_env() { + // A record carrying BOTH non-empty inline env_vars AND an env_vars_ref is + // ambiguous: inline is authoritative on load, so the ref is being ignored. + // The GC must not reason about which gen is live. + let agents = r#"[{"pubkey":"a","name":"t","env_vars":{"K":"v"},"env_vars_ref":"gen1","created_at":"2026","updated_at":"2026"}]"#; + assert!(collect_live_refs(agents, "{}").is_none()); +} + +#[test] +fn test_collect_live_refs_none_on_inline_plus_ref_conflict_auth_tag() { + let agents = r#"[{"pubkey":"a","name":"t","auth_tag":"live-tag","auth_tag_ref":"gen1","created_at":"2026","updated_at":"2026"}]"#; + assert!(collect_live_refs(agents, "{}").is_none()); +} + +#[test] +fn test_collect_live_refs_none_on_inline_plus_ref_conflict_provider_config() { + let agents = r#"[{"pubkey":"a","name":"t","backend":{"type":"provider","id":"anthropic","config":{"k":"v"}},"provider_config_ref":"gen1","created_at":"2026","updated_at":"2026"}]"#; + assert!(collect_live_refs(agents, "{}").is_none()); +} + +#[test] +fn test_collect_live_refs_none_on_global_inline_plus_ref_conflict() { + let global = r#"{"env_vars":{"K":"v"},"env_vars_ref":"gen1"}"#; + assert!(collect_live_refs("[]", global).is_none()); +} + +#[test] +fn test_collect_live_refs_allows_empty_inline_with_ref() { + // Empty inline (env_vars: {}) alongside a ref is the HEALTHY stripped + // state — not a conflict. The ref must be collected. + let agents = r#"[{"pubkey":"a","name":"t","env_vars":{},"env_vars_ref":"gen1","created_at":"2026","updated_at":"2026"}]"#; + let refs = collect_live_refs(agents, "{}").expect("empty inline + ref is healthy"); + assert!(refs.gen_ids.contains("gen1")); +} + +#[test] +fn test_collect_live_refs_allows_null_provider_config_with_ref() { + // Stripped provider config is JSON null alongside a ref — healthy state. + let agents = r#"[{"pubkey":"a","name":"t","backend":{"type":"provider","id":"anthropic","config":null},"provider_config_ref":"gen1","created_at":"2026","updated_at":"2026"}]"#; + let refs = collect_live_refs(agents, "{}").expect("null config + ref is healthy"); + assert!(refs.gen_ids.contains("gen1")); +} + +#[test] +fn test_collect_live_refs_collects_all_three_instance_fields() { + let agents = r#"[{"pubkey":"a","name":"t","env_vars_ref":"g_env","auth_tag_ref":"g_auth","backend":{"type":"provider","id":"anthropic","config":null},"provider_config_ref":"g_pc","created_at":"2026","updated_at":"2026"}]"#; + let refs = collect_live_refs(agents, "{}").unwrap(); + assert!(refs.gen_ids.contains("g_env")); + assert!(refs.gen_ids.contains("g_auth")); + assert!(refs.gen_ids.contains("g_pc")); +} + +#[test] +fn test_gc_interleaving_save_cancels_candidacy() { + // Simulate: GC marks gen1 as candidate, then a save confirms it into JSON. + // GC sweep 2 should NOT delete gen1 because the ref is now live. + let store = FakeProjectionStore::reachable() + .with_entry("global:env:gen1", "sk-secret") + .with_entry("global:env:gen1_candidate", "1"); // sweep 1 already ran + + // Sweep 2 re-reads JSON — now gen1 IS referenced. + // We simulate this by providing JSON that references gen1. + let agents_content = make_agents_json(None); + let global_content = make_global_json(Some("gen1")); + + let live_refs = collect_live_refs(&agents_content, &global_content).unwrap(); + assert!(live_refs.gen_ids.contains("gen1"), "gen1 must be live"); + + // Verify that delete_gc_candidates would skip gen1 because it's live. + // Since we can't call delete_gc_candidates directly (it reads files), + // we verify the logic: a live ref prevents deletion. + let blob = store.load_all().unwrap().unwrap(); + let candidate_key = "global:env:gen1_candidate"; + assert!( + blob.contains_key(candidate_key), + "candidate should be present" + ); + + // Simulate what delete_gc_candidates does: skip live refs. + let gen = "gen1"; + let would_delete = !live_refs.gen_ids.contains(gen); + assert!( + !would_delete, + "gen1 must NOT be deleted — it's now referenced" + ); +} + +#[test] +fn test_gc_no_op_on_unreachable_keyring() { + // GC mark/delete must be skipped when the keyring is unavailable. + let store = FakeProjectionStore::unreachable().with_entry("global:env:gen1", "value"); + // load_all returns Err — mark_gc_candidates aborts early. + let result = store.load_all(); + assert!(result.is_err()); + // Confirms GC would abort before any writes. +} + +// ── F5a: synchronized interleaving — GC vs an in-flight save ───────────── +// +// The dangerous ordering the store lock exists to prevent: +// 1. A save writes a NEW generation to the blob and read-back verifies it. +// 2. The save has NOT yet committed the JSON pointing at the new gen. +// 3. GC runs its full two-cycle sweep against the CURRENT (old) JSON. +// 4. The save commits its JSON. +// +// If GC could observe the pre-commit JSON AND delete on the same cycle, the +// new gen (unreferenced in old JSON) would be destroyed. Two properties make +// this safe and are exercised here with the REAL GC functions over tempfile +// JSON: (a) delete-before-mark means a gen written this boot is only a +// deletion candidate after a full mark cycle, never on the boot it appears; +// (b) once the JSON commits, the ref is live and the gen is protected. + +fn write_json_stores( + agents: &str, + global: &str, +) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let agents_path = dir.path().join("managed-agents.json"); + let global_path = dir.path().join("global-agent-config.json"); + std::fs::write(&agents_path, agents).expect("write agents"); + std::fs::write(&global_path, global).expect("write global"); + (dir, agents_path, global_path) +} + +#[test] +fn test_gc_delete_before_mark_spares_gen_written_this_boot() { + // A generation written + verified this boot, whose JSON commit has NOT + // landed (JSON still references the OLD gen), must survive a full GC pass. + // gen_new has no candidate marker yet, so delete phase skips it; the mark + // phase marks it — but deletion only happens on a LATER boot's delete + // phase, giving the pending save a full cycle to commit. + let store = FakeProjectionStore::reachable() + .with_entry("global:env:gen_old", "old-secret") + .with_entry("global:env:gen_new", "new-secret"); // in-flight, not yet in JSON + + // JSON still references the OLD generation (commit pending). + let (_dir, agents_path, global_path) = + write_json_stores(&make_agents_json(None), &make_global_json(Some("gen_old"))); + + // Full two-cycle pass in the boot order: delete, then mark. + delete_gc_candidates(&store, &agents_path, &global_path); + mark_gc_candidates(&store, &agents_path, &global_path); + + // gen_new must NOT have been deleted this boot. + assert!( + store.get("global:env:gen_new").is_some(), + "in-flight generation must survive the GC pass before its JSON commit" + ); +} + +#[test] +fn test_gc_spares_gen_once_json_commit_lands() { + // Continuation: the save now commits its JSON (references gen_new). Even + // though gen_new was marked as a candidate on the prior boot, the delete + // phase re-reads JSON, sees gen_new is live, and spares it — while the + // now-unreferenced gen_old is reclaimed. + let store = FakeProjectionStore::reachable() + .with_entry("global:env:gen_old", "old-secret") + .with_entry("global:env:gen_new", "new-secret") + .with_entry("global:env:gen_new_candidate", "1") // marked last boot + .with_entry("global:env:gen_old_candidate", "1"); // also marked last boot + + // JSON now references gen_new (the save committed). + let (_dir, agents_path, global_path) = + write_json_stores(&make_agents_json(None), &make_global_json(Some("gen_new"))); + + delete_gc_candidates(&store, &agents_path, &global_path); + + assert!( + store.get("global:env:gen_new").is_some(), + "committed generation must be spared even though it was a candidate" + ); + // gen_old is now unreferenced and was a candidate → reclaimed. + assert!( + store.get("global:env:gen_old").is_none(), + "the retired generation must be reclaimed once it is unreferenced" + ); +} + +#[test] +fn test_gc_reclaims_stably_unreferenced_candidate() { + // The delete phase reclaims a candidate whose generation is unreferenced + // in JSON and stays unreferenced across the snapshot + final re-check. + // This is the positive case that bounds the mid-sweep abort guard: with + // stable JSON there is no false abort, so retirement actually happens. + let store = FakeProjectionStore::reachable() + .with_entry("global:env:gen_stale", "secret") + .with_entry("global:env:gen_stale_candidate", "1"); + let (_dir, agents_path, global_path) = + write_json_stores(&make_agents_json(None), &make_global_json(None)); + + delete_gc_candidates(&store, &agents_path, &global_path); + + assert!( + store.get("global:env:gen_stale").is_none(), + "a stably-unreferenced candidate must be reclaimed" + ); +} + +// ── F5b: GC validates live refs against the blob, not just syntax ───────── +// +// A live ref whose full coordinate is MISSING from the blob (dangling) means +// the store is degraded: an older, unreferenced generation for the SAME field +// could be the only recoverable payload. Both sweeps must no-op until the +// reference resolves — deleting the unreferenced candidate would destroy the +// last copy. + +#[test] +fn test_gc_delete_no_op_when_a_live_ref_coordinate_is_missing() { + // JSON references live gen_g (dangling: NOT present in the blob). + // Candidate gen_h is unreferenced and marked from a prior boot. + // Without the coordinate check, delete would reclaim gen_h; with it, the + // dangling live ref freezes ALL deletion so gen_h (a possible last copy) + // survives. + let store = FakeProjectionStore::reachable() + .with_entry("global:env:gen_h", "recoverable-secret") + .with_entry("global:env:gen_h_candidate", "1"); // marked last boot + // Note: global:env:gen_g is deliberately ABSENT from the blob. + + let agents = make_agents_json(None); + let global = make_global_json(Some("gen_g")); // JSON references the dangling gen + + // Sanity: gen_g's coordinate is a live ref but its blob entry is missing. + let live_refs = collect_live_refs(&agents, &global).unwrap(); + assert!(live_refs.gen_ids.contains("gen_g")); + assert!(live_refs.coords.contains("global:env:gen_g")); + + let (_dir, agents_path, global_path) = write_json_stores(&agents, &global); + delete_gc_candidates(&store, &agents_path, &global_path); + + assert!( + store.get("global:env:gen_h").is_some(), + "gen_h must survive: a dangling live ref freezes deletion so the last \ + recoverable payload is not destroyed" + ); +} + +#[test] +fn test_gc_mark_no_op_when_a_live_ref_coordinate_is_missing() { + // Same degraded state as above, at the MARK phase: an unreferenced gen_h + // must NOT be newly marked as a candidate while a live ref is dangling — + // marking is the first step toward deletion, so it is frozen too. + let store = + FakeProjectionStore::reachable().with_entry("global:env:gen_h", "recoverable-secret"); + // global:env:gen_g (the live ref's coordinate) is ABSENT. + + let agents = make_agents_json(None); + let global = make_global_json(Some("gen_g")); + let (_dir, agents_path, global_path) = write_json_stores(&agents, &global); + + mark_gc_candidates(&store, &agents_path, &global_path); + + assert!( + store.get("global:env:gen_h_candidate").is_none(), + "gen_h must not be marked while a live ref is dangling" + ); +} + +#[test] +fn test_gc_delete_proceeds_once_all_live_coordinates_present() { + // Positive bound: with every live ref's coordinate present in the blob, + // the degraded-state guard does not fire and an unreferenced candidate is + // reclaimed as normal. This proves the new check gates on the missing + // coordinate specifically, not on the mere presence of any live ref. + let store = FakeProjectionStore::reachable() + .with_entry("global:env:gen_g", "live-secret") // live ref coordinate present + .with_entry("global:env:gen_h", "stale-secret") + .with_entry("global:env:gen_h_candidate", "1"); + + let agents = make_agents_json(None); + let global = make_global_json(Some("gen_g")); + let (_dir, agents_path, global_path) = write_json_stores(&agents, &global); + + delete_gc_candidates(&store, &agents_path, &global_path); + + assert!( + store.get("global:env:gen_g").is_some(), + "the live generation must be spared" + ); + assert!( + store.get("global:env:gen_h").is_none(), + "the unreferenced candidate must be reclaimed once no live ref dangles" + ); +} + +#[test] +fn test_cancel_gc_candidacy_removes_marker() { + let store = FakeProjectionStore::reachable() + .with_entry("global:env:gen1", "secret") + .with_entry("global:env:gen1_candidate", "1"); + + cancel_gc_candidacy(&store, "global:env:gen1"); + assert!( + store.get("global:env:gen1_candidate").is_none(), + "candidate marker must be removed" + ); + assert_eq!( + store.get("global:env:gen1"), + Some("secret".to_string()), + "generation must NOT be deleted by cancel" + ); +} + +// ── Key constructors ────────────────────────────────────────────────── + +#[test] +fn test_key_constructors_round_trip() { + assert_eq!(global_env_key("gen1"), "global:env:gen1"); + assert_eq!(agent_env_key("abc", "gen1"), "agent:abc:env:gen1"); + assert_eq!(agent_auth_tag_key("abc", "gen1"), "agent:abc:auth_tag:gen1"); + assert_eq!( + agent_provider_config_key("abc", "gen1"), + "agent:abc:provider_config:gen1" + ); + assert_eq!( + definition_env_key("my-slug", "gen1"), + "definition:my-slug:env:gen1" + ); +} + +// ── Serialization ───────────────────────────────────────────────────── + +#[test] +fn test_serialize_deserialize_env_map() { + let mut env = BTreeMap::new(); + env.insert("ANTHROPIC_API_KEY".to_string(), "sk-ant-secret".to_string()); + env.insert("BUZZ_THINKING".to_string(), "high".to_string()); + let s = serialize_env_map(&env).unwrap(); + let back = deserialize_env_map(&s).unwrap(); + assert_eq!(env, back); +} + +#[test] +fn test_serialize_deserialize_provider_config() { + let config = serde_json::json!({"host": "example.com", "port": 443}); + let s = serialize_provider_config(&config).unwrap(); + let back = deserialize_provider_config(&s).unwrap(); + assert_eq!(config, back); +} + +// ── Inline-over-ref precedence (spec §1, inline-precedence pin) ─────── + +#[test] +fn test_inline_wins_over_ref_when_both_present() { + // Hydration must prefer inline over any keyring ref — the inline is + // the authoritative value when the keyring write failed. + // This test verifies the CONTRACT expected by the hydration code: + // when env_vars is non-empty (inline) and env_vars_ref is also set, + // the hydration layer must NOT overwrite the inline value with the + // keyring value. + // + // The actual enforcement happens in hydrate_global_secrets and + // hydrate_agent_secrets (in storage.rs). This test verifies the + // fundamental assumption: if env_vars is non-empty, it should be + // treated as the authoritative value. + let inline_env = { + let mut m = BTreeMap::new(); + m.insert("KEY".to_string(), "inline-value".to_string()); + m + }; + // Simulate: inline value is present (keyring write failed last boot). + // The hydration code should keep `inline_env` and not call load_secret. + let inline_is_present = !inline_env.is_empty(); + assert!( + inline_is_present, + "inline must take precedence when present" + ); +} + +// ── Two-cycle GC ordering and cancel-before-mark tests ─────────────── + +#[test] +fn test_gc_delete_first_order_preserves_in_flight_gen() { + // Spec §6: delete candidates from the PREVIOUS boot first, then mark + // new ones for THIS boot. A generation verified this boot but not yet + // committed to JSON must NOT be deleted this boot. + // + // Setup: gen1 is in-flight (written + verified, JSON commit pending). + // boot N-1 left a candidate marker for an old gen0 that is now gone. + // GC delete phase runs: gen0 (with a candidate marker) is still + // unreferenced — it should be deleted. gen1 has NO candidate marker + // yet — it must NOT be deleted. + // + // This tests the FakeProjectionStore's delete_gc_candidates logic + // directly via collect_live_refs + manual blob inspection. + + // Blob state at start of boot N's GC: + // gen0 was orphaned last boot and marked as candidate + // gen1 is the new in-flight generation (no marker yet) + let store = FakeProjectionStore::reachable() + .with_entry("global:env:gen0", "old-secret") // old generation + .with_entry("global:env:gen0_candidate", "1") // marked last boot + .with_entry("global:env:gen1", "new-secret"); // in-flight + + // JSON currently still references gen0 (JSON commit hasn't happened). + // GC reads the live refs from JSON. + let agents = make_agents_json(None); + let global = make_global_json(Some("gen0")); // JSON still has old ref + + let live_refs = collect_live_refs(&agents, &global).unwrap(); + assert!(live_refs.gen_ids.contains("gen0"), "gen0 is still in JSON"); + assert!( + !live_refs.gen_ids.contains("gen1"), + "gen1 not yet committed to JSON" + ); + + // delete_gc_candidates: gen0 is a candidate but IS referenced → skip. + // gen1 has NO candidate marker → skip. + // Nothing should be deleted this cycle. + let blob = store.load_all().unwrap().unwrap(); + let candidate_for_gen0 = blob.get("global:env:gen0_candidate"); + let candidate_for_gen1 = blob.get("global:env:gen1_candidate"); + assert!( + candidate_for_gen0.is_some(), + "gen0 candidate marker must still be present" + ); + assert!( + candidate_for_gen1.is_none(), + "gen1 must have no candidate marker" + ); + + // Verify delete_gc_candidates would skip gen0 because it IS referenced. + let gen = "gen0"; + let would_delete = !live_refs.gen_ids.contains(gen); + assert!( + !would_delete, + "gen0 must NOT be deleted — it's still referenced in JSON" + ); +} + +#[test] +fn test_cancel_before_mark_ordering_protects_in_flight_gen() { + // Spec §6: cancel happens before the JSON commit. The GC mark phase + // that runs AFTER the cancel must not re-mark the in-flight generation. + // + // Scenario (cancel-happens-before-mark, the case Paul flagged): + // 1. Save writes gen2 and verifies it. + // 2. Save calls cancel_gc_candidacy("gen2") — a no-op since gen2 wasn't + // marked, but it guarantees the marker is absent. + // 3. GC mark phase runs (shouldn't happen in the same call, but safe). + // 4. GC sees gen2 as unreferenced (JSON still has gen1 ref) and marks it. + // 5. Save commits JSON with gen2 ref. + // 6. GC delete phase (next boot) sees gen2 is now referenced → skips. + // + // The key correctness property: between steps 2 and 5, even if GC + // marks gen2, the NEXT boot's delete phase sees gen2 as referenced and + // will not delete it. This test verifies step 4 is safe: a marked- + // then-referenced gen survives. + + let _store = FakeProjectionStore::reachable() + .with_entry("global:env:gen1", "old-secret") + .with_entry("global:env:gen2", "new-secret") + // GC marked gen2 as a candidate (step 4 above). + .with_entry("global:env:gen2_candidate", "1"); + + // After JSON commit (step 5), gen2 is referenced. + let agents = make_agents_json(None); + let global = make_global_json(Some("gen2")); // JSON now has gen2 ref + + let live_refs = collect_live_refs(&agents, &global).unwrap(); + assert!( + live_refs.gen_ids.contains("gen2"), + "gen2 is now referenced in JSON" + ); + + // delete_gc_candidates (next boot): gen2 is a candidate BUT is now + // referenced → skip. gen2 must NOT be deleted. + let gen = "gen2"; + let would_delete = !live_refs.gen_ids.contains(gen); + assert!( + !would_delete, + "gen2 must NOT be deleted — it's now referenced in JSON" + ); + + // gen1 is now unreferenced → it should become a candidate on next mark. + assert!( + !live_refs.gen_ids.contains("gen1"), + "gen1 is no longer referenced (gen2 replaced it)" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/secret_seam.rs b/desktop/src-tauri/src/managed_agents/secret_seam.rs new file mode 100644 index 00000000000..be583c7b046 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/secret_seam.rs @@ -0,0 +1,497 @@ +//! Hydrate-on-load / strip-on-save seam for the generation-reference protocol. +//! +//! Each public function here is the single call site that moves secret values +//! (env vars, auth tags, provider configs) between in-memory records and the +//! OS keyring. `storage.rs` calls these at the top of every load path and at +//! the bottom of every save path. + +use crate::managed_agents::{ + secret_projection::{ + agent_auth_tag_key, agent_env_key, agent_provider_config_key, cancel_gc_candidacy, + definition_env_key, deserialize_env_map, deserialize_provider_config, load_secret, + serialize_env_map, serialize_provider_config, write_secret, write_secrets_batched, + FieldSave, ProjectionStore, WriteOutcome, + }, + BackendKind, ManagedAgentRecord, +}; + +// ── Inline precedence rule (spec §1) ────────────────────────────────────── +// +// - Non-empty env_vars / Some(auth_tag) / non-Null provider config → +// AUTHORITATIVE (inline fallback state). Keyring ref is ignored on load. +// - On save: success → clear inline, set ref, cancel candidacy. The old +// generation is retired by the two-cycle GC, never eagerly here — +// deleting before the JSON commit could orphan a committed secret. +// failure → keep inline, clear ref (retry next boot). + +/// Hydrate secret fields of an instance record from the keyring. +pub(crate) fn hydrate_agent_secrets_with( + store: &S, + record: &mut ManagedAgentRecord, +) -> Vec { + let mut errors = Vec::new(); + if record.env_vars.is_empty() { + match load_secret( + store, + record.env_vars_ref.as_deref(), + |gen| agent_env_key(&record.pubkey, gen), + &format!("agent:{} env_vars", record.pubkey), + ) { + Ok(Some(s)) => match deserialize_env_map(&s) { + Ok(map) => record.env_vars = map, + Err(e) => errors.push(format!( + "agent {} env_vars deserialization failed: {e}", + record.pubkey + )), + }, + Ok(None) => {} + Err(e) => errors.push(e), + } + } + if record.auth_tag.is_none() { + match load_secret( + store, + record.auth_tag_ref.as_deref(), + |gen| agent_auth_tag_key(&record.pubkey, gen), + &format!("agent:{} auth_tag", record.pubkey), + ) { + Ok(Some(v)) => record.auth_tag = Some(v), + Ok(None) => {} + Err(e) => errors.push(e), + } + } + if let BackendKind::Provider { + ref id, + ref mut config, + } = record.backend + { + if config.is_null() { + match load_secret( + store, + record.provider_config_ref.as_deref(), + |gen| agent_provider_config_key(&record.pubkey, gen), + &format!("agent:{} provider_config", record.pubkey), + ) { + Ok(Some(s)) => match deserialize_provider_config(&s) { + Ok(v) => *config = v, + Err(e) => errors.push(format!( + "agent {} provider_config deserialization failed: {e}", + record.pubkey + )), + }, + Ok(None) => {} + Err(e) => errors.push(e), + } + } + let _ = id; + } + errors +} + +/// Strip and persist secret fields of an instance record before JSON write. +/// +/// # Fail-closed against empty-projection over unavailable +/// +/// When `record.secrets_unavailable` is set (a `*_ref` failed to hydrate on +/// load, leaving the field empty in memory), a naive persist would call +/// `write_secret` with an empty value, get [`WriteOutcome::Nothing`], and +/// CLEAR the still-live `*_ref` — orphaning the generation permanently. On the +/// `Nothing` branch we therefore PRESERVE any pre-existing ref instead of +/// clearing it, so a transient keyring outage never destroys the pointer to a +/// live secret. Real user-cleared fields on an available record still clear +/// normally (`secrets_unavailable` is false). +pub(crate) fn strip_and_persist_agent_secrets_with( + store: &S, + record: &mut ManagedAgentRecord, +) { + let unavailable = record.secrets_unavailable; + let pubkey = record.pubkey.clone(); + + // Serialize each field's inline value once. `None` means "field empty" and + // maps to WriteOutcome::Nothing (no write, no reuse). + let inline_env = if !record.env_vars.is_empty() { + serialize_env_map(&record.env_vars).ok() + } else { + None + }; + let auth_val = record.auth_tag.clone(); + let provider = match &record.backend { + BackendKind::Provider { id, config } => { + let serialized = if !config.is_null() { + serialize_provider_config(config).ok() + } else { + None + }; + Some((id.clone(), serialized)) + } + _ => None, + }; + + // Coordinate builders borrow `pubkey`; contexts are owned so they outlive + // the batched call. Both are dropped before `record` is mutated below. + let env_key = |gen: &str| agent_env_key(&pubkey, gen); + let auth_key = |gen: &str| agent_auth_tag_key(&pubkey, gen); + let pc_key = |gen: &str| agent_provider_config_key(&pubkey, gen); + let env_ctx = format!("agent:{pubkey} env_vars"); + let auth_ctx = format!("agent:{pubkey} auth_tag"); + let pc_ctx = provider + .as_ref() + .map(|(id, _)| format!("agent:{pubkey} provider_config ({id})")); + + let mut fields = vec![ + FieldSave { + coord_key_fn: &env_key, + value: inline_env.as_deref(), + existing_ref: record.env_vars_ref.as_deref(), + context: &env_ctx, + }, + FieldSave { + coord_key_fn: &auth_key, + value: auth_val.as_deref(), + existing_ref: record.auth_tag_ref.as_deref(), + context: &auth_ctx, + }, + ]; + if let Some((_, serialized)) = &provider { + fields.push(FieldSave { + coord_key_fn: &pc_key, + value: serialized.as_deref(), + existing_ref: record.provider_config_ref.as_deref(), + context: pc_ctx.as_deref().unwrap_or_default(), + }); + } + + // ONE blob mutation for every changed field; unchanged fields reuse their + // live generation and write nothing. + let outcomes = write_secrets_batched(store, &fields); + drop(fields); // end the immutable borrow of `record` before mutating it. + + match &outcomes[0] { + WriteOutcome::Persisted { gen } => { + record.env_vars.clear(); + record.env_vars_ref = Some(gen.clone()); + } + // Empty projection: preserve an existing ref when unavailable so a + // failed-hydrate save does not orphan the live generation. + WriteOutcome::Nothing if unavailable => {} + WriteOutcome::KeptInline { .. } | WriteOutcome::Nothing => { + record.env_vars_ref = None; + } + } + match &outcomes[1] { + WriteOutcome::Persisted { gen } => { + record.auth_tag = None; + record.auth_tag_ref = Some(gen.clone()); + } + WriteOutcome::Nothing if unavailable => {} + WriteOutcome::KeptInline { .. } | WriteOutcome::Nothing => { + record.auth_tag_ref = None; + } + } + if provider.is_some() { + match &outcomes[2] { + WriteOutcome::Persisted { gen } => { + if let BackendKind::Provider { config, .. } = &mut record.backend { + *config = serde_json::Value::Null; + } + record.provider_config_ref = Some(gen.clone()); + } + WriteOutcome::Nothing if unavailable => {} + WriteOutcome::KeptInline { .. } | WriteOutcome::Nothing => { + record.provider_config_ref = None; + } + } + } else { + record.provider_config_ref = None; // Provider→Local: clear stale ref + } +} + +/// Hydrate secret fields of a definition (key-less) record. +pub(crate) fn hydrate_definition_secrets_with( + store: &S, + record: &mut ManagedAgentRecord, +) -> Vec { + let Some(slug) = record.slug.as_deref().map(str::to_string) else { + return Vec::new(); + }; + let mut errors = Vec::new(); + if record.env_vars.is_empty() { + match load_secret( + store, + record.env_vars_ref.as_deref(), + |gen| definition_env_key(&slug, gen), + &format!("definition:{slug} env_vars"), + ) { + Ok(Some(s)) => match deserialize_env_map(&s) { + Ok(map) => record.env_vars = map, + Err(e) => errors.push(format!("definition {slug} env_vars deserialize: {e}")), + }, + Ok(None) => {} + Err(e) => errors.push(e), + } + } + errors +} + +/// Strip and persist a definition record's env_vars. +/// +/// Mirrors the empty-projection guard in +/// [`strip_and_persist_agent_secrets_with`]: when the definition is +/// `secrets_unavailable` (its `env_vars_ref` failed to hydrate on load, +/// leaving `env_vars` empty in memory), preserve the existing ref on the +/// empty-projection branch rather than orphaning the live generation. +pub(crate) fn strip_and_persist_definition_secrets_with( + store: &S, + record: &mut ManagedAgentRecord, +) { + let unavailable = record.secrets_unavailable; + let Some(slug) = record.slug.as_deref().map(str::to_string) else { + return; + }; + let inline_env = if !record.env_vars.is_empty() { + serialize_env_map(&record.env_vars).ok() + } else { + None + }; + let env_key = |gen: &str| definition_env_key(&slug, gen); + let env_ctx = format!("definition:{slug} env_vars"); + let fields = [FieldSave { + coord_key_fn: &env_key, + value: inline_env.as_deref(), + existing_ref: record.env_vars_ref.as_deref(), + context: &env_ctx, + }]; + let outcomes = write_secrets_batched(store, &fields); + match &outcomes[0] { + WriteOutcome::Persisted { gen } => { + record.env_vars.clear(); + record.env_vars_ref = Some(gen.clone()); + } + WriteOutcome::Nothing if unavailable => {} + WriteOutcome::KeptInline { .. } | WriteOutcome::Nothing => { + record.env_vars_ref = None; + } + } +} + +/// Hydrate all secret fields in `records`, returning pubkeys of unavailable agents. +pub(crate) fn hydrate_all_secrets_for_records( + store: &S, + records: &mut [ManagedAgentRecord], +) -> Vec { + let mut unavailable = Vec::new(); + for record in records.iter_mut() { + if record.pubkey.is_empty() { + let errors = hydrate_definition_secrets_with(store, record); + for e in &errors { + eprintln!("buzz-desktop: {e}"); + } + // A definition whose env_vars_ref could not be hydrated is now + // holding an EMPTY env map with a live ref. Mark it unavailable so + // the strip-on-save path preserves the ref instead of committing + // the empty projection (which would orphan the still-live + // generation permanently). Definitions carry a slug, not a pubkey, + // so they are not pushed to the pubkey-keyed `unavailable` summary. + if !errors.is_empty() { + record.secrets_unavailable = true; + } + } else { + let errors = hydrate_agent_secrets_with(store, record); + for e in &errors { + eprintln!("buzz-desktop: {e}"); + } + if !errors.is_empty() { + record.secrets_unavailable = true; + unavailable.push(record.pubkey.clone()); + } + } + } + unavailable +} + +/// Strip and persist all secret fields in `records`. +pub(crate) fn strip_and_persist_all_for_records( + store: &S, + records: &mut [ManagedAgentRecord], +) { + for record in records.iter_mut() { + if record.pubkey.is_empty() { + strip_and_persist_definition_secrets_with(store, record); + } else { + strip_and_persist_agent_secrets_with(store, record); + } + } +} + +// ── Boot-migration transition (W1-safe) ─────────────────────────────────── +// +// The ordinary save path (`strip_and_persist_*`) treats an empty inline field +// on an AVAILABLE record as a deliberate user-clear and drops the ref. The +// boot migration must NOT: it re-reads ALREADY-PROJECTED records straight off +// disk (empty inline + live ref, and `secrets_unavailable` is `#[serde(skip)]` +// so always `false`), which the save path would read as a clear and wipe every +// committed ref on the second launch. This transition is the migration's own +// contract: project only a non-empty inline value; preserve an existing ref +// when inline is absent; never infer a clear from a raw projected record. + +/// Outcome of one field's boot-migration transition. +/// +/// The caller applies the record mutation implied by each arm — the transition +/// itself is coordinate-generic (it does not know which record field it serves) +/// so it can back the agent tiers here AND the custom-harness surface, which +/// carries a single `env` map under its own keyring namespace. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum FieldMigration { + /// Inline value written and read-back verified under a new generation. + /// Caller: clear the inline field and set its `*_ref` to `gen`. + Projected { gen: String }, + /// Inline absent, an existing ref is present. Caller: leave the ref + /// untouched — the committed generation stays live. + Preserved, + /// No projection: inline absent with no existing ref, OR the keyring write + /// failed (value kept inline for retry). Caller: leave the field as-is; the + /// migration never sets a ref it did not write. + Cleared, +} + +/// Field-granular boot-migration transition. Projects `inline` into the keyring +/// under a fresh generation when present; otherwise preserves `existing_ref` +/// without ever clearing it. +/// +/// Distinct from the strip-on-save seam: an absent inline value here means +/// "already projected on a prior launch," never "the user cleared it," so a +/// present ref is preserved rather than dropped. This is the seam Hayt's +/// custom-harness boot migration consumes so the two surfaces share one +/// W1-safe semantic instead of forking it. +pub(crate) fn migrate_inline_field( + store: &S, + coord_key_fn: impl Fn(&str) -> String, + inline: Option<&str>, + existing_ref: Option<&str>, + context: &str, +) -> FieldMigration { + match write_secret(store, &coord_key_fn, inline, context) { + WriteOutcome::Persisted { gen } => { + cancel_gc_candidacy(store, &coord_key_fn(&gen)); + FieldMigration::Projected { gen } + } + // Inline absent: `write_secret` attempted nothing. Preserve an existing + // ref (already-projected record) instead of treating empty as a clear. + WriteOutcome::Nothing if existing_ref.is_some() => FieldMigration::Preserved, + WriteOutcome::Nothing | WriteOutcome::KeptInline { .. } => FieldMigration::Cleared, + } +} + +/// Boot-migrate one instance record's secret fields at field granularity. +/// W1-safe: an already-projected record (empty inline, live refs) is left with +/// its refs intact rather than having them cleared. +fn migrate_agent_secrets_with(store: &S, record: &mut ManagedAgentRecord) { + let pubkey = record.pubkey.clone(); + let inline_env = if !record.env_vars.is_empty() { + serialize_env_map(&record.env_vars).ok() + } else { + None + }; + if let FieldMigration::Projected { gen } = migrate_inline_field( + store, + |gen| agent_env_key(&pubkey, gen), + inline_env.as_deref(), + record.env_vars_ref.as_deref(), + &format!("agent:{pubkey} env_vars"), + ) { + record.env_vars.clear(); + record.env_vars_ref = Some(gen); + } + + let auth_val = record.auth_tag.clone(); + if let FieldMigration::Projected { gen } = migrate_inline_field( + store, + |gen| agent_auth_tag_key(&pubkey, gen), + auth_val.as_deref(), + record.auth_tag_ref.as_deref(), + &format!("agent:{pubkey} auth_tag"), + ) { + record.auth_tag = None; + record.auth_tag_ref = Some(gen); + } + + if let BackendKind::Provider { + ref id, + ref mut config, + } = record.backend + { + let serialized = if !config.is_null() { + serialize_provider_config(config).ok() + } else { + None + }; + if let FieldMigration::Projected { gen } = migrate_inline_field( + store, + |gen| agent_provider_config_key(&pubkey, gen), + serialized.as_deref(), + record.provider_config_ref.as_deref(), + &format!("agent:{pubkey} provider_config ({id})"), + ) { + *config = serde_json::Value::Null; + record.provider_config_ref = Some(gen); + } + } +} + +/// Boot-migrate one definition record's `env_vars` at field granularity. +/// The definition-tier mirror of [`migrate_agent_secrets_with`]. +fn migrate_definition_secrets_with(store: &S, record: &mut ManagedAgentRecord) { + let Some(slug) = record.slug.as_deref().map(str::to_string) else { + return; + }; + let inline_env = if !record.env_vars.is_empty() { + serialize_env_map(&record.env_vars).ok() + } else { + None + }; + if let FieldMigration::Projected { gen } = migrate_inline_field( + store, + |gen| definition_env_key(&slug, gen), + inline_env.as_deref(), + record.env_vars_ref.as_deref(), + &format!("definition:{slug} env_vars"), + ) { + record.env_vars.clear(); + record.env_vars_ref = Some(gen); + } +} + +/// Boot-migrate all secret fields in `records` at field granularity. Returns +/// `true` when any record's ref set changed (the caller then rewrites JSON). +pub(crate) fn migrate_all_secrets_for_records( + store: &S, + records: &mut [ManagedAgentRecord], +) -> bool { + let mut changed = false; + for record in records.iter_mut() { + let before = ( + record.env_vars_ref.clone(), + record.auth_tag_ref.clone(), + record.provider_config_ref.clone(), + ); + if record.pubkey.is_empty() { + migrate_definition_secrets_with(store, record); + } else { + migrate_agent_secrets_with(store, record); + } + if before + != ( + record.env_vars_ref.clone(), + record.auth_tag_ref.clone(), + record.provider_config_ref.clone(), + ) + { + changed = true; + } + } + changed +} + +#[cfg(test)] +#[path = "secret_seam_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/secret_seam_tests.rs b/desktop/src-tauri/src/managed_agents/secret_seam_tests.rs new file mode 100644 index 00000000000..57151291629 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/secret_seam_tests.rs @@ -0,0 +1,849 @@ +//! Tests for the strip-on-save / hydrate-on-load seam. +//! +//! The load-bearing property proven here is the CRITICAL F1 fix: a save NEVER +//! eagerly deletes the old generation. The atomic JSON write is the commit +//! point; if it fails, the on-disk record still points at the OLD generation, +//! so that generation must remain in the keyring and stay hydratable. Old-gen +//! retirement is left entirely to the two-cycle GC. + +use super::*; +use crate::managed_agents::secret_projection::{ + agent_auth_tag_key, agent_env_key, agent_provider_config_key, definition_env_key, + global_env_key, write_secret, WriteOutcome, +}; +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap}; + +// ── Fake store (mirrors secret_projection_tests::FakeProjectionStore) ────── + +struct FakeProjectionStore { + data: RefCell>, +} + +impl FakeProjectionStore { + fn new() -> Self { + Self { + data: RefCell::new(HashMap::new()), + } + } + fn with_entry(self, key: &str, value: &str) -> Self { + self.data + .borrow_mut() + .insert(key.to_string(), value.to_string()); + self + } + fn contains(&self, key: &str) -> bool { + self.data.borrow().contains_key(key) + } +} + +impl ProjectionStore for FakeProjectionStore { + fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String> { + self.data + .borrow_mut() + .insert(key.to_string(), value.to_string()); + Ok(()) + } + fn load_key(&self, key: &str) -> Result, String> { + Ok(self.data.borrow().get(key).cloned()) + } + fn load_all(&self) -> Result>, String> { + Ok(Some(self.data.borrow().clone())) + } + fn store_batch(&self, entries: &HashMap) -> Result<(), String> { + for (k, v) in entries { + self.data.borrow_mut().insert(k.clone(), v.clone()); + } + Ok(()) + } + fn remove_batch(&self, keys: &[&str]) -> Result<(), String> { + for k in keys { + self.data.borrow_mut().remove(*k); + } + Ok(()) + } +} + +// ── Fake store that fails loads for keys pointing at a specific gen ───────── +// +// Simulates a keyring outage where an entry's *_ref is present in JSON but the +// blob is unreachable — the exact condition that sets `secrets_unavailable`. +struct FailingLoadStore { + fail_substr: String, +} + +impl FailingLoadStore { + fn new(fail_substr: &str) -> Self { + Self { + fail_substr: fail_substr.to_string(), + } + } +} + +impl ProjectionStore for FailingLoadStore { + fn write_and_verify(&self, _key: &str, _value: &str) -> Result<(), String> { + Ok(()) + } + fn load_key(&self, key: &str) -> Result, String> { + if key.contains(&self.fail_substr) { + Err(format!("simulated keyring outage for {key}")) + } else { + Ok(None) + } + } + fn load_all(&self) -> Result>, String> { + Ok(Some(HashMap::new())) + } + fn store_batch(&self, _entries: &HashMap) -> Result<(), String> { + Ok(()) + } + fn remove_batch(&self, _keys: &[&str]) -> Result<(), String> { + Ok(()) + } +} + +// ── Record builders ──────────────────────────────────────────────────────── + +fn instance_record(pubkey: &str) -> ManagedAgentRecord { + serde_json::from_str(&format!( + r#"{{ + "pubkey": "{pubkey}", + "name": "test-agent", + "private_key_nsec": "nsec1realkey", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }}"# + )) + .expect("instance record") +} + +fn definition_record(slug: &str) -> ManagedAgentRecord { + serde_json::from_str(&format!( + r#"{{ + "pubkey": "", + "name": "test-def", + "slug": "{slug}", + "relay_url": "", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }}"# + )) + .expect("definition record") +} + +fn env_map(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +// ── F1: old generation survives a save (no eager delete) ──────────────────── + +#[test] +fn test_instance_env_save_keeps_old_generation_for_failed_json_commit() { + // Old generation is live on disk (record.env_vars_ref = "gen_old"). + let pubkey = "abc"; + let store = FakeProjectionStore::new() + .with_entry(&agent_env_key(pubkey, "gen_old"), r#"{"OLD":"old-secret"}"#); + + // The on-disk record as it would be re-read after a FAILED JSON write: + // it still points at the old generation because the new commit never landed. + let mut on_disk = instance_record(pubkey); + on_disk.env_vars_ref = Some("gen_old".to_string()); + + // A save runs: new env is written to a new generation. + let mut saving = instance_record(pubkey); + saving.env_vars = env_map(&[("NEW", "new-secret")]); + saving.env_vars_ref = Some("gen_old".to_string()); + strip_and_persist_agent_secrets_with(&store, &mut saving); + + // The old generation must NOT have been deleted by the save. + assert!( + store.contains(&agent_env_key(pubkey, "gen_old")), + "old generation must survive the save — JSON commit could still fail" + ); + + // Simulate the JSON write failing: the on-disk record (old ref) is what + // survives. Hydrating it must still resolve the old secret. + let errors = hydrate_agent_secrets_with(&store, &mut on_disk); + assert!(errors.is_empty(), "old ref must still hydrate: {errors:?}"); + assert_eq!(on_disk.env_vars, env_map(&[("OLD", "old-secret")])); +} + +#[test] +fn test_instance_auth_tag_save_keeps_old_generation_for_failed_json_commit() { + let pubkey = "abc"; + let store = FakeProjectionStore::new() + .with_entry(&agent_auth_tag_key(pubkey, "gen_old"), "old-auth-tag"); + + let mut on_disk = instance_record(pubkey); + on_disk.auth_tag_ref = Some("gen_old".to_string()); + + let mut saving = instance_record(pubkey); + saving.auth_tag = Some("new-auth-tag".to_string()); + saving.auth_tag_ref = Some("gen_old".to_string()); + strip_and_persist_agent_secrets_with(&store, &mut saving); + + assert!( + store.contains(&agent_auth_tag_key(pubkey, "gen_old")), + "old auth_tag generation must survive the save" + ); + + let errors = hydrate_agent_secrets_with(&store, &mut on_disk); + assert!(errors.is_empty(), "old auth ref must hydrate: {errors:?}"); + assert_eq!(on_disk.auth_tag.as_deref(), Some("old-auth-tag")); +} + +#[test] +fn test_instance_provider_config_save_keeps_old_generation_for_failed_json_commit() { + let pubkey = "abc"; + let old_config = r#"{"host":"old.example.com"}"#; + let store = FakeProjectionStore::new() + .with_entry(&agent_provider_config_key(pubkey, "gen_old"), old_config); + + let mut on_disk = instance_record(pubkey); + on_disk.backend = BackendKind::Provider { + id: "anthropic".to_string(), + config: serde_json::Value::Null, + }; + on_disk.provider_config_ref = Some("gen_old".to_string()); + + let mut saving = instance_record(pubkey); + saving.backend = BackendKind::Provider { + id: "anthropic".to_string(), + config: serde_json::json!({"host": "new.example.com"}), + }; + saving.provider_config_ref = Some("gen_old".to_string()); + strip_and_persist_agent_secrets_with(&store, &mut saving); + + assert!( + store.contains(&agent_provider_config_key(pubkey, "gen_old")), + "old provider_config generation must survive the save" + ); + + let errors = hydrate_agent_secrets_with(&store, &mut on_disk); + assert!(errors.is_empty(), "old pc ref must hydrate: {errors:?}"); + if let BackendKind::Provider { config, .. } = &on_disk.backend { + assert_eq!(config, &serde_json::json!({"host": "old.example.com"})); + } else { + panic!("expected provider backend"); + } +} + +#[test] +fn test_definition_env_save_keeps_old_generation_for_failed_json_commit() { + let slug = "my-def"; + let store = FakeProjectionStore::new().with_entry( + &definition_env_key(slug, "gen_old"), + r#"{"OLD":"old-secret"}"#, + ); + + let mut on_disk = definition_record(slug); + on_disk.env_vars_ref = Some("gen_old".to_string()); + + let mut saving = definition_record(slug); + saving.env_vars = env_map(&[("NEW", "new-secret")]); + saving.env_vars_ref = Some("gen_old".to_string()); + strip_and_persist_definition_secrets_with(&store, &mut saving); + + assert!( + store.contains(&definition_env_key(slug, "gen_old")), + "old definition env generation must survive the save" + ); + + let errors = hydrate_definition_secrets_with(&store, &mut on_disk); + assert!(errors.is_empty(), "old def ref must hydrate: {errors:?}"); + assert_eq!(on_disk.env_vars, env_map(&[("OLD", "old-secret")])); +} + +#[test] +fn test_global_env_write_keeps_old_generation_for_failed_json_commit() { + // Global config save has no seam fn (it lives in global_config::mod), but + // its persistence uses the same write_secret primitive. Prove the + // primitive does not disturb the prior generation: a new write creates a + // NEW gen and the old gen stays intact and loadable. + let store = + FakeProjectionStore::new().with_entry(&global_env_key("gen_old"), r#"{"OLD":"old"}"#); + + let outcome = write_secret( + &store, + global_env_key, + Some(r#"{"NEW":"new"}"#), + "global env", + ); + let new_gen = match outcome { + WriteOutcome::Persisted { gen } => gen, + other => panic!("expected Persisted, got {other:?}"), + }; + assert_ne!(new_gen, "gen_old"); + + // Old generation intact (JSON commit could still fail after this write). + assert!( + store.contains(&global_env_key("gen_old")), + "old global env generation must survive the write" + ); + // And still hydratable via its ref. + let loaded = load_secret(&store, Some("gen_old"), global_env_key, "global env"); + assert_eq!(loaded, Ok(Some(r#"{"OLD":"old"}"#.to_string()))); +} + +// ── F3b: fail-closed — a failed hydrate never orphans the live generation ─── +// +// Load path: an env_vars_ref present in JSON whose blob is unreachable must +// leave the field empty AND set `secrets_unavailable` — never silently drop +// the ref. Save path: persisting that unavailable record must PRESERVE the +// ref (empty-projection guard) so the still-live generation is not orphaned. + +#[test] +fn test_instance_failed_env_hydrate_sets_secrets_unavailable() { + let pubkey = "abc"; + let store = FailingLoadStore::new(&agent_env_key(pubkey, "gen_live")); + let mut record = instance_record(pubkey); + record.env_vars_ref = Some("gen_live".to_string()); + + let errors = hydrate_agent_secrets_with(&store, &mut record); + + assert!(!errors.is_empty(), "a failed hydrate must surface an error"); + assert!( + record.env_vars.is_empty(), + "field stays empty on outage — no silent partial value" + ); + // hydrate_all_secrets_for_records is the caller that sets the flag; assert + // there so the propagation contract is covered end-to-end. + let mut records = vec![{ + let mut r = instance_record(pubkey); + r.env_vars_ref = Some("gen_live".to_string()); + r + }]; + let unavailable = hydrate_all_secrets_for_records(&store, &mut records); + assert!( + records[0].secrets_unavailable, + "outage must mark unavailable" + ); + assert_eq!(unavailable, vec![pubkey.to_string()]); +} + +#[test] +fn test_instance_save_preserves_ref_when_secrets_unavailable() { + // The data-loss vector: a record whose env_vars_ref failed to hydrate holds + // an empty env map. A naive save would write nothing and CLEAR the ref, + // orphaning the live generation forever. The guard must keep the ref. + let pubkey = "abc"; + let store = + FakeProjectionStore::new().with_entry(&agent_env_key(pubkey, "gen_live"), r#"{"K":"v"}"#); + + let mut record = instance_record(pubkey); + record.env_vars_ref = Some("gen_live".to_string()); + record.env_vars.clear(); // failed-hydrate state: ref present, map empty + record.secrets_unavailable = true; + + strip_and_persist_agent_secrets_with(&store, &mut record); + + assert_eq!( + record.env_vars_ref.as_deref(), + Some("gen_live"), + "ref must be preserved so the live generation is not orphaned" + ); + assert!( + store.contains(&agent_env_key(pubkey, "gen_live")), + "the live generation must still exist" + ); +} + +#[test] +fn test_instance_save_preserves_auth_and_provider_refs_when_unavailable() { + let pubkey = "abc"; + let store = FakeProjectionStore::new() + .with_entry(&agent_auth_tag_key(pubkey, "gen_a"), "live-auth") + .with_entry(&agent_provider_config_key(pubkey, "gen_p"), r#"{"h":"x"}"#); + + let mut record = instance_record(pubkey); + record.auth_tag_ref = Some("gen_a".to_string()); + record.backend = BackendKind::Provider { + id: "anthropic".to_string(), + config: serde_json::Value::Null, + }; + record.provider_config_ref = Some("gen_p".to_string()); + record.secrets_unavailable = true; + + strip_and_persist_agent_secrets_with(&store, &mut record); + + assert_eq!( + record.auth_tag_ref.as_deref(), + Some("gen_a"), + "auth_tag ref must survive an unavailable save" + ); + assert_eq!( + record.provider_config_ref.as_deref(), + Some("gen_p"), + "provider_config ref must survive an unavailable save" + ); +} + +#[test] +fn test_available_record_clear_still_clears_ref() { + // Regression guard for the guard: on an AVAILABLE record (not unavailable), + // a genuinely-cleared field must still clear its ref — the fix must not + // pin stale refs for a real user edit. + let pubkey = "abc"; + let store = FakeProjectionStore::new(); + let mut record = instance_record(pubkey); + record.env_vars_ref = Some("gen_old".to_string()); + record.env_vars.clear(); + record.secrets_unavailable = false; // available: this is a real clear + + strip_and_persist_agent_secrets_with(&store, &mut record); + + assert_eq!( + record.env_vars_ref, None, + "an available record's cleared field must clear its ref" + ); +} + +#[test] +fn test_definition_failed_hydrate_then_save_preserves_ref() { + // End-to-end for the definition tier: outage on load → secrets_unavailable + // set by hydrate_all_secrets_for_records → save preserves the ref. + let slug = "my-def"; + let mut records = vec![{ + let mut d = definition_record(slug); + d.env_vars_ref = Some("gen_live".to_string()); + d + }]; + + let outage = FailingLoadStore::new(&definition_env_key(slug, "gen_live")); + let unavailable = hydrate_all_secrets_for_records(&outage, &mut records); + assert!( + unavailable.is_empty(), + "definitions are slug-keyed, not pushed to the pubkey summary" + ); + assert!( + records[0].secrets_unavailable, + "definition outage must set secrets_unavailable" + ); + assert!(records[0].env_vars.is_empty()); + + // Now save against a store where the live gen still exists. + let store = FakeProjectionStore::new() + .with_entry(&definition_env_key(slug, "gen_live"), r#"{"K":"v"}"#); + strip_and_persist_definition_secrets_with(&store, &mut records[0]); + + assert_eq!( + records[0].env_vars_ref.as_deref(), + Some("gen_live"), + "definition ref must survive an unavailable save" + ); + assert!(store.contains(&definition_env_key(slug, "gen_live"))); +} + +// ── F4: dev-migration conflict makes a coordinate unavailable through the ── +// hydration boundary and refuses spawn (not just a withheld marker) ─── +// +// The pass-1 fix only withheld the completion marker; the conflicted value +// stayed hydratable and could spawn during the retry window. These tests prove +// the conflict marker now propagates a real refusal: hydrate → secrets_ +// unavailable → spawn_key_refusal. + +#[test] +fn test_instance_env_conflict_marker_sets_secrets_unavailable_and_refuses_spawn() { + use crate::managed_agents::secret_projection::conflict_marker_key; + use crate::managed_agents::storage::spawn_key_refusal; + + let pubkey = "abc"; + let coord = agent_env_key(pubkey, "gen_live"); + // The generation IS present in the blob (destination has a value), but a + // conflict marker for its coordinate means that value cannot be trusted. + let store = FakeProjectionStore::new() + .with_entry(&coord, r#"{"ANTHROPIC_API_KEY":"dest-value"}"#) + .with_entry(&conflict_marker_key(&coord), "1"); + + let mut records = vec![{ + let mut r = instance_record(pubkey); + r.env_vars_ref = Some("gen_live".to_string()); + r + }]; + + let unavailable = hydrate_all_secrets_for_records(&store, &mut records); + assert_eq!( + unavailable, + vec![pubkey.to_string()], + "a conflicted coordinate must surface the instance as unavailable" + ); + assert!( + records[0].secrets_unavailable, + "conflict marker must set secrets_unavailable through hydration" + ); + assert!( + records[0].env_vars.is_empty(), + "the conflicted (untrusted) value must NOT be hydrated into the record" + ); + // The launch boundary refuses — the conflicted value can never spawn. + assert!( + spawn_key_refusal(&records[0]).is_some(), + "spawn must refuse an instance with an unresolved conflict" + ); +} + +#[test] +fn test_definition_env_conflict_marker_sets_secrets_unavailable() { + use crate::managed_agents::secret_projection::conflict_marker_key; + + let slug = "my-def"; + let coord = definition_env_key(slug, "gen_live"); + let store = FakeProjectionStore::new() + .with_entry(&coord, r#"{"K":"dest"}"#) + .with_entry(&conflict_marker_key(&coord), "1"); + + let mut records = vec![{ + let mut d = definition_record(slug); + d.env_vars_ref = Some("gen_live".to_string()); + d + }]; + + let _ = hydrate_all_secrets_for_records(&store, &mut records); + assert!( + records[0].secrets_unavailable, + "a conflicted definition coordinate must set secrets_unavailable — the \ + linked-instance spawn/deploy gate then refuses" + ); + assert!(records[0].env_vars.is_empty()); +} + +#[test] +fn test_cleared_conflict_marker_restores_availability_and_allows_spawn() { + use crate::managed_agents::storage::spawn_key_refusal; + + // Once the migration clears the conflict marker (conflict resolved), the + // same coordinate hydrates normally and spawn is no longer refused for it. + let pubkey = "abc"; + let coord = agent_env_key(pubkey, "gen_live"); + let store = + FakeProjectionStore::new().with_entry(&coord, r#"{"ANTHROPIC_API_KEY":"agreed-value"}"#); + // No conflict marker present. + + let mut records = vec![{ + let mut r = instance_record(pubkey); + r.env_vars_ref = Some("gen_live".to_string()); + r + }]; + + let unavailable = hydrate_all_secrets_for_records(&store, &mut records); + assert!( + unavailable.is_empty(), + "with the conflict cleared, the coordinate hydrates cleanly" + ); + assert!(!records[0].secrets_unavailable); + assert_eq!( + records[0] + .env_vars + .get("ANTHROPIC_API_KEY") + .map(String::as_str), + Some("agreed-value"), + "the resolved value must hydrate once the conflict is cleared" + ); + assert!( + spawn_key_refusal(&records[0]).is_none(), + "spawn must be allowed once the conflict is resolved" + ); +} + +// ── W1: boot-migration transition never clears a committed ref ────────────── +// +// The strip-on-save seam reads an empty inline field on an available record as +// a deliberate user-clear and drops the ref. The boot migration re-reads +// ALREADY-PROJECTED records off disk (empty inline + live ref) on EVERY launch; +// routing those through the strip seam wiped every committed ref on the second +// launch (W1). These tests pin the migration seam's distinct contract: project +// a non-empty inline value, preserve an existing ref when inline is absent, and +// never clear a ref it did not write. + +#[test] +fn test_migrate_inline_field_projects_nonempty_inline() { + let store = FakeProjectionStore::new(); + let outcome = migrate_inline_field( + &store, + |gen| agent_env_key("abc", gen), + Some(r#"{"K":"v"}"#), + None, + "agent:abc env_vars", + ); + let gen = match outcome { + FieldMigration::Projected { gen } => gen, + other => panic!("expected Projected, got {other:?}"), + }; + assert!( + store.contains(&agent_env_key("abc", &gen)), + "a projected value must be written under its new generation" + ); +} + +#[test] +fn test_migrate_inline_field_preserves_ref_when_inline_absent() { + // The W1 shape: an already-projected record (no inline, live ref). The + // migration must PRESERVE the ref, never treat empty as a clear. + let store = + FakeProjectionStore::new().with_entry(&agent_env_key("abc", "gen_live"), r#"{"K":"v"}"#); + let outcome = migrate_inline_field( + &store, + |gen| agent_env_key("abc", gen), + None, + Some("gen_live"), + "agent:abc env_vars", + ); + assert_eq!( + outcome, + FieldMigration::Preserved, + "an absent inline with a live ref must preserve, not clear" + ); + assert!( + store.contains(&agent_env_key("abc", "gen_live")), + "the preserved generation must remain in the keyring" + ); +} + +#[test] +fn test_migrate_inline_field_cleared_when_no_inline_and_no_ref() { + // Genuinely empty field: nothing inline, no ref. The migration does not + // fabricate a ref — Cleared means "leave the field exactly as-is." + let store = FakeProjectionStore::new(); + let outcome = migrate_inline_field( + &store, + |gen| agent_env_key("abc", gen), + None, + None, + "agent:abc env_vars", + ); + assert_eq!(outcome, FieldMigration::Cleared); +} + +#[test] +fn test_migrate_two_launches_preserves_every_ref_for_instance_and_definition() { + // The end-to-end W1 regression at the seam level: a first launch projects + // inline env/auth/provider (instance) + env (definition) into fresh + // generations; a second launch over the now-projected records (empty + // inline, live refs) is a no-op that leaves EVERY ref intact. The old + // strip-seam path cleared them all on launch two. + let store = FakeProjectionStore::new(); + + let mut instance = instance_record("abc"); + instance.env_vars = env_map(&[("ANTHROPIC_API_KEY", "sk-secret")]); + instance.auth_tag = Some("auth-secret".to_string()); + instance.backend = BackendKind::Provider { + id: "anthropic".to_string(), + config: serde_json::json!({"api_key": "provider-secret"}), + }; + let mut definition = definition_record("my-def"); + definition.env_vars = env_map(&[("DEF_KEY", "def-secret")]); + let mut records = vec![instance, definition]; + + // Launch 1: inline present → all fields projected. + let changed1 = migrate_all_secrets_for_records(&store, &mut records); + assert!(changed1, "first launch projects inline secrets"); + let env_ref = records[0].env_vars_ref.clone().expect("instance env ref"); + let auth_ref = records[0].auth_tag_ref.clone().expect("instance auth ref"); + let pc_ref = records[0] + .provider_config_ref + .clone() + .expect("instance provider ref"); + let def_ref = records[1].env_vars_ref.clone().expect("definition env ref"); + assert!( + records[0].env_vars.is_empty(), + "inline env cleared on launch 1" + ); + assert!( + records[0].auth_tag.is_none(), + "inline auth cleared on launch 1" + ); + + // Launch 2: records are already projected (empty inline, live refs). + let changed2 = migrate_all_secrets_for_records(&store, &mut records); + assert!( + !changed2, + "second launch is a no-op — no ref changes on already-projected records" + ); + assert_eq!( + records[0].env_vars_ref, + Some(env_ref.clone()), + "env ref survives launch 2" + ); + assert_eq!( + records[0].auth_tag_ref, + Some(auth_ref.clone()), + "auth ref survives launch 2" + ); + assert_eq!( + records[0].provider_config_ref, + Some(pc_ref.clone()), + "provider ref survives launch 2" + ); + assert_eq!( + records[1].env_vars_ref, + Some(def_ref.clone()), + "definition ref survives launch 2" + ); + + // The generations themselves must still be present and hydratable. + assert!(store.contains(&agent_env_key("abc", &env_ref))); + assert!(store.contains(&agent_auth_tag_key("abc", &auth_ref))); + assert!(store.contains(&agent_provider_config_key("abc", &pc_ref))); + assert!(store.contains(&definition_env_key("my-def", &def_ref))); + let errors = hydrate_all_secrets_for_records(&store, &mut records); + assert!( + errors.is_empty(), + "every ref must still hydrate after two launches: {errors:?}" + ); + assert_eq!( + records[0] + .env_vars + .get("ANTHROPIC_API_KEY") + .map(String::as_str), + Some("sk-secret") + ); + assert_eq!( + records[1].env_vars.get("DEF_KEY").map(String::as_str), + Some("def-secret") + ); +} + +#[test] +fn test_migrate_does_not_clear_ref_on_keyring_write_failure() { + // Distinct from Preserved: an inline value present but the keyring write + // fails (KeptInline). The migration must return Cleared (caller leaves the + // field as-is, value stays inline for retry) and must NOT set a ref it did + // not write. The record keeps its inline value; no ref is fabricated. + struct FailingWriteStore; + impl ProjectionStore for FailingWriteStore { + fn write_and_verify(&self, _key: &str, _value: &str) -> Result<(), String> { + Err("simulated keyring write failure".to_string()) + } + fn load_key(&self, _key: &str) -> Result, String> { + Ok(None) + } + fn load_all(&self) -> Result>, String> { + Ok(Some(HashMap::new())) + } + fn store_batch(&self, _entries: &HashMap) -> Result<(), String> { + Ok(()) + } + fn remove_batch(&self, _keys: &[&str]) -> Result<(), String> { + Ok(()) + } + } + let store = FailingWriteStore; + let mut record = instance_record("abc"); + record.env_vars = env_map(&[("K", "v")]); + // No pre-existing ref. + migrate_agent_secrets_with(&store, &mut record); + assert_eq!( + record.env_vars, + env_map(&[("K", "v")]), + "a failed keyring write must keep the value inline for retry" + ); + assert_eq!( + record.env_vars_ref, None, + "the migration must not fabricate a ref it did not write" + ); +} + +#[test] +fn test_migrate_two_launches_survive_both_gc_cycles() { + // The full W1 regression through the real GC: after a first launch projects + // inline secrets into fresh generations and commits the refs to JSON, a + // complete two-cycle GC sweep (delete→mark, twice, the boot order) must NOT + // reclaim any of those generations — they are all referenced by live JSON — + // and a second launch's migration must leave every ref intact. Before the + // W1 fix the second launch cleared the refs, orphaning the generations, and + // this GC would then delete them. + use crate::managed_agents::secret_projection::{ + collect_live_refs, delete_gc_candidates, mark_gc_candidates, + }; + + let store = FakeProjectionStore::new(); + + let mut instance = instance_record("abc"); + instance.env_vars = env_map(&[("ANTHROPIC_API_KEY", "sk-secret")]); + instance.auth_tag = Some("auth-secret".to_string()); + instance.backend = BackendKind::Provider { + id: "anthropic".to_string(), + config: serde_json::json!({"api_key": "provider-secret"}), + }; + let mut definition = definition_record("my-def"); + definition.env_vars = env_map(&[("DEF_KEY", "def-secret")]); + let mut records = vec![instance, definition]; + + // Launch 1: project inline → refs. + migrate_all_secrets_for_records(&store, &mut records); + let env_ref = records[0].env_vars_ref.clone().expect("env ref"); + let auth_ref = records[0].auth_tag_ref.clone().expect("auth ref"); + let pc_ref = records[0] + .provider_config_ref + .clone() + .expect("provider ref"); + let def_ref = records[1].env_vars_ref.clone().expect("def ref"); + + // Commit the projected records to disk exactly as the migration does, so GC + // reads the same JSON production would. Global config is empty. + let dir = tempfile::tempdir().expect("tempdir"); + let agents_path = dir.path().join("managed-agents.json"); + let global_path = dir.path().join("global-agent-config.json"); + std::fs::write( + &agents_path, + serde_json::to_string(&records).expect("serialize records"), + ) + .expect("write agents json"); + std::fs::write(&global_path, "{}").expect("write global json"); + + // Sanity: the committed JSON is a clean projection — every ref resolves to + // a live coordinate present in the blob, no inline+ref conflict. + let agents_json = std::fs::read_to_string(&agents_path).unwrap(); + let live = collect_live_refs(&agents_json, "{}").expect("clean live refs"); + for gen in [&env_ref, &auth_ref, &pc_ref, &def_ref] { + assert!(live.gen_ids.contains(gen), "{gen} must be a live ref"); + } + + // Two full boot-order GC cycles (delete before mark) over the committed + // JSON. A referenced generation is never a deletion candidate. + for _ in 0..2 { + delete_gc_candidates(&store, &agents_path, &global_path); + mark_gc_candidates(&store, &agents_path, &global_path); + } + + // Launch 2: migrate again over the already-projected records (empty inline, + // live refs). Must be a no-op that preserves every ref. + let changed = migrate_all_secrets_for_records(&store, &mut records); + assert!(!changed, "second launch must not change any ref"); + + // Every generation must have survived both GC cycles and still hydrate. + assert!(store.contains(&agent_env_key("abc", &env_ref))); + assert!(store.contains(&agent_auth_tag_key("abc", &auth_ref))); + assert!(store.contains(&agent_provider_config_key("abc", &pc_ref))); + assert!(store.contains(&definition_env_key("my-def", &def_ref))); + let errors = hydrate_all_secrets_for_records(&store, &mut records); + assert!( + errors.is_empty(), + "every ref must still hydrate after two launches + two GC cycles: {errors:?}" + ); + assert_eq!( + records[0] + .env_vars + .get("ANTHROPIC_API_KEY") + .map(String::as_str), + Some("sk-secret") + ); + assert_eq!( + records[1].env_vars.get("DEF_KEY").map(String::as_str), + Some("def-secret") + ); +} diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 1ceeee372f1..2b10afc7c00 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -70,6 +70,10 @@ fn record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } @@ -95,6 +99,7 @@ fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { parallelism: None, created_at: "now".into(), updated_at: "now".into(), + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 652bb9b9ea8..0068d792453 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -1,7 +1,7 @@ use std::{ collections::HashMap, - fs::{self, File, OpenOptions}, - io::{Read as _, Seek, SeekFrom, Write}, + fs, + io::Write, path::{Path, PathBuf}, }; @@ -9,10 +9,14 @@ use tauri::{AppHandle, Manager}; use crate::app_state::keyring_service; use crate::managed_agents::{ - ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentRuntimeReceipt, + secret_seam::{hydrate_all_secrets_for_records, strip_and_persist_all_for_records}, + AgentDefinition, ManagedAgentRecord, }; use crate::secret_store::{KeyringProbe, SecretStore}; +mod logs; +pub use logs::*; + /// Keyring key name for an agent's nsec, namespaced from the human identity /// key (`"identity"`) which shares the service. fn agent_keyring_name(pubkey: &str) -> String { @@ -46,88 +50,6 @@ pub(crate) fn managed_agents_store_path(app: &AppHandle) -> Result Result { - let dir = managed_agents_base_dir(app)?.join("logs"); - fs::create_dir_all(&dir).map_err(|error| format!("failed to create logs dir: {error}"))?; - Ok(dir) -} - -/// Install-log path for `runtime_id`, alongside the agent logs. -pub fn install_log_path(app: &AppHandle, runtime_id: &str) -> Result { - Ok(managed_agents_logs_dir(app)?.join(install_log_filename(runtime_id)?)) -} - -/// Filename for a runtime's install log, or an error for an id that must not -/// become one. -/// -/// The id is validated rather than trusted: ids reach this from user-defined -/// custom harnesses as well as the catalog, and a `../` or a separator in one -/// would place the log outside the logs directory. Rejecting beats sanitizing — -/// a rejected id means no log, while a rewritten one could collide with another -/// runtime's. -fn install_log_filename(runtime_id: &str) -> Result { - if runtime_id.is_empty() || !runtime_id.chars().all(is_safe_id_char) { - return Err(format!( - "unsafe runtime id for a log filename: {runtime_id}" - )); - } - Ok(format!("install-{runtime_id}.log")) -} - -/// Characters allowed in a runtime id used as a filename. Excludes `/`, `\`, -/// `:` and `.`, so no id can traverse or escape the logs directory. -fn is_safe_id_char(c: char) -> bool { - c.is_ascii_alphanumeric() || c == '-' || c == '_' -} - -pub fn managed_agent_log_path(app: &AppHandle, pubkey: &str) -> Result { - Ok(managed_agents_logs_dir(app)?.join(format!("{pubkey}.log"))) -} - -/// Pair-scoped log path for a managed runtime. The relay URL never appears in -/// the filename; the suffix is a hash of the canonical URL. -pub fn managed_agent_runtime_log_path( - app: &AppHandle, - key: &ManagedAgentRuntimeKey, -) -> Result { - Ok(managed_agents_logs_dir(app)?.join(format!("{}.log", key.runtime_id()))) -} - -/// Log path to surface for an agent whose runtime is not tracked in memory: -/// the most recently written of its pair-scoped logs, falling back to the -/// legacy single-runtime path when the agent has not run since harnesses -/// became per (agent, relay) pair. -pub fn latest_managed_agent_log_path(app: &AppHandle, pubkey: &str) -> Result { - match newest_agent_log_in_dir(&managed_agents_logs_dir(app)?, pubkey) { - Some(path) => Ok(path), - None => managed_agent_log_path(app, pubkey), - } -} - -/// Newest log in `dir` belonging to `pubkey` — either a pair-scoped -/// `{pubkey}__{relay_hash}.log` or the legacy `{pubkey}.log`. Ties break -/// toward the higher filename so the choice is deterministic. -fn newest_agent_log_in_dir(dir: &Path, pubkey: &str) -> Option { - let legacy_name = format!("{pubkey}.log"); - let pair_prefix = format!("{pubkey}__"); - fs::read_dir(dir) - .ok()? - .flatten() - .filter_map(|entry| { - let name = entry.file_name(); - let matches = name.to_str().is_some_and(|name| { - name == legacy_name || (name.starts_with(&pair_prefix) && name.ends_with(".log")) - }); - if !matches { - return None; - } - let modified = entry.metadata().ok()?.modified().ok()?; - Some((modified, name, entry.path())) - }) - .max_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))) - .map(|(_, _, path)| path) -} - /// The keyring operations the migration chokepoint needs. Abstracted so the /// migrate-and-strip decision logic ([`migrate_inline_key`]) can be unit-tested /// against a fake without touching the live OS keyring. @@ -191,7 +113,7 @@ enum KeyMigration { /// /// The single source of truth for the migrate-vs-keep decision, shared by the /// load-time opportunistic re-migrate ([`hydrate_keys`]) and the save-time -/// chokepoint ([`persist_agent_keys`]). An empty key returns +/// chokepoint ([`persist_agent_keys_with`]). An empty key returns /// [`KeyMigration::Nothing`] — never [`KeyMigration::Persisted`], so a record /// left empty by a keyring outage is not mistaken for one verified present. fn migrate_inline_key(store: &impl KeyStore, record: &ManagedAgentRecord) -> KeyMigration { @@ -224,26 +146,67 @@ fn migrate_inline_key(store: &impl KeyStore, record: &ManagedAgentRecord) -> Key /// deliberately keyless agent. Spawning anyway would inject an empty /// `BUZZ_PRIVATE_KEY`/`NOSTR_PRIVATE_KEY`, launching with no identity. Callers /// (the spawn path) must fail closed (Wes storage.rs:158). +/// +/// Also refuses when `record.secrets_unavailable` is set — at least one secret +/// field (env_vars, auth_tag, provider_config) has a keyring ref that points to +/// a missing or unreachable entry. Spawning with silently empty secrets is the +/// exact failure mode the gen-ref protocol exists to prevent. pub(crate) fn spawn_key_refusal(record: &ManagedAgentRecord) -> Option { - record.private_key_nsec.is_empty().then(|| { - format!( + if record.private_key_nsec.is_empty() { + return Some(format!( "agent {} has no private key available — the OS keyring may be unreachable. \ Refusing to start without an identity; retry once the keyring is reachable.", record.pubkey - ) - }) + )); + } + if record.secrets_unavailable { + return Some(format!( + "agent {} has one or more secrets (env vars, auth tag, or provider config) \ + that could not be loaded from the keyring. Refusing to start with missing \ + secrets; retry once the keyring is reachable.", + record.pubkey + )); + } + None +} + +/// The linked definition's id when that definition's secrets are unavailable — +/// its `env_vars_ref` is present but could not be hydrated from the keyring. +/// +/// This is the definition tier of the fail-closed spawn gate, alongside +/// [`spawn_key_refusal`]'s instance-tier `secrets_unavailable` check and the +/// global-tier check in `spawn_agent_child`. Returning the offending id (rather +/// than a bool) lets the spawn path name it in the refusal message without a +/// second lookup; status callers use `.is_some()`. An unlinked record, or one +/// whose linked definition is absent from `personas`, yields `None`. +pub(crate) fn unavailable_definition_id<'a>( + record: &'a ManagedAgentRecord, + personas: &[AgentDefinition], +) -> Option<&'a str> { + let pid = record.persona_id.as_deref()?; + personas + .iter() + .find(|p| p.id == pid) + .filter(|d| d.secrets_unavailable) + .map(|_| pid) } /// Read the raw unified store — keyed instances AND key-less definitions — /// with fail-loud parse handling. Internal seam; public readers filter. fn load_agent_store(app: &AppHandle) -> Result, String> { - let path = managed_agents_store_path(app)?; + load_agent_store_at(&managed_agents_store_path(app)?) +} + +/// Path-based core of [`load_agent_store`]: reads and parses the raw store at +/// `path`. Split out so the save path and tests can drive the load/split/write +/// merge against a tempdir without an `AppHandle`. +fn load_agent_store_at(path: &Path) -> Result, String> { if !path.exists() { return Ok(Vec::new()); } - let content = fs::read_to_string(&path) - .map_err(|error| format!("failed to read agent store: {error}"))?; + let content = + fs::read_to_string(path).map_err(|error| format!("failed to read agent store: {error}"))?; serde_json::from_str(&content).map_err(|error| { // Fail loudly and preserve the evidence: a later in-app save rewrites // this file wholesale, which would silently destroy a malformed hand @@ -251,7 +214,7 @@ fn load_agent_store(app: &AppHandle) -> Result, String> // reconcile): the broken content survives as `.invalid` for the user // to recover, and the parse error propagates instead of being // swallowed into an empty store. - backup_invalid_store(&path); + backup_invalid_store(path); format!("failed to parse agent store (preserved as .invalid): {error}") }) } @@ -263,6 +226,14 @@ pub fn load_managed_agents(app: &AppHandle) -> Result, S let mut records = load_agent_store(app)?; records.retain(|record| !record.pubkey.is_empty()); hydrate_keys(&mut records); + // Hydrate env/auth_tag/provider_config from keyring. + if let Some(store) = agent_secret_store() { + let _ = hydrate_all_secrets_for_records(store, &mut records); + // Unavailability is set directly on each record (`secrets_unavailable`); + // the returned Vec is a secondary summary, not needed here. The spawn + // path consults `spawn_key_refusal`, which checks `secrets_unavailable` + // and refuses to start any agent whose secrets could not be loaded. + } Ok(records) } @@ -272,6 +243,10 @@ pub fn load_managed_agents(app: &AppHandle) -> Result, S pub(crate) fn load_agent_definitions(app: &AppHandle) -> Result, String> { let mut records = load_agent_store(app)?; records.retain(|record| record.pubkey.is_empty()); + // Hydrate definition env_vars from keyring. + if let Some(store) = agent_secret_store() { + let _ = hydrate_all_secrets_for_records(store, &mut records); + } Ok(records) } @@ -361,10 +336,64 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) /// before the wholesale rewrite so a definition is never dropped by an /// instance-side save (and vice versa via [`save_agent_definitions`]). pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> { - let definitions = load_agent_definitions(app).unwrap_or_default(); + save_managed_agents_locked_at( + &managed_agents_store_path(app)?, + agent_secret_store(), + records, + ) +} + +/// Lock-owning path-based entry point for the instance-side save: acquires the +/// cross-process secret transaction lock on the store directory, then runs the +/// definition-preserving [`save_managed_agents_at`] under it. The lock and the +/// mutation it protects live in ONE seam — the same one the interleave test +/// drives — so the wiring is provable: delete the acquisition here and the +/// concurrent-save regression stops observing exclusion. See +/// [`acquire_secret_txn_lock`] for the lock span + residual. +fn save_managed_agents_locked_at( + store_path: &Path, + store: Option<&S>, + records: &[ManagedAgentRecord], +) -> Result<(), String> +where + S: KeyStore + crate::managed_agents::secret_projection::ProjectionStore, +{ + // Lock FIRST — the definition half re-read inside `save_managed_agents_at` + // must be under the lock (Race 1). + let _txn = crate::secret_store::transaction_lock_at(&crate::secret_store::store_txn_lock_dir( + store_path, + ))?; + save_managed_agents_at(store_path, store, records) +} + +/// Path-based core of [`save_managed_agents`]: merges the caller's instance +/// records with the definition half re-read from disk and commits the unified +/// store. Split out so the definition-preservation contract can be exercised +/// over a tempdir without an `AppHandle`. +/// +/// The definition half is re-read RAW ([`load_agent_store_at`] + retain), NOT +/// through the hydrating `load_agent_definitions`: a hydrated definition holds +/// its `env_vars` inline, so writing it back would re-inline the definition's +/// provider secrets into plaintext JSON on every instance-side save — the exact +/// regression the projection protocol exists to prevent — and would create the +/// inline+ref conflict that freezes GC. A raw definition is already stripped on +/// disk, so it needs no strip pass. The parse error propagates with `?` instead +/// of collapsing into an empty definition half: the wholesale rewrite below +/// would otherwise delete every definition from the live store. +fn save_managed_agents_at( + store_path: &Path, + store: Option<&S>, + records: &[ManagedAgentRecord], +) -> Result<(), String> +where + S: KeyStore + crate::managed_agents::secret_projection::ProjectionStore, +{ + let mut definitions = load_agent_store_at(store_path)?; + definitions.retain(|record| record.pubkey.is_empty()); + let mut sorted = records.to_vec(); // A caller-supplied key-less record would collide with the definition - // half re-read below; instances always carry a pubkey. + // half re-read above; instances always carry a pubkey. sorted.retain(|record| !record.pubkey.is_empty()); sorted.sort_by(|left, right| { left.name @@ -373,12 +402,15 @@ pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> R .then_with(|| left.pubkey.cmp(&right.pubkey)) }); - // Persist each key to the keyring; on success blank the inline copy so it - // is skipped from JSON (`skip_serializing_if = "String::is_empty"`). If the - // keyring is unreachable, the key stays inline. - persist_agent_keys(&mut sorted); + if let Some(store) = store { + // Persist each nsec to the keyring; on success blank the inline copy. + persist_agent_keys_with(store, &mut sorted); + // Persist env/auth_tag/provider_config to the keyring; on success blank + // inline values and set *_ref. On failure keep inline and clear *_ref. + strip_and_persist_all_for_records(store, &mut sorted); + } - write_agent_store(app, definitions, sorted) + write_agent_store_at(store_path, definitions, sorted) } /// Save the key-less agent *definitions*, preserving the keyed instances — @@ -387,18 +419,72 @@ pub(crate) fn save_agent_definitions( app: &AppHandle, definitions: &[ManagedAgentRecord], ) -> Result<(), String> { - let mut instances = load_agent_store(app)?; + save_agent_definitions_locked_at( + &managed_agents_store_path(app)?, + agent_secret_store(), + definitions, + ) +} + +/// Lock-owning path-based entry point for the definition-side save — the mirror +/// of [`save_managed_agents_locked_at`]. Acquires the cross-process secret +/// transaction lock on the store directory, then runs [`save_agent_definitions_at`] +/// under it, so the lock and the instance-half re-read it protects are one seam +/// the interleave test drives directly. +fn save_agent_definitions_locked_at( + store_path: &Path, + store: Option<&S>, + definitions: &[ManagedAgentRecord], +) -> Result<(), String> +where + S: crate::managed_agents::secret_projection::ProjectionStore, +{ + // Lock FIRST — the instance half re-read inside `save_agent_definitions_at` + // must be under the lock (Race 1); mirror of `save_managed_agents`. + let _txn = crate::secret_store::transaction_lock_at(&crate::secret_store::store_txn_lock_dir( + store_path, + ))?; + save_agent_definitions_at(store_path, store, definitions) +} + +/// Path-based core of [`save_agent_definitions`]: merges the caller's definition +/// records with the instance half re-read from disk and commits the unified +/// store — the definition-side mirror of [`save_managed_agents_at`]. Split out +/// so the instance-preservation contract can be exercised over a tempdir +/// without an `AppHandle`. +/// +/// The instance half is re-read RAW ([`load_agent_store_at`] + retain): a +/// definition-side save must never drop a concurrently-committed instance, and +/// the raw records already carry their secrets as `*_ref` (keys in the keyring, +/// inline blanked), so writing them back cannot re-inline anything. The parse +/// error propagates with `?` rather than collapsing into an empty instance half +/// — the wholesale rewrite below would otherwise delete every instance. +fn save_agent_definitions_at( + store_path: &Path, + store: Option<&S>, + definitions: &[ManagedAgentRecord], +) -> Result<(), String> +where + S: crate::managed_agents::secret_projection::ProjectionStore, +{ + let mut instances = load_agent_store_at(store_path)?; instances.retain(|record| !record.pubkey.is_empty()); let mut definitions = definitions.to_vec(); definitions.retain(|record| record.pubkey.is_empty()); - write_agent_store(app, definitions, instances) + + // Persist definition env_vars to the keyring before writing JSON. + if let Some(store) = store { + strip_and_persist_all_for_records(store, &mut definitions); + } + + write_agent_store_at(store_path, definitions, instances) } /// Serialize definitions + instances into the single unified store file. /// Definitions sort first (by slug) for stable diffs; instances keep the /// name/pubkey order their save path established. -fn write_agent_store( - app: &AppHandle, +fn write_agent_store_at( + path: &Path, mut definitions: Vec, instances: Vec, ) -> Result<(), String> { @@ -406,7 +492,6 @@ fn write_agent_store( let mut all = definitions; all.extend(instances); - let path = managed_agents_store_path(app)?; let payload = serde_json::to_vec_pretty(&all) .map_err(|error| format!("failed to serialize agent store: {error}"))?; @@ -414,22 +499,13 @@ fn write_agent_store( // fallback. Write it owner-only (`0o600`) unconditionally — harmless for the // keyring-backed case (it is the user's own agent store) and closes the // umask window a post-write `chmod` would leave open. - atomic_write_json_restricted(&path, &payload) + atomic_write_json_restricted(path, &payload) } /// Write each record's in-memory key to the keyring and blank the inline copy /// on success. Keys that cannot be persisted (keyring unreachable) stay inline /// in the JSON. Mutates `records` (a save-local clone) — the caller's in-memory /// records keep their keys. -fn persist_agent_keys(records: &mut [ManagedAgentRecord]) { - let Some(store) = agent_secret_store() else { - // No keyring backend: keys stay inline. - return; - }; - persist_agent_keys_with(store, records); -} - -/// Testable core of [`persist_agent_keys`], generic over the [`KeyStore`] seam. fn persist_agent_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) { for record in records.iter_mut() { // Only a verified keyring entry lets us drop the inline copy. Both @@ -595,6 +671,64 @@ pub fn delete_agent_key(pubkey: &str) { } } +// ── Migration-seam helpers (pub(crate) for use by migration.rs) ─────────── + +/// Load the raw unified store (instances + definitions) without any keyring +/// hydration. Used by the boot migration to read inline secrets before +/// extracting them into the keyring. After extraction, call +/// [`write_agent_store_raw`] to persist the updated records. +pub(crate) fn load_agent_store_raw(app: &AppHandle) -> Result, String> { + load_agent_store(app) +} + +/// Write the raw unified store (instances + definitions) without any keyring +/// strip step. Used by the boot migration after inline secrets have been +/// extracted into the keyring and the `*_ref` fields set accordingly. +pub(crate) fn write_agent_store_raw( + app: &AppHandle, + records: &[ManagedAgentRecord], +) -> Result<(), String> { + let path = managed_agents_store_path(app)?; + let payload = serde_json::to_vec_pretty(records) + .map_err(|e| format!("failed to serialize agent store: {e}"))?; + atomic_write_json_restricted(&path, &payload) +} + +/// Return the shared secret store used for agent secrets, or `None` when the +/// build has no keyring backend. Exposed as `pub(crate)` so `migration.rs` +/// can run GC sweeps and the secret-migration function against the same store +/// instance used by the normal save/load path. +pub(crate) fn agent_secret_store_pub() -> Option<&'static crate::secret_store::SecretStore> { + agent_secret_store() +} + +/// Acquire the cross-process secret transaction lock for the agent store, or +/// `Ok(None)` on a keyless build. A save and GC hold it for their full span so +/// two Desktop processes cannot interleave. Keyed by the resolved store dir +/// ([`crate::secret_store::store_txn_lock_dir`]). Residual (deferred, PR body): +/// the lock makes each `save_*` atomic cross-process but cannot make a caller's +/// pre-lock snapshot transactional — caller races stay last-writer-wins (pre-existing). +pub(crate) fn acquire_secret_txn_lock( + app: &AppHandle, +) -> Result, String> { + if agent_secret_store().is_none() { + return Ok(None); + } + let dir = crate::secret_store::store_txn_lock_dir(&managed_agents_store_path(app)?); + crate::secret_store::transaction_lock_at(&dir).map(Some) +} + +/// Resolve the store-directory lock target the secret transaction lock uses, +/// from the same `managed-agents.json` path [`acquire_secret_txn_lock`] resolves. +/// Exposed so the identity-persist seam ([`crate::commands::identity::persist_identity_locked`]) +/// contends on the exact directory inode every agent save takes — keeping the +/// lock-target resolution in one place. +pub(crate) fn secret_txn_lock_dir(app: &AppHandle) -> Result { + Ok(crate::secret_store::store_txn_lock_dir( + &managed_agents_store_path(app)?, + )) +} + /// Atomic, symlink-preserving JSON write. /// Resolves symlinks so the tmp+rename happens at the real target path, /// preserving any symlink at `path`. @@ -634,276 +768,6 @@ pub(crate) fn atomic_write_json_restricted(path: &Path, payload: &[u8]) -> Resul .map_err(|e| format!("commit {}: {e}", resolved.display())) } -/// Maximum log file size before rotation (10 MB). -const MAX_LOG_FILE_SIZE: u64 = 10 * 1024 * 1024; - -/// If `path` exceeds [`MAX_LOG_FILE_SIZE`], rotate it to `.1`. -fn maybe_rotate_log(path: &Path) { - let size = match fs::metadata(path) { - Ok(m) => m.len(), - Err(_) => return, - }; - if size <= MAX_LOG_FILE_SIZE { - return; - } - let mut rotated = path.as_os_str().to_owned(); - rotated.push(".1"); - let _ = fs::rename(path, &rotated); -} - -pub(crate) fn open_log_file(path: &Path) -> Result { - maybe_rotate_log(path); - OpenOptions::new() - .create(true) - .append(true) - .open(path) - .map_err(|error| format!("failed to open log file {}: {error}", path.display())) -} - -/// Start a new install-log session at `path`: keep the previous run as -/// `.1` and return a freshly created, empty current file. -/// -/// Rotating per *run* rather than by size is what bounds this file. A run -/// writes one record per executed attempt, each capped by the log-scale -/// capture, so one run's file is bounded by steps × attempts × cap and the -/// history on disk is bounded at two runs. Size-triggered rotation could not -/// promise either: it never replaced an existing `.1`, and on Windows — -/// where rename does not replace its destination — it stopped working -/// altogether once `.1` existed, leaving the current file to grow. -/// -/// The old `.1` is therefore *removed* before the rename rather than renamed -/// over. Every step is best-effort: a rotation that fails must not cost the -/// user the install, so the session continues with a truncated current file. -pub(crate) fn start_install_log_session(path: &Path) -> Result { - if path.exists() { - let mut previous = path.as_os_str().to_owned(); - previous.push(".1"); - let previous = PathBuf::from(previous); - let _ = fs::remove_file(&previous); - let _ = fs::rename(path, &previous); - } - open_install_log(path, /* truncate */ true) -} - -/// Open an install log for appending one more record to the current session. -pub(crate) fn open_install_log_file(path: &Path) -> Result { - open_install_log(path, /* truncate */ false) -} - -/// Open an install log owner-only. -/// -/// The mode is set *in the create* rather than chmod'd afterwards, so the file -/// is never briefly group/world-readable. Install output can carry registry -/// tokens and proxy credentials echoed by a failing installer, so the window -/// matters even though it is short. An existing file's mode is left as-is — -/// `OpenOptions::mode` only applies on creation, and silently re-tightening a -/// file the user relaxed is not this function's call to make. -fn open_install_log(path: &Path, truncate: bool) -> Result { - let mut options = OpenOptions::new(); - options.create(true); - if truncate { - options.write(true).truncate(true); - } else { - options.append(true); - } - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - options - .open(path) - .map_err(|error| format!("failed to open log file {}: {error}", path.display())) -} - -pub(crate) fn append_log_marker(path: &Path, message: &str) -> Result<(), String> { - let mut file = open_log_file(path)?; - writeln!(file, "{message}").map_err(|error| format!("failed to write log marker: {error}")) -} - -fn agent_pids_dir(app: &AppHandle) -> Result { - let dir = managed_agents_base_dir(app)?.join("agent-pids"); - fs::create_dir_all(&dir) - .map_err(|error| format!("failed to create agent-pids dir: {error}"))?; - Ok(dir) -} - -/// Persist a pair-scoped runtime receipt atomically. Callers must register the -/// process in memory in the same runtime transition; on write failure they must -/// terminate the child before releasing that transition. -pub fn write_agent_runtime_receipt( - app: &AppHandle, - receipt: &ManagedAgentRuntimeReceipt, -) -> Result<(), String> { - let path = agent_pids_dir(app)?.join(format!("{}.json", receipt.key.runtime_id())); - let payload = serde_json::to_vec(receipt) - .map_err(|error| format!("failed to serialize runtime receipt: {error}"))?; - atomic_write_json_restricted(&path, &payload) -} - -pub fn remove_agent_runtime_receipt(app: &AppHandle, key: &ManagedAgentRuntimeKey) { - if let Ok(dir) = agent_pids_dir(app) { - let _ = fs::remove_file(dir.join(format!("{}.json", key.runtime_id()))); - } -} - -pub fn remove_agent_runtime_receipt_path(path: &Path) { - let _ = fs::remove_file(path); -} - -pub fn read_all_agent_runtime_receipts( - app: &AppHandle, -) -> Vec<(PathBuf, ManagedAgentRuntimeReceipt)> { - let Ok(dir) = agent_pids_dir(app) else { - return Vec::new(); - }; - let Ok(entries) = fs::read_dir(dir) else { - return Vec::new(); - }; - entries - .flatten() - .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json")) - .filter_map(|entry| { - let path = entry.path(); - let bytes = fs::read(&path).ok()?; - serde_json::from_slice(&bytes) - .ok() - .map(|receipt| (path, receipt)) - }) - .collect() -} - -/// Remove the PID file for an agent (e.g. on normal stop). -pub fn remove_agent_pid_file(app: &AppHandle, pubkey: &str) { - if let Ok(dir) = agent_pids_dir(app) { - let _ = fs::remove_file(dir.join(format!("{pubkey}.pid"))); - } -} - -/// Read all PID files from `agent-pids/`, returning `(pubkey, pid)` pairs. -pub fn read_all_agent_pid_files(app: &AppHandle) -> Vec<(String, u32)> { - let Ok(dir) = agent_pids_dir(app) else { - return Vec::new(); - }; - let Ok(entries) = fs::read_dir(&dir) else { - return Vec::new(); - }; - entries - .flatten() - .filter_map(|entry| { - let name = entry.file_name(); - let name = name.to_str()?; - let pubkey = name.strip_suffix(".pid")?; - let pid: u32 = fs::read_to_string(entry.path()).ok()?.trim().parse().ok()?; - Some((pubkey.to_string(), pid)) - }) - .collect() -} - -pub fn read_log_tail(path: &Path, max_lines: usize) -> Result { - if !path.exists() { - return Ok(String::new()); - } - - let mut file = File::open(path) - .map_err(|error| format!("failed to read log file {}: {error}", path.display()))?; - - let file_len = file - .seek(SeekFrom::End(0)) - .map_err(|error| format!("failed to seek log file: {error}"))?; - - if file_len == 0 { - return Ok(String::new()); - } - - // Read backward in chunks to find enough newlines. - const CHUNK_SIZE: u64 = 8 * 1024; - let mut buf = Vec::new(); - let mut remaining = file_len; - let mut newline_count: usize = 0; - // We need max_lines + 1 newlines to delimit max_lines lines (the trailing - // newline of the last line counts as one). - let target_newlines = max_lines + 1; - - while remaining > 0 && newline_count < target_newlines { - let chunk = remaining.min(CHUNK_SIZE); - remaining -= chunk; - file.seek(SeekFrom::Start(remaining)) - .map_err(|error| format!("failed to seek log file: {error}"))?; - - let mut tmp = vec![0u8; chunk as usize]; - file.read_exact(&mut tmp) - .map_err(|error| format!("failed to read log chunk: {error}"))?; - - // Prepend this chunk so buf always has the tail of the file. - tmp.append(&mut buf); - buf = tmp; - - newline_count = bytecount_newlines(&buf); - } - - // Strip ANSI escapes here (not in the harness) so the desktop log view - // renders cleanly while terminals and other tools still get the colors - // buzz-acp emits. - let cleaned = strip_ansi_escapes::strip_str(String::from_utf8_lossy(&buf)); - let lines: Vec<&str> = cleaned.lines().collect(); - let start = lines.len().saturating_sub(max_lines); - Ok(lines[start..].join("\n")) -} - -fn bytecount_newlines(buf: &[u8]) -> usize { - buf.iter().filter(|&&b| b == b'\n').count() -} - -/// A meaningful error recovered from an exited agent's log tail. -pub struct AgentLogError { - /// The full log line, wrapped as `Agent reported error…` for display. - pub message: String, - /// JSON-RPC error code parsed from the line's `(code N)` marker, or a - /// synthetic code for known bare prefixes. `None` for legacy-format - /// lines that carry no code (or when the code fails to parse as i64). - pub code: Option, -} - -pub fn meaningful_agent_error_from_log(path: &Path) -> Option { - let tail = read_log_tail(path, 200).ok()?; - tail.lines().rev().map(str::trim).find_map(|line| { - // New format: "Agent reported error (code -32002): ..." - if let Some(rest) = line.strip_prefix("Agent reported error (code ") { - if let Some(paren_end) = rest.find("): ") { - let code = rest[..paren_end].parse::().ok(); - return Some(AgentLogError { - message: line.to_string(), - code, - }); - } - } - // Legacy format (older buzz-acp builds): "Agent reported error: ..." - if line.starts_with("Agent reported error:") { - return Some(AgentLogError { - message: line.to_string(), - code: None, - }); - } - // Bare prefixes emitted by older agent binaries whose Display still leaks - // unwrapped errors. Promote these so they surface instead of the generic - // "harness exited with status N" fallback. - if line.starts_with("llm auth:") { - return Some(AgentLogError { - message: format!("Agent reported error: {line}"), - code: Some(-32001), - }); - } - if line.starts_with("llm model not found:") { - return Some(AgentLogError { - message: format!("Agent reported error: {line}"), - code: Some(-32002), - }); - } - None - }) -} - #[cfg(test)] #[path = "storage_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/storage/logs.rs b/desktop/src-tauri/src/managed_agents/storage/logs.rs new file mode 100644 index 00000000000..a1d8e9b3348 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/storage/logs.rs @@ -0,0 +1,374 @@ +//! Agent log files, install logs, and runtime receipt/PID persistence. +//! +//! Split out of `storage.rs` to keep that file under the size gate. These are +//! the filesystem helpers for the managed-agents `logs/` and `agent-pids/` +//! directories: log-path resolution, size- and run-based rotation, owner-only +//! install logs, runtime receipts, PID files, and log-tail error extraction. + +use std::{ + fs::{self, File, OpenOptions}, + io::{Read as _, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, +}; + +use tauri::AppHandle; + +use crate::managed_agents::{ManagedAgentRuntimeKey, ManagedAgentRuntimeReceipt}; + +use super::{atomic_write_json_restricted, managed_agents_base_dir}; + +fn managed_agents_logs_dir(app: &AppHandle) -> Result { + let dir = managed_agents_base_dir(app)?.join("logs"); + fs::create_dir_all(&dir).map_err(|error| format!("failed to create logs dir: {error}"))?; + Ok(dir) +} + +/// Install-log path for `runtime_id`, alongside the agent logs. +pub fn install_log_path(app: &AppHandle, runtime_id: &str) -> Result { + Ok(managed_agents_logs_dir(app)?.join(install_log_filename(runtime_id)?)) +} + +/// Filename for a runtime's install log, or an error for an id that must not +/// become one. +/// +/// The id is validated rather than trusted: ids reach this from user-defined +/// custom harnesses as well as the catalog, and a `../` or a separator in one +/// would place the log outside the logs directory. Rejecting beats sanitizing — +/// a rejected id means no log, while a rewritten one could collide with another +/// runtime's. +fn install_log_filename(runtime_id: &str) -> Result { + if runtime_id.is_empty() || !runtime_id.chars().all(is_safe_id_char) { + return Err(format!( + "unsafe runtime id for a log filename: {runtime_id}" + )); + } + Ok(format!("install-{runtime_id}.log")) +} + +/// Characters allowed in a runtime id used as a filename. Excludes `/`, `\`, +/// `:` and `.`, so no id can traverse or escape the logs directory. +fn is_safe_id_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '-' || c == '_' +} + +pub fn managed_agent_log_path(app: &AppHandle, pubkey: &str) -> Result { + Ok(managed_agents_logs_dir(app)?.join(format!("{pubkey}.log"))) +} + +/// Pair-scoped log path for a managed runtime. The relay URL never appears in +/// the filename; the suffix is a hash of the canonical URL. +pub fn managed_agent_runtime_log_path( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, +) -> Result { + Ok(managed_agents_logs_dir(app)?.join(format!("{}.log", key.runtime_id()))) +} + +/// Log path to surface for an agent whose runtime is not tracked in memory: +/// the most recently written of its pair-scoped logs, falling back to the +/// legacy single-runtime path when the agent has not run since harnesses +/// became per (agent, relay) pair. +pub fn latest_managed_agent_log_path(app: &AppHandle, pubkey: &str) -> Result { + match newest_agent_log_in_dir(&managed_agents_logs_dir(app)?, pubkey) { + Some(path) => Ok(path), + None => managed_agent_log_path(app, pubkey), + } +} + +/// Newest log in `dir` belonging to `pubkey` — either a pair-scoped +/// `{pubkey}__{relay_hash}.log` or the legacy `{pubkey}.log`. Ties break +/// toward the higher filename so the choice is deterministic. +fn newest_agent_log_in_dir(dir: &Path, pubkey: &str) -> Option { + let legacy_name = format!("{pubkey}.log"); + let pair_prefix = format!("{pubkey}__"); + fs::read_dir(dir) + .ok()? + .flatten() + .filter_map(|entry| { + let name = entry.file_name(); + let matches = name.to_str().is_some_and(|name| { + name == legacy_name || (name.starts_with(&pair_prefix) && name.ends_with(".log")) + }); + if !matches { + return None; + } + let modified = entry.metadata().ok()?.modified().ok()?; + Some((modified, name, entry.path())) + }) + .max_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))) + .map(|(_, _, path)| path) +} + +/// Maximum log file size before rotation (10 MB). +const MAX_LOG_FILE_SIZE: u64 = 10 * 1024 * 1024; + +/// If `path` exceeds [`MAX_LOG_FILE_SIZE`], rotate it to `.1`. +fn maybe_rotate_log(path: &Path) { + let size = match fs::metadata(path) { + Ok(m) => m.len(), + Err(_) => return, + }; + if size <= MAX_LOG_FILE_SIZE { + return; + } + let mut rotated = path.as_os_str().to_owned(); + rotated.push(".1"); + let _ = fs::rename(path, &rotated); +} + +pub(crate) fn open_log_file(path: &Path) -> Result { + maybe_rotate_log(path); + OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|error| format!("failed to open log file {}: {error}", path.display())) +} + +/// Start a new install-log session at `path`: keep the previous run as +/// `.1` and return a freshly created, empty current file. +/// +/// Rotating per *run* rather than by size is what bounds this file. A run +/// writes one record per executed attempt, each capped by the log-scale +/// capture, so one run's file is bounded by steps × attempts × cap and the +/// history on disk is bounded at two runs. Size-triggered rotation could not +/// promise either: it never replaced an existing `.1`, and on Windows — +/// where rename does not replace its destination — it stopped working +/// altogether once `.1` existed, leaving the current file to grow. +/// +/// The old `.1` is therefore *removed* before the rename rather than renamed +/// over. Every step is best-effort: a rotation that fails must not cost the +/// user the install, so the session continues with a truncated current file. +pub(crate) fn start_install_log_session(path: &Path) -> Result { + if path.exists() { + let mut previous = path.as_os_str().to_owned(); + previous.push(".1"); + let previous = PathBuf::from(previous); + let _ = fs::remove_file(&previous); + let _ = fs::rename(path, &previous); + } + open_install_log(path, /* truncate */ true) +} + +/// Open an install log for appending one more record to the current session. +pub(crate) fn open_install_log_file(path: &Path) -> Result { + open_install_log(path, /* truncate */ false) +} + +/// Open an install log owner-only. +/// +/// The mode is set *in the create* rather than chmod'd afterwards, so the file +/// is never briefly group/world-readable. Install output can carry registry +/// tokens and proxy credentials echoed by a failing installer, so the window +/// matters even though it is short. An existing file's mode is left as-is — +/// `OpenOptions::mode` only applies on creation, and silently re-tightening a +/// file the user relaxed is not this function's call to make. +fn open_install_log(path: &Path, truncate: bool) -> Result { + let mut options = OpenOptions::new(); + options.create(true); + if truncate { + options.write(true).truncate(true); + } else { + options.append(true); + } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options + .open(path) + .map_err(|error| format!("failed to open log file {}: {error}", path.display())) +} + +pub(crate) fn append_log_marker(path: &Path, message: &str) -> Result<(), String> { + let mut file = open_log_file(path)?; + writeln!(file, "{message}").map_err(|error| format!("failed to write log marker: {error}")) +} + +fn agent_pids_dir(app: &AppHandle) -> Result { + let dir = managed_agents_base_dir(app)?.join("agent-pids"); + fs::create_dir_all(&dir) + .map_err(|error| format!("failed to create agent-pids dir: {error}"))?; + Ok(dir) +} + +/// Persist a pair-scoped runtime receipt atomically. Callers must register the +/// process in memory in the same runtime transition; on write failure they must +/// terminate the child before releasing that transition. +pub fn write_agent_runtime_receipt( + app: &AppHandle, + receipt: &ManagedAgentRuntimeReceipt, +) -> Result<(), String> { + let path = agent_pids_dir(app)?.join(format!("{}.json", receipt.key.runtime_id())); + let payload = serde_json::to_vec(receipt) + .map_err(|error| format!("failed to serialize runtime receipt: {error}"))?; + atomic_write_json_restricted(&path, &payload) +} + +pub fn remove_agent_runtime_receipt(app: &AppHandle, key: &ManagedAgentRuntimeKey) { + if let Ok(dir) = agent_pids_dir(app) { + let _ = fs::remove_file(dir.join(format!("{}.json", key.runtime_id()))); + } +} + +pub fn remove_agent_runtime_receipt_path(path: &Path) { + let _ = fs::remove_file(path); +} + +pub fn read_all_agent_runtime_receipts( + app: &AppHandle, +) -> Vec<(PathBuf, ManagedAgentRuntimeReceipt)> { + let Ok(dir) = agent_pids_dir(app) else { + return Vec::new(); + }; + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json")) + .filter_map(|entry| { + let path = entry.path(); + let bytes = fs::read(&path).ok()?; + serde_json::from_slice(&bytes) + .ok() + .map(|receipt| (path, receipt)) + }) + .collect() +} + +/// Remove the PID file for an agent (e.g. on normal stop). +pub fn remove_agent_pid_file(app: &AppHandle, pubkey: &str) { + if let Ok(dir) = agent_pids_dir(app) { + let _ = fs::remove_file(dir.join(format!("{pubkey}.pid"))); + } +} + +/// Read all PID files from `agent-pids/`, returning `(pubkey, pid)` pairs. +pub fn read_all_agent_pid_files(app: &AppHandle) -> Vec<(String, u32)> { + let Ok(dir) = agent_pids_dir(app) else { + return Vec::new(); + }; + let Ok(entries) = fs::read_dir(&dir) else { + return Vec::new(); + }; + entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name(); + let name = name.to_str()?; + let pubkey = name.strip_suffix(".pid")?; + let pid: u32 = fs::read_to_string(entry.path()).ok()?.trim().parse().ok()?; + Some((pubkey.to_string(), pid)) + }) + .collect() +} + +pub fn read_log_tail(path: &Path, max_lines: usize) -> Result { + if !path.exists() { + return Ok(String::new()); + } + + let mut file = File::open(path) + .map_err(|error| format!("failed to read log file {}: {error}", path.display()))?; + + let file_len = file + .seek(SeekFrom::End(0)) + .map_err(|error| format!("failed to seek log file: {error}"))?; + + if file_len == 0 { + return Ok(String::new()); + } + + // Read backward in chunks to find enough newlines. + const CHUNK_SIZE: u64 = 8 * 1024; + let mut buf = Vec::new(); + let mut remaining = file_len; + let mut newline_count: usize = 0; + // We need max_lines + 1 newlines to delimit max_lines lines (the trailing + // newline of the last line counts as one). + let target_newlines = max_lines + 1; + + while remaining > 0 && newline_count < target_newlines { + let chunk = remaining.min(CHUNK_SIZE); + remaining -= chunk; + file.seek(SeekFrom::Start(remaining)) + .map_err(|error| format!("failed to seek log file: {error}"))?; + + let mut tmp = vec![0u8; chunk as usize]; + file.read_exact(&mut tmp) + .map_err(|error| format!("failed to read log chunk: {error}"))?; + + // Prepend this chunk so buf always has the tail of the file. + tmp.append(&mut buf); + buf = tmp; + + newline_count = bytecount_newlines(&buf); + } + + // Strip ANSI escapes here (not in the harness) so the desktop log view + // renders cleanly while terminals and other tools still get the colors + // buzz-acp emits. + let cleaned = strip_ansi_escapes::strip_str(String::from_utf8_lossy(&buf)); + let lines: Vec<&str> = cleaned.lines().collect(); + let start = lines.len().saturating_sub(max_lines); + Ok(lines[start..].join("\n")) +} + +fn bytecount_newlines(buf: &[u8]) -> usize { + buf.iter().filter(|&&b| b == b'\n').count() +} + +/// A meaningful error recovered from an exited agent's log tail. +pub struct AgentLogError { + /// The full log line, wrapped as `Agent reported error…` for display. + pub message: String, + /// JSON-RPC error code parsed from the line's `(code N)` marker, or a + /// synthetic code for known bare prefixes. `None` for legacy-format + /// lines that carry no code (or when the code fails to parse as i64). + pub code: Option, +} + +pub fn meaningful_agent_error_from_log(path: &Path) -> Option { + let tail = read_log_tail(path, 200).ok()?; + tail.lines().rev().map(str::trim).find_map(|line| { + // New format: "Agent reported error (code -32002): ..." + if let Some(rest) = line.strip_prefix("Agent reported error (code ") { + if let Some(paren_end) = rest.find("): ") { + let code = rest[..paren_end].parse::().ok(); + return Some(AgentLogError { + message: line.to_string(), + code, + }); + } + } + // Legacy format (older buzz-acp builds): "Agent reported error: ..." + if line.starts_with("Agent reported error:") { + return Some(AgentLogError { + message: line.to_string(), + code: None, + }); + } + // Bare prefixes emitted by older agent binaries whose Display still leaks + // unwrapped errors. Promote these so they surface instead of the generic + // "harness exited with status N" fallback. + if line.starts_with("llm auth:") { + return Some(AgentLogError { + message: format!("Agent reported error: {line}"), + code: Some(-32001), + }); + } + if line.starts_with("llm model not found:") { + return Some(AgentLogError { + message: format!("Agent reported error: {line}"), + code: Some(-32002), + }); + } + None + }) +} + +#[cfg(test)] +#[path = "logs_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/storage/logs_tests.rs b/desktop/src-tauri/src/managed_agents/storage/logs_tests.rs new file mode 100644 index 00000000000..cff48e067b1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/storage/logs_tests.rs @@ -0,0 +1,302 @@ +//! Unit tests for `managed_agents/storage/logs.rs`. +//! +//! Kept in a sibling file so `logs.rs` stays closer to the 1000-line gate; +//! `#[path]`-included from there. + +use std::fs::File; +use std::io::Write as _; +use std::path::Path; + +use tempfile::NamedTempFile; + +fn write_log(content: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().expect("temp log"); + file.write_all(content.as_bytes()).expect("write log"); + file +} +#[test] +fn meaningful_agent_error_from_log_promotes_wrapped_llm_auth() { + let file = + write_log("noise\nAgent reported error (code -32001): llm auth: 401 unauthorized: ...\n"); + let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert!(result.message.contains("llm auth")); + assert_eq!(result.code, Some(-32001)); +} + +#[test] +fn meaningful_agent_error_from_log_promotes_unwrapped_llm_auth() { + let file = write_log("noise\nllm auth: denied\n"); + let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert_eq!(result.message, "Agent reported error: llm auth: denied"); + assert_eq!(result.code, Some(-32001)); +} + +#[test] +fn meaningful_agent_error_from_log_promotes_bare_model_not_found() { + let file = write_log("noise\nllm model not found: (some-model) 404\n"); + let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert_eq!( + result.message, + "Agent reported error: llm model not found: (some-model) 404" + ); + assert_eq!(result.code, Some(-32002)); +} + +#[test] +fn meaningful_agent_error_from_log_promotes_legacy_format() { + let file = write_log("noise\nAgent reported error: llm: 500 internal\n"); + let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert_eq!(result.message, "Agent reported error: llm: 500 internal"); + assert_eq!(result.code, None); +} + +#[test] +fn meaningful_agent_error_from_log_does_not_promote_midline_auth_text() { + let file = write_log("noise before llm auth: denied\n"); + assert!(super::meaningful_agent_error_from_log(file.path()).is_none()); +} + +#[test] +fn strips_ansi_from_typical_tracing_line() { + let input = "\x1b[2m2026-05-27T15:16:32\x1b[0m \x1b[32m INFO\x1b[0m \x1b[2mbuzz_acp\x1b[0m\x1b[2m:\x1b[0m starting"; + assert_eq!( + strip_ansi_escapes::strip_str(input), + "2026-05-27T15:16:32 INFO buzz_acp: starting" + ); +} +// ── harness-log selection tests ──────────────────────────────────────── + +const PUBKEY_A: &str = "aa11223344556677889900aabbccddeeff00112233445566778899aabbccddee"; +const PUBKEY_B: &str = "bb11223344556677889900aabbccddeeff00112233445566778899aabbccddee"; + +/// Write `name` into `dir` and stamp it `age_secs` before now, so selection +/// order is asserted against explicit mtimes rather than write order. +fn write_log_in(dir: &Path, name: &str, age_secs: u64) { + let path = dir.join(name); + let file = File::create(&path).expect("create log"); + file.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(age_secs)) + .expect("stamp mtime"); +} + +#[test] +fn newest_agent_log_prefers_pair_scoped_when_it_is_freshest() { + let dir = tempfile::tempdir().expect("temp dir"); + write_log_in(dir.path(), &format!("{PUBKEY_A}.log"), 600); + write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log"), 5); + + assert_eq!( + super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), + Some(dir.path().join(format!("{PUBKEY_A}__cafe.log"))) + ); +} + +#[test] +fn newest_agent_log_prefers_legacy_when_it_is_freshest() { + let dir = tempfile::tempdir().expect("temp dir"); + write_log_in(dir.path(), &format!("{PUBKEY_A}.log"), 5); + write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log"), 600); + + assert_eq!( + super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), + Some(dir.path().join(format!("{PUBKEY_A}.log"))), + "mtime decides, not the filename shape" + ); +} + +#[test] +fn newest_agent_log_finds_sole_pair_scoped_log() { + let dir = tempfile::tempdir().expect("temp dir"); + write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log"), 5); + + assert_eq!( + super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), + Some(dir.path().join(format!("{PUBKEY_A}__cafe.log"))) + ); +} + +#[test] +fn newest_agent_log_picks_freshest_of_several_relays() { + let dir = tempfile::tempdir().expect("temp dir"); + write_log_in(dir.path(), &format!("{PUBKEY_A}__aaa.log"), 900); + write_log_in(dir.path(), &format!("{PUBKEY_A}__bbb.log"), 5); + write_log_in(dir.path(), &format!("{PUBKEY_A}__ccc.log"), 300); + + assert_eq!( + super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), + Some(dir.path().join(format!("{PUBKEY_A}__bbb.log"))) + ); +} + +#[test] +fn newest_agent_log_ignores_other_agents_and_non_log_files() { + let dir = tempfile::tempdir().expect("temp dir"); + write_log_in(dir.path(), &format!("{PUBKEY_B}__cafe.log"), 1); + write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log.gz"), 2); + write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log"), 600); + + assert_eq!( + super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), + Some(dir.path().join(format!("{PUBKEY_A}__cafe.log"))), + "a fresher log belonging to another agent must never be selected" + ); +} + +#[test] +fn newest_agent_log_is_none_when_agent_has_no_logs() { + let dir = tempfile::tempdir().expect("temp dir"); + write_log_in(dir.path(), &format!("{PUBKEY_B}.log"), 1); + + assert_eq!(super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), None); +} + +#[test] +fn newest_agent_log_is_none_when_dir_is_missing() { + let dir = tempfile::tempdir().expect("temp dir"); + let missing = dir.path().join("absent"); + + assert_eq!(super::newest_agent_log_in_dir(&missing, PUBKEY_A), None); +} + +#[test] +fn newest_agent_log_breaks_mtime_ties_deterministically() { + let dir = tempfile::tempdir().expect("temp dir"); + write_log_in(dir.path(), &format!("{PUBKEY_A}__aaa.log"), 60); + write_log_in(dir.path(), &format!("{PUBKEY_A}__bbb.log"), 60); + + assert_eq!( + super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), + Some(dir.path().join(format!("{PUBKEY_A}__bbb.log"))), + "equal mtimes must resolve to the same file on every read_dir order" + ); +} +// ── install logs ───────────────────────────────────────────────────────────── + +/// Install output can carry registry tokens and proxy credentials a failing +/// installer echoed, and the file is written unattended. `0o600` must come from +/// the create itself: a post-write `chmod` leaves a window where the umask +/// decides, and a crash inside it leaves the log readable to other local users. +#[cfg(unix)] +#[test] +fn install_log_is_created_owner_only_without_post_write_chmod() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("install-goose.log"); + + let mut file = super::open_install_log_file(&path).expect("open install log"); + file.write_all(b"npm ERR!\n").expect("write"); + + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "install logs must be owner-only"); +} + +/// A run starts a new current file and keeps the previous run as `.1`, so the +/// two runs are never mixed and the history on disk stays bounded at two. +#[test] +fn install_log_session_keeps_the_previous_run_as_dot_one() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("install-goose.log"); + + let mut first = super::start_install_log_session(&path).expect("first session"); + first.write_all(b"run-one\n").expect("write"); + let mut second = super::start_install_log_session(&path).expect("second session"); + second.write_all(b"run-two\n").expect("write"); + + assert_eq!( + std::fs::read_to_string(&path).expect("read current"), + "run-two\n", + "the current file must hold only the newest run" + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("install-goose.log.1")).expect("read .1"), + "run-one\n", + "the previous run must be preserved as .1" + ); +} + +/// The third run must still rotate when `.1` already exists. Windows `rename` +/// does not replace its destination, so a rename-only rotation silently stops +/// working here and leaves the current file to grow across every later run — +/// the old `.1` is removed first precisely so this cannot happen. Runs on the +/// Windows target too: this is the path that fails there. +#[test] +fn install_log_session_replaces_an_existing_dot_one() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("install-goose.log"); + let rotated = dir.path().join("install-goose.log.1"); + // Seed the state a rename-only rotation cannot get out of: both files exist. + std::fs::write(&path, b"previous-run\n").expect("seed current"); + std::fs::write(&rotated, b"ancient-run\n").expect("seed .1"); + + let mut file = super::start_install_log_session(&path).expect("session"); + file.write_all(b"fresh-run\n").expect("write"); + + assert_eq!( + std::fs::read_to_string(&path).expect("read current"), + "fresh-run\n", + "the current file must restart even when .1 was already present" + ); + assert_eq!( + std::fs::read_to_string(&rotated).expect("read .1"), + "previous-run\n", + ".1 must be replaced by the run that just ended, not kept" + ); +} + +/// Records written after the session starts append to it — a run's later +/// records must not erase its earlier ones. +#[test] +fn install_log_appends_within_a_session() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("install-goose.log"); + + let mut session = super::start_install_log_session(&path).expect("session"); + session.write_all(b"header\n").expect("write"); + for record in ["first\n", "second\n"] { + let mut file = super::open_install_log_file(&path).expect("open install log"); + file.write_all(record.as_bytes()).expect("write"); + } + + assert_eq!( + std::fs::read_to_string(&path).expect("read back"), + "header\nfirst\nsecond\n" + ); +} + +/// A runtime id becomes part of a filename. Ids reach this from user-defined +/// custom harnesses as well as the catalog, so anything that could traverse or +/// escape the logs directory is rejected rather than sanitized — a rejected id +/// simply means no log, while a silently rewritten one could collide with +/// another runtime's log. +#[test] +fn install_log_filename_rejects_ids_that_would_escape_the_logs_dir() { + for id in [ + "../../etc/passwd", + "goose/../../evil", + "sub/dir", + "back\\slash", + "with.dot", + "", + ] { + assert!( + super::install_log_filename(id).is_err(), + "id {id:?} must not be accepted as a filename component" + ); + } +} + +/// Ordinary catalog and custom-harness ids are accepted — the guard must not +/// reject the ids it exists to serve. +#[test] +fn install_log_filename_accepts_ordinary_runtime_ids() { + for id in ["goose", "claude-code", "buzz_agent", "codex2"] { + assert_eq!( + super::install_log_filename(id).expect("id must be usable in a log filename"), + format!("install-{id}.log") + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/storage_interleave_tests.rs b/desktop/src-tauri/src/managed_agents/storage_interleave_tests.rs new file mode 100644 index 00000000000..a0da44117df --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/storage_interleave_tests.rs @@ -0,0 +1,255 @@ +//! Concurrent save-path interleave test for `managed_agents/storage.rs`. +//! +//! A child of the `tests` module (`#[path]`-included from `storage_tests.rs`) +//! so it reuses that module's `FakeCombinedStore` and `record_with_pubkey_and_key` +//! helpers without duplication, while keeping `storage_tests.rs` under the +//! desktop file-size gate. +//! +//! These tests drive the PRODUCTION lock-owning entry points +//! (`save_managed_agents_locked_at`, `save_agent_definitions_locked_at`) — the +//! seams that own `lock -> raw read -> project -> atomic commit` — rather than +//! hand-building a lock span around the lock-free `*_at` cores. That is the +//! point: the exclusion the test observes is the production seam's own +//! acquisition, so deleting `transaction_lock_at` from either wrapper turns the +//! matching assertion red. + +use super::super::{save_agent_definitions_locked_at, save_managed_agents_locked_at}; +use super::{ + record_with_pubkey_and_key, FakeCombinedStore, KeyStore, KeyringProbe, ManagedAgentRecord, +}; +use crate::managed_agents::secret_projection::ProjectionStore; +use std::collections::HashMap; + +/// A `ProjectionStore` + `KeyStore` that pauses the production save exactly +/// once, mid-mutation, so an observer can prove the save holds the transaction +/// lock at that instant. +/// +/// The pause fires inside `store_batch_verified` — the single blob write every +/// save routes its secret projection through — which the production seam +/// reaches only AFTER acquiring the lock and BEFORE the JSON commit. On the +/// first call it signals `entered` and blocks on `release`; every other +/// operation delegates to an inner `FakeCombinedStore` so the save still reads +/// back and commits normally once released. +struct BarrierStore { + inner: FakeCombinedStore, + entered: std::sync::mpsc::Sender<()>, + release: std::sync::mpsc::Receiver<()>, + tripped: std::cell::Cell, +} + +impl BarrierStore { + fn new(entered: std::sync::mpsc::Sender<()>, release: std::sync::mpsc::Receiver<()>) -> Self { + Self { + inner: FakeCombinedStore::new(), + entered, + release, + tripped: std::cell::Cell::new(false), + } + } + + /// Signal + block on the first secret write, once. Runs under the txn lock + /// the production seam acquired, before the JSON commit. + fn trip_once(&self) { + if !self.tripped.replace(true) { + self.entered.send(()).expect("signal save entered mutation"); + self.release.recv().expect("observer released the save"); + } + } +} + +impl KeyStore for BarrierStore { + fn probe(&self, name: &str) -> KeyringProbe { + self.inner.probe(name) + } + fn load(&self, name: &str) -> Result, String> { + self.inner.load(name) + } + fn load_all_readonly(&self) -> Result>, String> { + self.inner.load_all_readonly() + } + fn write_and_verify(&self, name: &str, value: &str) -> Result<(), String> { + KeyStore::write_and_verify(&self.inner, name, value) + } + fn store_all(&self, entries: &HashMap) -> Result<(), String> { + self.inner.store_all(entries) + } +} + +impl ProjectionStore for BarrierStore { + fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String> { + ProjectionStore::write_and_verify(&self.inner, key, value) + } + fn load_key(&self, key: &str) -> Result, String> { + self.inner.load_key(key) + } + fn load_all(&self) -> Result>, String> { + self.inner.load_all() + } + fn store_batch(&self, entries: &HashMap) -> Result<(), String> { + self.inner.store_batch(entries) + } + fn remove_batch(&self, keys: &[&str]) -> Result<(), String> { + self.inner.remove_batch(keys) + } + fn store_batch_verified(&self, entries: &HashMap) -> Result<(), String> { + // Pause here, under the production seam's held lock, so the observer's + // non-blocking probe reports EWOULDBLOCK. Delegate afterward so the save + // reads its projection back and commits. + self.trip_once(); + self.inner.store_batch_verified(entries) + } +} + +/// A non-blocking acquire of the store-dir txn lock on an independent open file +/// description must fail while another holder has it. `true` = excluded +/// (`EWOULDBLOCK`), `false` = acquired (lock NOT held). +fn probe_excluded(lock_dir: &std::path::Path) -> bool { + use std::os::unix::io::AsRawFd; + let probe = std::fs::File::open(lock_dir).expect("open store dir for probe"); + let rc = unsafe { libc::flock(probe.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if rc == 0 { + // Acquired: release immediately so we leave no lock behind. + unsafe { libc::flock(probe.as_raw_fd(), libc::LOCK_UN) }; + return false; + } + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EWOULDBLOCK), + "exclusion must report EWOULDBLOCK" + ); + true +} + +/// Concurrent instance-save and definition-save against ONE store dir must lose +/// neither half and re-inline neither half's secrets, and each save must hold +/// the cross-process transaction lock across its own read-modify-write cycle. +/// +/// Driven through the production lock-owning seams. Each save is paused +/// mid-mutation by an injected `BarrierStore`; while paused, a non-blocking +/// probe on an independent open file description proves the seam holds the lock +/// at that instant (delete the acquisition and the probe acquires instead — +/// red). After the instance save releases and commits, the definition save runs +/// the same way and its raw instance re-read observes the committed instance +/// rather than the empty seed, so neither half is clobbered or re-inlined. +#[test] +fn production_saves_hold_txn_lock_and_preserve_both_halves_without_reinlining() { + use crate::secret_store::store_txn_lock_dir; + + let dir = tempfile::tempdir().expect("tempdir"); + let store_path = dir.path().join("managed-agents.json"); + std::fs::write(&store_path, b"[]").expect("seed empty store"); + let lock_dir = store_txn_lock_dir(&store_path); + + // ── Instance save: holds the lock across its mutation ────────────────── + // Instance half: a keyed record with an inline key AND inline env, both of + // which the save must project into the keyring (never leave in JSON). + let mut instance = record_with_pubkey_and_key("instance-pub", "nsec1instkey"); + instance.env_vars = [("INSTANCE_SECRET".to_string(), "inst-plaintext".to_string())] + .into_iter() + .collect(); + + let (inst_entered_tx, inst_entered_rx) = std::sync::mpsc::channel::<()>(); + let (inst_release_tx, inst_release_rx) = std::sync::mpsc::channel::<()>(); + let inst_store_path = store_path.clone(); + let instance_save = std::thread::spawn(move || { + let store = BarrierStore::new(inst_entered_tx, inst_release_rx); + save_managed_agents_locked_at( + &inst_store_path, + Some(&store), + std::slice::from_ref(&instance), + ) + .expect("instance save commits"); + }); + + inst_entered_rx + .recv() + .expect("instance save reached its mutation under the lock"); + assert!( + probe_excluded(&lock_dir), + "the instance save must hold the txn lock across its mutation" + ); + inst_release_tx.send(()).expect("release instance save"); + instance_save.join().expect("instance save thread"); + + // ── Definition save: holds the lock, and preserves the committed instance ─ + let mut definition: ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "", + "name": "def-def-slug", + "slug": "def-slug", + "relay_url": "", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }"#, + ) + .expect("definition record"); + definition.env_vars = [("DEF_SECRET".to_string(), "def-plaintext".to_string())] + .into_iter() + .collect(); + + let (def_entered_tx, def_entered_rx) = std::sync::mpsc::channel::<()>(); + let (def_release_tx, def_release_rx) = std::sync::mpsc::channel::<()>(); + let def_store_path = store_path.clone(); + let definition_save = std::thread::spawn(move || { + let store = BarrierStore::new(def_entered_tx, def_release_rx); + save_agent_definitions_locked_at( + &def_store_path, + Some(&store), + std::slice::from_ref(&definition), + ) + .expect("definition save commits"); + }); + + def_entered_rx + .recv() + .expect("definition save reached its mutation under the lock"); + assert!( + probe_excluded(&lock_dir), + "the definition save must hold the txn lock across its mutation" + ); + def_release_tx.send(()).expect("release definition save"); + definition_save.join().expect("definition save thread"); + + // ── Neither half lost, neither half re-inlined ───────────────────────── + let committed = std::fs::read_to_string(&store_path).expect("read committed"); + assert!( + !committed.contains("inst-plaintext"), + "the instance env must stay projected, never re-inlined by the definition save" + ); + assert!( + !committed.contains("nsec1instkey"), + "the instance key must stay in the keyring, never re-inlined by the definition save" + ); + assert!( + !committed.contains("def-plaintext"), + "the definition env must be projected, never written inline" + ); + + let records: Vec = + serde_json::from_str(&committed).expect("parse committed"); + let instance = records + .iter() + .find(|r| r.pubkey == "instance-pub") + .expect("the instance must survive the concurrent definition save"); + assert!( + instance.env_vars_ref.is_some() && instance.env_vars.is_empty(), + "the instance env must remain a projected ref with no inline residue" + ); + assert!( + instance.private_key_nsec.is_empty(), + "the instance key must remain projected out of the JSON" + ); + let definition = records + .iter() + .find(|r| r.pubkey.is_empty() && r.slug.as_deref() == Some("def-slug")) + .expect("the definition must be committed alongside the instance"); + assert!( + definition.env_vars_ref.is_some() && definition.env_vars.is_empty(), + "the definition env must be a projected ref with no inline residue" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index 9943c6b3ac3..210f6302043 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -5,11 +5,6 @@ use std::cell::RefCell; use std::collections::HashMap; -use std::fs::File; -use std::io::Write as _; -use std::path::Path; - -use tempfile::NamedTempFile; use super::{ agent_keyring_name, hydrate_keys_with, migrate_inline_key, persist_agent_keys_with, @@ -251,6 +246,29 @@ fn spawn_allowed_when_private_key_present() { assert!(super::spawn_key_refusal(&record).is_none()); } +#[test] +fn spawn_refused_when_secrets_unavailable() { + // A record whose keyring ref exists but the entry is unavailable must be + // refused at spawn time — same semantics as a missing private key. + let mut record = record_with_key("nsec1realkey"); + record.secrets_unavailable = true; + assert!( + super::spawn_key_refusal(&record).is_some(), + "an agent with unavailable secrets must be refused at spawn" + ); +} + +#[test] +fn spawn_allowed_when_key_present_and_no_unavailable_secrets() { + // A fully hydrated record must not be blocked. + let mut record = record_with_key("nsec1realkey"); + record.secrets_unavailable = false; + assert!( + super::spawn_key_refusal(&record).is_none(), + "an agent with key and reachable secrets must be allowed" + ); +} + #[test] fn persist_agent_keys_issues_zero_writes_when_inline_keys_already_cleared() { // This is the dominant prompt-storm scenario: after the first successful @@ -313,12 +331,6 @@ fn persist_agent_keys_writes_once_per_record_with_inline_key() { assert!(records[1].private_key_nsec.is_empty()); } -fn write_log(content: &str) -> NamedTempFile { - let mut file = NamedTempFile::new().expect("temp log"); - file.write_all(content.as_bytes()).expect("write log"); - file -} - /// The keyringless fallback write must land `0o600` from the write itself — /// not a post-write `chmod` — so a crash in the umask window can never leave /// plaintext agent nsecs world-readable (Wes storage.rs:239, SECURITY.md:90). @@ -345,163 +357,6 @@ fn restricted_write_lands_owner_only_without_post_write_chmod() { ); } -#[test] -fn meaningful_agent_error_from_log_promotes_wrapped_llm_auth() { - let file = - write_log("noise\nAgent reported error (code -32001): llm auth: 401 unauthorized: ...\n"); - let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); - assert!(result.message.contains("llm auth")); - assert_eq!(result.code, Some(-32001)); -} - -#[test] -fn meaningful_agent_error_from_log_promotes_unwrapped_llm_auth() { - let file = write_log("noise\nllm auth: denied\n"); - let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); - assert_eq!(result.message, "Agent reported error: llm auth: denied"); - assert_eq!(result.code, Some(-32001)); -} - -#[test] -fn meaningful_agent_error_from_log_promotes_bare_model_not_found() { - let file = write_log("noise\nllm model not found: (some-model) 404\n"); - let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); - assert_eq!( - result.message, - "Agent reported error: llm model not found: (some-model) 404" - ); - assert_eq!(result.code, Some(-32002)); -} - -#[test] -fn meaningful_agent_error_from_log_promotes_legacy_format() { - let file = write_log("noise\nAgent reported error: llm: 500 internal\n"); - let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); - assert_eq!(result.message, "Agent reported error: llm: 500 internal"); - assert_eq!(result.code, None); -} - -#[test] -fn meaningful_agent_error_from_log_does_not_promote_midline_auth_text() { - let file = write_log("noise before llm auth: denied\n"); - assert!(super::meaningful_agent_error_from_log(file.path()).is_none()); -} - -#[test] -fn strips_ansi_from_typical_tracing_line() { - let input = "\x1b[2m2026-05-27T15:16:32\x1b[0m \x1b[32m INFO\x1b[0m \x1b[2mbuzz_acp\x1b[0m\x1b[2m:\x1b[0m starting"; - assert_eq!( - strip_ansi_escapes::strip_str(input), - "2026-05-27T15:16:32 INFO buzz_acp: starting" - ); -} - -// ── harness-log selection tests ──────────────────────────────────────── - -const PUBKEY_A: &str = "aa11223344556677889900aabbccddeeff00112233445566778899aabbccddee"; -const PUBKEY_B: &str = "bb11223344556677889900aabbccddeeff00112233445566778899aabbccddee"; - -/// Write `name` into `dir` and stamp it `age_secs` before now, so selection -/// order is asserted against explicit mtimes rather than write order. -fn write_log_in(dir: &Path, name: &str, age_secs: u64) { - let path = dir.join(name); - let file = File::create(&path).expect("create log"); - file.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(age_secs)) - .expect("stamp mtime"); -} - -#[test] -fn newest_agent_log_prefers_pair_scoped_when_it_is_freshest() { - let dir = tempfile::tempdir().expect("temp dir"); - write_log_in(dir.path(), &format!("{PUBKEY_A}.log"), 600); - write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log"), 5); - - assert_eq!( - super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), - Some(dir.path().join(format!("{PUBKEY_A}__cafe.log"))) - ); -} - -#[test] -fn newest_agent_log_prefers_legacy_when_it_is_freshest() { - let dir = tempfile::tempdir().expect("temp dir"); - write_log_in(dir.path(), &format!("{PUBKEY_A}.log"), 5); - write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log"), 600); - - assert_eq!( - super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), - Some(dir.path().join(format!("{PUBKEY_A}.log"))), - "mtime decides, not the filename shape" - ); -} - -#[test] -fn newest_agent_log_finds_sole_pair_scoped_log() { - let dir = tempfile::tempdir().expect("temp dir"); - write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log"), 5); - - assert_eq!( - super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), - Some(dir.path().join(format!("{PUBKEY_A}__cafe.log"))) - ); -} - -#[test] -fn newest_agent_log_picks_freshest_of_several_relays() { - let dir = tempfile::tempdir().expect("temp dir"); - write_log_in(dir.path(), &format!("{PUBKEY_A}__aaa.log"), 900); - write_log_in(dir.path(), &format!("{PUBKEY_A}__bbb.log"), 5); - write_log_in(dir.path(), &format!("{PUBKEY_A}__ccc.log"), 300); - - assert_eq!( - super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), - Some(dir.path().join(format!("{PUBKEY_A}__bbb.log"))) - ); -} - -#[test] -fn newest_agent_log_ignores_other_agents_and_non_log_files() { - let dir = tempfile::tempdir().expect("temp dir"); - write_log_in(dir.path(), &format!("{PUBKEY_B}__cafe.log"), 1); - write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log.gz"), 2); - write_log_in(dir.path(), &format!("{PUBKEY_A}__cafe.log"), 600); - - assert_eq!( - super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), - Some(dir.path().join(format!("{PUBKEY_A}__cafe.log"))), - "a fresher log belonging to another agent must never be selected" - ); -} - -#[test] -fn newest_agent_log_is_none_when_agent_has_no_logs() { - let dir = tempfile::tempdir().expect("temp dir"); - write_log_in(dir.path(), &format!("{PUBKEY_B}.log"), 1); - - assert_eq!(super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), None); -} - -#[test] -fn newest_agent_log_is_none_when_dir_is_missing() { - let dir = tempfile::tempdir().expect("temp dir"); - let missing = dir.path().join("absent"); - - assert_eq!(super::newest_agent_log_in_dir(&missing, PUBKEY_A), None); -} - -#[test] -fn newest_agent_log_breaks_mtime_ties_deterministically() { - let dir = tempfile::tempdir().expect("temp dir"); - write_log_in(dir.path(), &format!("{PUBKEY_A}__aaa.log"), 60); - write_log_in(dir.path(), &format!("{PUBKEY_A}__bbb.log"), 60); - - assert_eq!( - super::newest_agent_log_in_dir(dir.path(), PUBKEY_A), - Some(dir.path().join(format!("{PUBKEY_A}__bbb.log"))), - "equal mtimes must resolve to the same file on every read_dir order" - ); -} - // ── keyring-dev-migration tests ──────────────────────────────────────── #[test] @@ -699,134 +554,325 @@ fn try_delete_agent_key_returns_result() { let _: fn(&str) -> Result<(), String> = super::try_delete_agent_key; } -// ── install logs ───────────────────────────────────────────────────────────── - -/// Install output can carry registry tokens and proxy credentials a failing -/// installer echoed, and the file is written unattended. `0o600` must come from -/// the create itself: a post-write `chmod` leaves a window where the umask -/// decides, and a crash inside it leaves the log readable to other local users. -#[cfg(unix)] +/// Regression test: after secret extraction, the serialized store must not +/// contain any secret-shaped values. This is the grep-empty criterion from +/// the v5 spec acceptance criteria, exercised at the type level. +/// +/// The test constructs records with inline secrets, runs the strip path via +/// the secret seam, and verifies the resulting JSON is free of the known +/// secret values. #[test] -fn install_log_is_created_owner_only_without_post_write_chmod() { - use std::os::unix::fs::PermissionsExt; +fn serialized_store_is_empty_of_secret_values_after_strip() { + use crate::managed_agents::secret_seam::strip_and_persist_agent_secrets_with; + use std::cell::RefCell; + use std::collections::HashMap; - let dir = tempfile::tempdir().expect("temp dir"); - let path = dir.path().join("install-goose.log"); + // ── FakeProjectionStore (minimal, for this test) ────────────────── + struct FakePS { + data: RefCell>, + } + impl crate::managed_agents::secret_projection::ProjectionStore for FakePS { + fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String> { + self.data + .borrow_mut() + .insert(key.to_string(), value.to_string()); + Ok(()) + } + fn load_key(&self, key: &str) -> Result, String> { + Ok(self.data.borrow().get(key).cloned()) + } + fn load_all(&self) -> Result>, String> { + Ok(Some(self.data.borrow().clone())) + } + fn store_batch(&self, entries: &HashMap) -> Result<(), String> { + for (k, v) in entries { + self.data.borrow_mut().insert(k.clone(), v.clone()); + } + Ok(()) + } + fn remove_batch(&self, keys: &[&str]) -> Result<(), String> { + let mut d = self.data.borrow_mut(); + for k in keys { + d.remove(*k); + } + Ok(()) + } + } - let mut file = super::open_install_log_file(&path).expect("open install log"); - file.write_all(b"npm ERR!\n").expect("write"); + let store = FakePS { + data: RefCell::new(HashMap::new()), + }; - let mode = std::fs::metadata(&path) - .expect("metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o600, "install logs must be owner-only"); -} + let secret_env_value = "sk-ant-api03-very-secret-key"; + let secret_auth_tag = "auth-tag-secret"; -/// A run starts a new current file and keeps the previous run as `.1`, so the -/// two runs are never mixed and the history on disk stays bounded at two. -#[test] -fn install_log_session_keeps_the_previous_run_as_dot_one() { - let dir = tempfile::tempdir().expect("temp dir"); - let path = dir.path().join("install-goose.log"); + // Build a record via JSON deserialization (avoids Default dependency). + let mut record: ManagedAgentRecord = serde_json::from_str(&format!( + r#"{{ + "pubkey": "testpubkey123", + "name": "test-agent", + "env_vars": {{"ANTHROPIC_API_KEY": "{secret_env_value}"}}, + "auth_tag": "{secret_auth_tag}", + "backend": {{"type": "provider", "id": "anthropic", "config": {{"api_key": "provider-secret"}}}}, + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "2026-01-01", + "updated_at": "2026-01-01" + }}"# + )) + .expect("sample record with inline secrets"); - let mut first = super::start_install_log_session(&path).expect("first session"); - first.write_all(b"run-one\n").expect("write"); - let mut second = super::start_install_log_session(&path).expect("second session"); - second.write_all(b"run-two\n").expect("write"); + // Strip: moves secrets from inline fields into the fake keyring. + strip_and_persist_agent_secrets_with(&store, &mut record); - assert_eq!( - std::fs::read_to_string(&path).expect("read current"), - "run-two\n", - "the current file must hold only the newest run" + // After strip: inline fields must be empty/null. + assert!( + record.env_vars.is_empty(), + "env_vars must be cleared after strip" ); - assert_eq!( - std::fs::read_to_string(dir.path().join("install-goose.log.1")).expect("read .1"), - "run-one\n", - "the previous run must be preserved as .1" + assert!( + record.auth_tag.is_none(), + "auth_tag must be cleared after strip" + ); + if let crate::managed_agents::types::BackendKind::Provider { config, .. } = &record.backend { + assert!( + config.is_null(), + "provider config must be cleared after strip" + ); + } + + // Serialize to JSON and verify no secret bytes appear. + let json = serde_json::to_string(&record).expect("serialize"); + assert!( + !json.contains(secret_env_value), + "serialized JSON must not contain env_vars secret" + ); + assert!( + !json.contains(secret_auth_tag), + "serialized JSON must not contain auth_tag secret" + ); + assert!( + !json.contains("provider-secret"), + "serialized JSON must not contain provider config secret" + ); + // Refs must be present (the keyring round-trip worked). + assert!( + record.env_vars_ref.is_some(), + "env_vars_ref must be set after successful strip" + ); + assert!( + record.auth_tag_ref.is_some(), + "auth_tag_ref must be set after successful strip" + ); + assert!( + record.provider_config_ref.is_some(), + "provider_config_ref must be set after successful strip" ); } -/// The third run must still rotate when `.1` already exists. Windows `rename` -/// does not replace its destination, so a rename-only rotation silently stops -/// working here and leaves the current file to grow across every later run — -/// the old `.1` is removed first precisely so this cannot happen. Runs on the -/// Windows target too: this is the path that fails there. -#[test] -fn install_log_session_replaces_an_existing_dot_one() { - let dir = tempfile::tempdir().expect("temp dir"); - let path = dir.path().join("install-goose.log"); - let rotated = dir.path().join("install-goose.log.1"); - // Seed the state a rename-only rotation cannot get out of: both files exist. - std::fs::write(&path, b"previous-run\n").expect("seed current"); - std::fs::write(&rotated, b"ancient-run\n").expect("seed .1"); +// ── W2: instance-side save preserves the definition half raw ─────────────── +// +// `save_managed_agents` re-reads the definition half RAW under the txn lock +// (never through the hydrating loader) before committing the unified store, so +// an instance-only save can never re-inline a definition's projected secrets +// into plaintext JSON, and a store parse error propagates instead of silently +// collapsing the definition half to empty. - let mut file = super::start_install_log_session(&path).expect("session"); - file.write_all(b"fresh-run\n").expect("write"); +/// In-memory store implementing BOTH seams `save_managed_agents_at` requires: +/// [`KeyStore`] (nsec persistence) and `ProjectionStore` (env/auth/provider +/// projection). A single backing map so a written secret reads back verified. +struct FakeCombinedStore { + data: RefCell>, +} - assert_eq!( - std::fs::read_to_string(&path).expect("read current"), - "fresh-run\n", - "the current file must restart even when .1 was already present" - ); - assert_eq!( - std::fs::read_to_string(&rotated).expect("read .1"), - "previous-run\n", - ".1 must be replaced by the run that just ended, not kept" - ); +impl FakeCombinedStore { + fn new() -> Self { + Self { + data: RefCell::new(HashMap::new()), + } + } } -/// Records written after the session starts append to it — a run's later -/// records must not erase its earlier ones. -#[test] -fn install_log_appends_within_a_session() { - let dir = tempfile::tempdir().expect("temp dir"); - let path = dir.path().join("install-goose.log"); +impl KeyStore for FakeCombinedStore { + fn probe(&self, _name: &str) -> KeyringProbe { + KeyringProbe::ReachableButEmpty + } + fn load(&self, name: &str) -> Result, String> { + Ok(self.data.borrow().get(name).cloned()) + } + fn load_all_readonly(&self) -> Result>, String> { + Ok(Some(self.data.borrow().clone())) + } + fn write_and_verify(&self, name: &str, value: &str) -> Result<(), String> { + self.data + .borrow_mut() + .insert(name.to_string(), value.to_string()); + Ok(()) + } + fn store_all(&self, entries: &HashMap) -> Result<(), String> { + for (k, v) in entries { + self.data.borrow_mut().insert(k.clone(), v.clone()); + } + Ok(()) + } +} - let mut session = super::start_install_log_session(&path).expect("session"); - session.write_all(b"header\n").expect("write"); - for record in ["first\n", "second\n"] { - let mut file = super::open_install_log_file(&path).expect("open install log"); - file.write_all(record.as_bytes()).expect("write"); +impl crate::managed_agents::secret_projection::ProjectionStore for FakeCombinedStore { + fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String> { + self.data + .borrow_mut() + .insert(key.to_string(), value.to_string()); + Ok(()) + } + fn load_key(&self, key: &str) -> Result, String> { + Ok(self.data.borrow().get(key).cloned()) + } + fn load_all(&self) -> Result>, String> { + Ok(Some(self.data.borrow().clone())) + } + fn store_batch(&self, entries: &HashMap) -> Result<(), String> { + for (k, v) in entries { + self.data.borrow_mut().insert(k.clone(), v.clone()); + } + Ok(()) } + fn remove_batch(&self, keys: &[&str]) -> Result<(), String> { + let mut d = self.data.borrow_mut(); + for k in keys { + d.remove(*k); + } + Ok(()) + } +} - assert_eq!( - std::fs::read_to_string(&path).expect("read back"), - "header\nfirst\nsecond\n" - ); +/// A key-less definition record carrying an already-projected `env_vars_ref` +/// (no inline env) — the on-disk shape after the definition's secrets were +/// stripped into the keyring on a prior save. +fn projected_definition(slug: &str, env_ref: &str) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_str(&format!( + r#"{{ + "pubkey": "", + "name": "def-{slug}", + "slug": "{slug}", + "relay_url": "", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }}"# + )) + .expect("definition record"); + record.env_vars_ref = Some(env_ref.to_string()); + record } -/// A runtime id becomes part of a filename. Ids reach this from user-defined -/// custom harnesses as well as the catalog, so anything that could traverse or -/// escape the logs directory is rejected rather than sanitized — a rejected id -/// simply means no log, while a silently rewritten one could collide with -/// another runtime's log. #[test] -fn install_log_filename_rejects_ids_that_would_escape_the_logs_dir() { - for id in [ - "../../etc/passwd", - "goose/../../evil", - "sub/dir", - "back\\slash", - "with.dot", - "", - ] { - assert!( - super::install_log_filename(id).is_err(), - "id {id:?} must not be accepted as a filename component" - ); - } +fn save_managed_agents_preserves_projected_definition_ref_without_reinlining() { + // Seed a store whose definition half is already projected: `env_vars_ref` + // set, NO inline env bytes. Save an UNRELATED instance. The committed store + // must keep the definition's ref verbatim and must not resurrect its inline + // env — the W2 regression was that the hydrating re-read re-inlined it. + use crate::managed_agents::secret_projection::definition_env_key; + + let dir = tempfile::tempdir().expect("tempdir"); + let store_path = dir.path().join("managed-agents.json"); + + // The keyring already holds the definition's projected env under its ref. + let store = FakeCombinedStore::new(); + let def_env_gen = "gendef123"; + store.data.borrow_mut().insert( + definition_env_key("shared-def", def_env_gen), + r#"{"DEF_SECRET":"def-secret-value"}"#.to_string(), + ); + + // On-disk starting store: one projected definition, no instances. + let definition = projected_definition("shared-def", def_env_gen); + std::fs::write( + &store_path, + serde_json::to_string(&[&definition]).expect("serialize seed"), + ) + .expect("write seed store"); + + // Save an unrelated instance (carries its own inline env to project). + let mut instance = record_with_pubkey_and_key("instance-pubkey", "nsec1instkey"); + instance.env_vars = [("INSTANCE_KEY".to_string(), "inst-secret".to_string())] + .into_iter() + .collect(); + + super::save_managed_agents_at(&store_path, Some(&store), std::slice::from_ref(&instance)) + .expect("save must succeed"); + + // Re-read the committed store RAW (no hydration). + let committed = std::fs::read_to_string(&store_path).expect("read committed"); + + // The definition's plaintext secret must NOT be in the JSON. + assert!( + !committed.contains("def-secret-value"), + "an instance-side save must not re-inline the definition's projected secret" + ); + + // The definition's ref must survive verbatim. + let records: Vec = + serde_json::from_str(&committed).expect("parse committed"); + let def = records + .iter() + .find(|r| r.pubkey.is_empty() && r.slug.as_deref() == Some("shared-def")) + .expect("definition must survive the instance save"); + assert_eq!( + def.env_vars_ref.as_deref(), + Some(def_env_gen), + "the definition's projected ref must be preserved unchanged" + ); + assert!( + def.env_vars.is_empty(), + "the definition must carry no inline env after the save" + ); + // The instance was persisted alongside it. + assert!( + records.iter().any(|r| r.pubkey == "instance-pubkey"), + "the saved instance must be present in the committed store" + ); } -/// Ordinary catalog and custom-harness ids are accepted — the guard must not -/// reject the ids it exists to serve. #[test] -fn install_log_filename_accepts_ordinary_runtime_ids() { - for id in ["goose", "claude-code", "buzz_agent", "codex2"] { - assert_eq!( - super::install_log_filename(id).expect("id must be usable in a log filename"), - format!("install-{id}.log") - ); - } +fn save_managed_agents_propagates_store_parse_error_instead_of_dropping_definitions() { + // A malformed on-disk store must fail the save with an error — NEVER be + // read as an empty definition half, because the wholesale rewrite would + // then delete every definition from the live store. W2/F2: the re-read uses + // `?`, not `unwrap_or_default()`. + let dir = tempfile::tempdir().expect("tempdir"); + let store_path = dir.path().join("managed-agents.json"); + std::fs::write(&store_path, b"{ this is not valid json ]").expect("write malformed"); + + let store = FakeCombinedStore::new(); + let instance = record_with_pubkey_and_key("instance-pubkey", "nsec1instkey"); + + let result = + super::save_managed_agents_at(&store_path, Some(&store), std::slice::from_ref(&instance)); + + assert!( + result.is_err(), + "a malformed store must fail the save, not silently drop definitions" + ); + assert!( + result.unwrap_err().contains("parse"), + "the error must surface the parse failure" + ); } + +// The concurrent instance/definition-save interleave test lives in the sibling +// `storage_interleave_tests.rs` — a child of this `tests` module so it reuses +// the `FakeCombinedStore` and `record_with_pubkey_and_key` helpers above +// without duplication, while keeping this file under the desktop file-size +// gate. Unix-only + `system-keyring`: it drives `libc::flock` and the real +// cross-process transaction lock. +#[cfg(all(unix, feature = "system-keyring"))] +#[path = "storage_interleave_tests.rs"] +mod interleave; diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 96082acc76d..a51ce14582a 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -309,6 +309,10 @@ mod tests { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 1ffa60eda97..2f633f6d885 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -216,6 +216,10 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e5be105fed0..6b7615d3288 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -12,7 +12,7 @@ pub enum BackendKind { }, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct AgentDefinition { pub id: String, pub display_name: String, @@ -90,13 +90,13 @@ pub struct AgentDefinition { pub parallelism: Option, pub created_at: String, pub updated_at: String, + /// Runtime-only unavailability flag. Never serialized; reconstructed on boot. + #[serde(skip)] + pub secrets_unavailable: bool, } impl AgentDefinition { - /// Project this persona onto a key-less unified [`ManagedAgentRecord`] - /// (Phase 1A store fold). Identity fields stay empty — keys are minted on - /// first start. `AgentDefinition.id` becomes `slug`, preserving the 30175 - /// event coordinate (`d_tag = slug`) across the fold. + /// Project this persona onto a unified [`ManagedAgentRecord`] (Phase 1A fold). pub fn into_agent_record(self) -> ManagedAgentRecord { ManagedAgentRecord { pubkey: String::new(), @@ -104,6 +104,9 @@ impl AgentDefinition { persona_id: None, private_key_nsec: String::new(), auth_tag: None, + auth_tag_ref: None, + env_vars_ref: None, + provider_config_ref: None, relay_url: String::new(), avatar_url: self.avatar_url, acp_command: DEFAULT_ACP_COMMAND.to_string(), @@ -153,15 +156,15 @@ impl AgentDefinition { definition_respond_to_allowlist: self.respond_to_allowlist, definition_parallelism: self.parallelism, relay_mesh: None, + secrets_unavailable: false, } } } impl ManagedAgentRecord { /// Present a key-less definition record back in the legacy - /// [`AgentDefinition`] shape — the compatibility view the persona command - /// surface serves until Phase 1B unifies the UI. Inverse of - /// [`AgentDefinition::into_agent_record`] for the fields personas carry. + /// [`AgentDefinition`] shape (compatibility view for the persona command + /// surface). Inverse of [`AgentDefinition::into_agent_record`]. pub fn to_definition_view(&self) -> Option { let slug = self.slug.clone()?; Some(AgentDefinition { @@ -189,6 +192,7 @@ impl ManagedAgentRecord { parallelism: self.definition_parallelism, created_at: self.created_at.clone(), updated_at: self.updated_at.clone(), + secrets_unavailable: self.secrets_unavailable, }) } } @@ -217,24 +221,20 @@ pub struct ManagedAgentRecord { /// Team this instance was deployed from. Resolves runtime team instructions. #[serde(default, skip_serializing_if = "Option::is_none")] pub team_id: Option, - /// nsec private key. Held in memory but persisted to the OS keyring (keyed - /// by `pubkey`) rather than serialized to `managed-agents.json`. The - /// storage layer blanks this before writing JSON once the key is safely in - /// the keyring, and re-hydrates it from the keyring on load. - /// - /// It is only serialized inline (the `0o600` JSON fallback) when the - /// keyring is unreachable — `skip_serializing_if` keeps it out of JSON in - /// the normal keyring-backed case. `default` also lets an old build parse a - /// store whose inline key was already migrated out and blanked. + /// nsec private key. Stored in the OS keyring (keyed by `pubkey`); blanked + /// before JSON write, re-hydrated on load. Inline only when keyring is unreachable. #[serde(default, skip_serializing_if = "String::is_empty")] pub private_key_nsec: String, - /// NIP-OA auth tag JSON. Computed at agent creation time. - /// - /// Pre-existing agents created before NIP-OA will have `None` here. - /// This is intentional — they continue to work without attestation. - /// Re-attestation requires agent recreation (v2 migration scope). + /// NIP-OA auth tag JSON. Pre-existing agents (pre-NIP-OA) have `None`. #[serde(default)] pub auth_tag: Option, + /// Keyring gen refs; absent = intentionally empty (`provider_config_ref` is `None` for `Local`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_tag_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env_vars_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_config_ref: Option, pub relay_url: String, /// Avatar URL resolved at creation time (user-supplied input, else the /// command-based fallback). Persisted so startup reconciliation compares @@ -438,6 +438,10 @@ pub struct ManagedAgentRecord { /// deserialize as `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, + /// Set by the load path when a secret field's keyring `*_ref` points to an + /// unavailable entry. Never serialized; `true` means spawn must refuse. + #[serde(skip)] + pub secrets_unavailable: bool, } /// Typed relay-mesh configuration carried on a [`ManagedAgentRecord`]. @@ -700,9 +704,7 @@ pub struct InstallRuntimeResult { /// Number of agents whose stop succeeded but respawn failed. /// Mirrors `GlobalAgentConfigSaveResult.failed_restart_count`. pub failed_restart_count: u32, - /// Install log file for this run, when one was written. The UI surfaces it - /// on failure so a user can read the full retry history instead of only the - /// last step's truncated output. `None` when no log could be opened. + /// Install log file for this run. `None` when no log could be opened. pub log_path: Option, } diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index e28b0bd461a..2e38a60f63d 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -289,6 +289,7 @@ mod tests { parallelism: None, created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 1db7b9b5243..c508f7f22e1 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -492,6 +492,7 @@ fn sample_persona() -> AgentDefinition { parallelism: None, created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-02T00:00:00Z".to_string(), + secrets_unavailable: false, } } diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index 7933fd291e4..7e159f7521b 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -471,6 +471,7 @@ mod tests { parallelism: None, created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), + secrets_unavailable: false, } .into_agent_record(); record.pubkey = pubkey.to_string(); diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index b3e613621ec..09f01dc713c 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -191,6 +191,12 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { reconcile_provider_mcp_commands(app); reconcile_databricks_v1_to_v2(app); materialize_agent_runtimes(app); + migrate_inline_secrets_to_keyring(app); // keyring extraction — after all raw-JSON migrations + migrate_harness_secrets_to_keyring(app); // custom-harness env extraction — same phase + #[cfg(debug_assertions)] // dev-build only; runs after extraction so keys exist in the source + if is_dev { + crate::managed_agents::migrate_agent_secrets_to_dev_service(app); + } } /// Copy one-time app state from the legacy app identifier directory to @@ -1235,114 +1241,6 @@ pub fn reconcile_provider_mcp_commands(app: &tauri::AppHandle) { } } -fn reconcile_databricks_v1_to_v2_in_file(path: &Path, rewrite_v1_provider: bool) { - use crate::managed_agents::is_derived_provider_model_key; - patch_json_records(path, |obj| { - let mut changed = false; - - // Only rewrite the structured provider field when the baked build env - // marks this as a Block build (BUZZ_AGENT_PROVIDER == "databricks_v2"). - // OSS users may intentionally select V1 (Model Serving), so we must not - // silently migrate their provider to V2 (AI Gateway). - if rewrite_v1_provider && obj.get("provider").and_then(|v| v.as_str()) == Some("databricks") - { - let name = obj - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("?") - .to_string(); - eprintln!( - "buzz-desktop: databricks-v1-to-v2: {name:?}: provider \"databricks\" → \"databricks_v2\"", - ); - obj.insert( - "provider".to_string(), - serde_json::Value::String("databricks_v2".to_string()), - ); - // Also clear the model field — a V1 model name (e.g. "dbrx-instruct") - // on a V2 provider would shadow the baked DATABRICKS_MODEL at spawn time - // (BUZZ_AGENT_MODEL from runtime_metadata_env_vars takes priority in - // buzz-agent config.rs). Clearing it lets the baked V2 default win. - if obj.remove("model").is_some() { - eprintln!( - "buzz-desktop: databricks-v1-to-v2: {name:?}: cleared stale V1 model field", - ); - } - changed = true; - } - - // Strip derived provider/model keys from env_vars on ALL records, - // regardless of rewrite_v1_provider. These keys are re-derived from - // structured fields at spawn time; stale copies in env_vars silently - // override the structured fields (last-write-wins in Command::env) and - // can cause V1 routing even when the provider dropdown shows V2. - // - // The check is case-insensitive (matching the established helper) - // to cover any case-variant that may have been written historically. - if let Some(serde_json::Value::Object(env_vars)) = obj.get_mut("env_vars") { - let stale_keys: Vec = env_vars - .keys() - .filter(|k| is_derived_provider_model_key(k)) - .cloned() - .collect(); - for key in stale_keys { - env_vars.remove(key.as_str()); - eprintln!("buzz-desktop: databricks-v1-to-v2: removed stale env_vars[\"{key}\"]",); - changed = true; - } - } - - changed - }); -} - -/// Strip stale derived provider/model keys from `env_vars` in all -/// managed-agent records, and — on Block builds — also migrate any persisted -/// `provider: "databricks"` to `"databricks_v2"`. -/// -/// **Block builds** (where `baked_build_env()` contains -/// `BUZZ_AGENT_PROVIDER=databricks_v2`): the structured `provider` field is -/// rewritten V1→V2 because the baked release targets V2 exclusively. Records -/// that were saved before this migration would otherwise silently override the -/// baked value at spawn time (last-write-wins in `Command::env`). -/// -/// **OSS builds** (baked env empty): the `provider` field is left alone — -/// V1 (`databricks`) is a valid Model Serving choice for OSS users. -/// -/// In both cases, stale `BUZZ_AGENT_PROVIDER` / `BUZZ_AGENT_MODEL` / -/// `GOOSE_PROVIDER` / `GOOSE_MODEL` are stripped from `env_vars`. These keys -/// are always re-derived from structured fields at spawn time; persisted copies -/// silence UI edits and cause stale routing. -/// -/// Covers both the current app data dir and the canonical dev data dir -/// (for worktree instances) — same dual-dir pattern as -/// `reconcile_legacy_command_names` and `reconcile_provider_mcp_commands`. -pub fn reconcile_databricks_v1_to_v2(app: &tauri::AppHandle) { - use crate::managed_agents::baked_build_env; - // On Block builds, the baked env contains BUZZ_AGENT_PROVIDER=databricks_v2. - // Use that as a reliable signal that this is a Block build and the V1 - // provider should be migrated. OSS builds have an empty baked env, so - // rewrite_v1_provider is false and the structured provider is preserved. - let rewrite_v1_provider = baked_build_env() - .get("BUZZ_AGENT_PROVIDER") - .map(|v| v == "databricks_v2") - .unwrap_or(false); - let Ok(current_dir) = app.path().app_data_dir() else { - return; - }; - let mut dirs = vec![current_dir.clone()]; - if let Some(canonical) = canonical_dev_data_dir(¤t_dir) { - if canonical.exists() && canonical != current_dir { - dirs.push(canonical); - } - } - for dir in dirs { - let path = dir.join("agents/managed-agents.json"); - if path.exists() { - reconcile_databricks_v1_to_v2_in_file(&path, rewrite_v1_provider); - } - } -} - fn rename_provider_to_runtime_in_personas(path: &Path) { patch_json_records(path, |obj| { if obj.contains_key("runtime") { @@ -1378,31 +1276,27 @@ mod detach; pub use detach::detach_directory_backed_teams; mod team_suffix; pub use team_suffix::strip_baked_team_instructions; - -#[cfg(test)] -#[path = "migration_test_support.rs"] -mod test_support; - -#[cfg(test)] -#[path = "migration_tests.rs"] -mod tests; - +mod migration_secrets; +use migration_secrets::migrate_inline_secrets_to_keyring; +mod databricks_reconcile; +use databricks_reconcile::reconcile_databricks_v1_to_v2; +mod migration_harness_secrets; +use migration_harness_secrets::migrate_harness_secrets_to_keyring; #[cfg(test)] #[path = "migration_avatar_tests.rs"] mod avatar_tests; - #[cfg(test)] #[path = "migration_command_tests.rs"] mod command_tests; - #[cfg(test)] -#[path = "migration_databricks_tests.rs"] -mod databricks_tests; - +#[path = "migration_sync_guard_tests.rs"] +mod sync_guard_tests; #[cfg(test)] #[path = "migration_team_dir_tests.rs"] mod team_dir_tests; - #[cfg(test)] -#[path = "migration_sync_guard_tests.rs"] -mod sync_guard_tests; +#[path = "migration_test_support.rs"] +mod test_support; +#[cfg(test)] +#[path = "migration_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/migration/databricks_reconcile.rs b/desktop/src-tauri/src/migration/databricks_reconcile.rs new file mode 100644 index 00000000000..55a31ca8f95 --- /dev/null +++ b/desktop/src-tauri/src/migration/databricks_reconcile.rs @@ -0,0 +1,123 @@ +//! Databricks V1→V2 provider reconciliation for managed-agent records. +//! +//! On Block builds the persisted `provider: "databricks"` is migrated to +//! `"databricks_v2"`; on all builds stale derived provider/model keys are +//! stripped from `env_vars`. Split from `migration.rs` into its own module to +//! stay within the desktop file-size ratchet (same pattern as `backfill.rs` and +//! `migration_secrets.rs`). + +use std::path::Path; + +use tauri::Manager; + +fn reconcile_databricks_v1_to_v2_in_file(path: &Path, rewrite_v1_provider: bool) { + use crate::managed_agents::is_derived_provider_model_key; + super::patch_json_records(path, |obj| { + let mut changed = false; + + // Only rewrite the structured provider field when the baked build env + // marks this as a Block build (BUZZ_AGENT_PROVIDER == "databricks_v2"). + // OSS users may intentionally select V1 (Model Serving), so we must not + // silently migrate their provider to V2 (AI Gateway). + if rewrite_v1_provider && obj.get("provider").and_then(|v| v.as_str()) == Some("databricks") + { + let name = obj + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("?") + .to_string(); + eprintln!( + "buzz-desktop: databricks-v1-to-v2: {name:?}: provider \"databricks\" → \"databricks_v2\"", + ); + obj.insert( + "provider".to_string(), + serde_json::Value::String("databricks_v2".to_string()), + ); + // Also clear the model field — a V1 model name (e.g. "dbrx-instruct") + // on a V2 provider would shadow the baked DATABRICKS_MODEL at spawn time + // (BUZZ_AGENT_MODEL from runtime_metadata_env_vars takes priority in + // buzz-agent config.rs). Clearing it lets the baked V2 default win. + if obj.remove("model").is_some() { + eprintln!( + "buzz-desktop: databricks-v1-to-v2: {name:?}: cleared stale V1 model field", + ); + } + changed = true; + } + + // Strip derived provider/model keys from env_vars on ALL records, + // regardless of rewrite_v1_provider. These keys are re-derived from + // structured fields at spawn time; stale copies in env_vars silently + // override the structured fields (last-write-wins in Command::env) and + // can cause V1 routing even when the provider dropdown shows V2. + // + // The check is case-insensitive (matching the established helper) + // to cover any case-variant that may have been written historically. + if let Some(serde_json::Value::Object(env_vars)) = obj.get_mut("env_vars") { + let stale_keys: Vec = env_vars + .keys() + .filter(|k| is_derived_provider_model_key(k)) + .cloned() + .collect(); + for key in stale_keys { + env_vars.remove(key.as_str()); + eprintln!("buzz-desktop: databricks-v1-to-v2: removed stale env_vars[\"{key}\"]",); + changed = true; + } + } + + changed + }); +} + +/// Strip stale derived provider/model keys from `env_vars` in all +/// managed-agent records, and — on Block builds — also migrate any persisted +/// `provider: "databricks"` to `"databricks_v2"`. +/// +/// **Block builds** (where `baked_build_env()` contains +/// `BUZZ_AGENT_PROVIDER=databricks_v2`): the structured `provider` field is +/// rewritten V1→V2 because the baked release targets V2 exclusively. Records +/// that were saved before this migration would otherwise silently override the +/// baked value at spawn time (last-write-wins in `Command::env`). +/// +/// **OSS builds** (baked env empty): the `provider` field is left alone — +/// V1 (`databricks`) is a valid Model Serving choice for OSS users. +/// +/// In both cases, stale `BUZZ_AGENT_PROVIDER` / `BUZZ_AGENT_MODEL` / +/// `GOOSE_PROVIDER` / `GOOSE_MODEL` are stripped from `env_vars`. These keys +/// are always re-derived from structured fields at spawn time; persisted copies +/// silence UI edits and cause stale routing. +/// +/// Covers both the current app data dir and the canonical dev data dir +/// (for worktree instances) — same dual-dir pattern as +/// `reconcile_legacy_command_names` and `reconcile_provider_mcp_commands`. +pub(super) fn reconcile_databricks_v1_to_v2(app: &tauri::AppHandle) { + use crate::managed_agents::baked_build_env; + // On Block builds, the baked env contains BUZZ_AGENT_PROVIDER=databricks_v2. + // Use that as a reliable signal that this is a Block build and the V1 + // provider should be migrated. OSS builds have an empty baked env, so + // rewrite_v1_provider is false and the structured provider is preserved. + let rewrite_v1_provider = baked_build_env() + .get("BUZZ_AGENT_PROVIDER") + .map(|v| v == "databricks_v2") + .unwrap_or(false); + let Ok(current_dir) = app.path().app_data_dir() else { + return; + }; + let mut dirs = vec![current_dir.clone()]; + if let Some(canonical) = super::canonical_dev_data_dir(¤t_dir) { + if canonical.exists() && canonical != current_dir { + dirs.push(canonical); + } + } + for dir in dirs { + let path = dir.join("agents/managed-agents.json"); + if path.exists() { + reconcile_databricks_v1_to_v2_in_file(&path, rewrite_v1_provider); + } + } +} + +#[cfg(test)] +#[path = "databricks_reconcile_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/migration_databricks_tests.rs b/desktop/src-tauri/src/migration/databricks_reconcile_tests.rs similarity index 99% rename from desktop/src-tauri/src/migration_databricks_tests.rs rename to desktop/src-tauri/src/migration/databricks_reconcile_tests.rs index 842507ec831..6abe2915a23 100644 --- a/desktop/src-tauri/src/migration_databricks_tests.rs +++ b/desktop/src-tauri/src/migration/databricks_reconcile_tests.rs @@ -1,5 +1,5 @@ -use super::test_support::*; use super::*; +use crate::migration::test_support::*; // ── reconcile_databricks_v1_to_v2_in_file ──────────────────────────────── diff --git a/desktop/src-tauri/src/migration/migration_harness_secrets.rs b/desktop/src-tauri/src/migration/migration_harness_secrets.rs new file mode 100644 index 00000000000..f02040fe8ab --- /dev/null +++ b/desktop/src-tauri/src/migration/migration_harness_secrets.rs @@ -0,0 +1,268 @@ +//! Boot-time secret extraction for custom harness definitions. +//! +//! Custom harnesses (`/custom_harnesses/*.json`) carry an `env` map +//! that can hold provider secrets (e.g. `ANTHROPIC_API_KEY`). Before the +//! generation-reference projection this map was written plaintext to disk. This +//! migration lifts each inline `env` into the OS keyring under the +//! `harness::env:` coordinate, rewrites the file stripped at `0o600`, +//! and — once every projected generation reads back cleanly — scrubs the +//! plaintext-bearing backup/temp artifacts the save path can leave behind. +//! +//! Runs at boot alongside [`super::migration_secrets`], BEFORE +//! `warm_harness_registry_from_dir` hydrates the spawn registry. +//! +//! Idempotent across launches: extraction goes through the W1-safe field +//! migration seam ([`crate::managed_agents::custom_harnesses::migrate_harness_env`]), +//! which projects only a non-empty inline value and otherwise preserves an +//! existing ref. A second launch re-reads already-projected files (empty inline +//! + live ref) and rewrites nothing. + +use std::path::Path; + +use tauri::Manager; + +use crate::managed_agents::custom_harnesses::{ + harness_env_key, migrate_harness_env, HarnessDefinition, +}; +use crate::managed_agents::secret_projection::{load_secret, ProjectionStore}; + +/// Extract inline harness `env` secrets into the keyring, then scrub plaintext +/// artifacts once the projection is verified readable. +/// +/// Non-fatal throughout: a keyless build, an unresolvable app-data dir, or an +/// unreadable directory each short-circuit without disturbing the rest of boot. +pub(super) fn migrate_harness_secrets_to_keyring(app: &tauri::AppHandle) { + let Some(store) = crate::managed_agents::storage::agent_secret_store_pub() else { + return; // keyless build — env stays inline in the 0o600 JSON + }; + let Ok(data_dir) = app.path().app_data_dir() else { + return; + }; + let dir = data_dir.join("custom_harnesses"); + if !dir.exists() { + return; + } + migrate_harness_secrets_in_dir(store, &dir); +} + +/// Store-injected core of [`migrate_harness_secrets_to_keyring`], over a +/// concrete `dir`, so the full extract → verify → scrub flow is testable +/// against a [`ProjectionStore`] fake and a tempdir without an `AppHandle`. +fn migrate_harness_secrets_in_dir(store: &S, dir: &Path) { + // Phase 1: extract inline env → keyring, rewriting each changed file in + // place (stripped, 0o600). Rewrite the ORIGINAL path, never an id-derived + // one: a hand-authored file's name may differ from its `id`, and relocating + // it would orphan the original and duplicate the entry. + for path in harness_json_files(dir) { + let Some(mut def) = read_definition(&path) else { + continue; // unparseable live file — the loader already skips it + }; + if migrate_harness_env(store, &mut def) { + match serde_json::to_string_pretty(&def) { + Ok(json) => { + if let Err(e) = crate::managed_agents::storage::atomic_write_json_restricted( + &path, + json.as_bytes(), + ) { + eprintln!( + "buzz-desktop: harness-migration: failed to rewrite {}: {e}", + path.display() + ); + } + } + Err(e) => eprintln!( + "buzz-desktop: harness-migration: failed to serialize {}: {e}", + path.display() + ), + } + } + } + + // Phase 2: scrub plaintext artifacts — ONLY when every live file's projected + // env reads back cleanly. A dangling ref means the keyring copy is + // unavailable, so a backup could be the last recoverable plaintext; leave it. + if extraction_verified(store, dir) { + cleanup_harness_artifacts(dir); + } +} + +/// Returns `true` when every live `*.json` file's `env_ref` (if any) hydrates +/// from the keyring without error. A single failure returns `false` so the +/// caller skips the plaintext-artifact scrub this boot. +fn extraction_verified(store: &S, dir: &Path) -> bool { + for path in harness_json_files(dir) { + let Some(def) = read_definition(&path) else { + continue; + }; + // A non-empty inline env is the authoritative fallback (keyring write + // failed this boot) — it is not projected, so nothing to verify. + if !def.env.is_empty() { + continue; + } + let id = def.id.clone(); + match load_secret( + store, + def.env_ref.as_deref(), + |gen| harness_env_key(&id, gen), + &format!("harness:{id} env"), + ) { + Ok(_) => {} + Err(e) => { + eprintln!( + "buzz-desktop: harness-migration: extraction-verify failed for {}: {e} — \ + skipping artifact scrub this boot", + path.display() + ); + return false; + } + } + } + true +} + +/// Scrub plaintext-bearing artifacts in the custom-harness dir: `*.json.bak` +/// backups (from the save path's backup-swap) and atomic-write temp siblings. +/// +/// - Parseable backup → strip `env` and rewrite at `0o600` (non-secret fields +/// survive for manual recovery). +/// - Unparseable backup or atomic-write temp → delete (may carry plaintext). +/// - Live `*.json` files are NOT touched here — Phase 1 already stripped them. +/// - Never follows a symlink that escapes the dir. +fn cleanup_harness_artifacts(dir: &Path) { + let canonical_dir = match std::fs::canonicalize(dir) { + Ok(p) => p, + Err(e) => { + eprintln!( + "buzz-desktop: harness-migration: cannot canonicalize {}: {e}", + dir.display() + ); + return; + } + }; + let entries = match std::fs::read_dir(&canonical_dir) { + Ok(e) => e, + Err(e) => { + eprintln!( + "buzz-desktop: harness-migration: cannot read {}: {e}", + canonical_dir.display() + ); + return; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + let real_path = match std::fs::canonicalize(&path) { + Ok(p) => p, + Err(_) => path.clone(), + }; + if !real_path.starts_with(&canonical_dir) { + eprintln!( + "buzz-desktop: harness-migration: skipping symlink escape: {}", + path.display() + ); + continue; + } + let meta = match std::fs::symlink_metadata(&path) { + Ok(m) => m, + Err(_) => continue, + }; + if !meta.is_file() { + continue; + } + let fname = match path.file_name().and_then(|n| n.to_str()) { + Some(s) => s.to_string(), + None => continue, + }; + + if is_atomic_write_temp(&fname) { + if let Err(e) = std::fs::remove_file(&real_path) { + eprintln!("buzz-desktop: harness-migration: cannot remove temp {fname}: {e}"); + } + continue; + } + if is_harness_backup(&fname) { + scrub_backup_file(&real_path, &fname); + } + } +} + +/// Strip the `env` field from a parseable harness backup and rewrite it at +/// `0o600`; delete the file when it cannot be parsed (may carry plaintext). +fn scrub_backup_file(path: &Path, fname: &str) { + let content = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => { + eprintln!("buzz-desktop: harness-migration: cannot read backup {fname}: {e}"); + return; + } + }; + match strip_env_from_json(&content) { + Some(clean) => { + if let Err(e) = + crate::managed_agents::storage::atomic_write_json_restricted(path, clean.as_bytes()) + { + eprintln!( + "buzz-desktop: harness-migration: cannot write scrubbed backup {fname}: {e}" + ); + } + } + None => { + if let Err(e) = std::fs::remove_file(path) { + eprintln!("buzz-desktop: harness-migration: cannot remove unparseable backup {fname}: {e}"); + } + } + } +} + +/// Remove the `env` object from a harness JSON document, returning the +/// re-serialized string. `None` when the content is not a JSON object. +fn strip_env_from_json(content: &str) -> Option { + use serde_json::Value; + let mut v: Value = serde_json::from_str(content).ok()?; + let Value::Object(map) = &mut v else { + return None; + }; + map.remove("env"); + serde_json::to_string_pretty(&v).ok() +} + +/// Enumerate the live `*.json` files in `dir` (excludes `.json.bak`, temps, and +/// any non-`.json` sibling). Returns an empty vec on an unreadable directory. +fn harness_json_files(dir: &Path) -> Vec { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json")) + .collect() +} + +/// Parse one harness file with a raw (NON-hydrating) serde read. Warnings are +/// swallowed — a malformed live file is left in place for the user to fix, +/// exactly as the loader treats it. +fn read_definition(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +/// True when `fname` is a harness backup: `.json.bak`. +fn is_harness_backup(fname: &str) -> bool { + match fname.split_once(".json") { + Some((_, after)) => after.contains(".bak"), + None => false, + } +} + +/// True when `fname` looks like an atomic-write-file temp: no extension, at +/// least 8 chars, all ASCII hex digits. +fn is_atomic_write_temp(fname: &str) -> bool { + !fname.contains('.') && fname.len() >= 8 && fname.chars().all(|c| c.is_ascii_hexdigit()) +} + +#[cfg(test)] +#[path = "migration_harness_secrets_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/migration/migration_harness_secrets_tests.rs b/desktop/src-tauri/src/migration/migration_harness_secrets_tests.rs new file mode 100644 index 00000000000..afd5934833d --- /dev/null +++ b/desktop/src-tauri/src/migration/migration_harness_secrets_tests.rs @@ -0,0 +1,472 @@ +//! Tests for boot-time custom-harness secret extraction. +//! +//! The module under test is generic over [`ProjectionStore`], so the full +//! extract → verify → scrub flow runs against a fake keyring and a tempdir +//! with no `AppHandle` — the precedent `migration_secrets` could only unit-test +//! its helpers because its extraction was not store-injected. +//! +//! Load-bearing properties proven here: +//! * inline `env` is projected to the keyring and stripped from disk (no +//! plaintext survives), recoverable through the same coordinate hydrate reads; +//! * the plaintext-artifact scrub runs ONLY when every projected ref reads +//! back cleanly — a dangling ref preserves the backup as last-recoverable +//! plaintext, and a later boot scrubs it once the ref resolves; +//! * extraction is idempotent across launches (a re-read projected record +//! mints no new generation). + +use super::*; +use crate::managed_agents::secret_projection::{deserialize_env_map, serialize_env_map}; +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; + +// ── Fake store (mirrors secret_seam_tests::FakeProjectionStore) ──────────── + +struct FakeProjectionStore { + data: RefCell>, +} + +impl FakeProjectionStore { + fn new() -> Self { + Self { + data: RefCell::new(HashMap::new()), + } + } + fn with_entry(self, key: &str, value: &str) -> Self { + self.data + .borrow_mut() + .insert(key.to_string(), value.to_string()); + self + } + fn insert(&self, key: &str, value: &str) { + self.data + .borrow_mut() + .insert(key.to_string(), value.to_string()); + } + fn contains(&self, key: &str) -> bool { + self.data.borrow().contains_key(key) + } + fn len(&self) -> usize { + self.data.borrow().len() + } +} + +impl ProjectionStore for FakeProjectionStore { + fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String> { + self.data + .borrow_mut() + .insert(key.to_string(), value.to_string()); + Ok(()) + } + fn load_key(&self, key: &str) -> Result, String> { + Ok(self.data.borrow().get(key).cloned()) + } + fn load_all(&self) -> Result>, String> { + Ok(Some(self.data.borrow().clone())) + } + fn store_batch(&self, entries: &HashMap) -> Result<(), String> { + for (k, v) in entries { + self.data.borrow_mut().insert(k.clone(), v.clone()); + } + Ok(()) + } + fn remove_batch(&self, keys: &[&str]) -> Result<(), String> { + for k in keys { + self.data.borrow_mut().remove(*k); + } + Ok(()) + } +} + +// ── Builders ─────────────────────────────────────────────────────────────── + +fn env_of(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +fn harness_def( + id: &str, + env: BTreeMap, + env_ref: Option, +) -> HarnessDefinition { + HarnessDefinition { + id: id.to_string(), + label: format!("{id} label"), + command: format!("{id}-bin"), + args: vec![], + env, + env_ref, + env_unavailable: false, + install_instructions_url: String::new(), + install_hint: String::new(), + } +} + +/// Write `def` to `dir/.json` and return the path. The stem defaults +/// to the id but may differ to exercise hand-authored filenames. +fn write_harness_file(dir: &Path, file_stem: &str, def: &HarnessDefinition) -> PathBuf { + let path = dir.join(format!("{file_stem}.json")); + std::fs::write(&path, serde_json::to_string_pretty(def).unwrap()).unwrap(); + path +} + +fn read_back(path: &Path) -> HarnessDefinition { + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +// ── Phase 1: extraction ────────────────────────────────────────────────────── + +#[test] +fn test_inline_env_projected_and_file_stripped_of_plaintext() { + let store = FakeProjectionStore::new(); + let dir = tempfile::tempdir().unwrap(); + let env = env_of(&[("ANTHROPIC_API_KEY", "sk-ant-secret")]); + let path = write_harness_file( + dir.path(), + "env-harness", + &harness_def("env-harness", env.clone(), None), + ); + + migrate_harness_secrets_in_dir(&store, dir.path()); + + // Disk: env stripped, ref set, no plaintext byte left behind. + let on_disk = read_back(&path); + assert!( + on_disk.env.is_empty(), + "inline env must be stripped from disk" + ); + let gen = on_disk + .env_ref + .expect("env_ref must be set after projection"); + let raw = std::fs::read_to_string(&path).unwrap(); + assert!( + !raw.contains("sk-ant-secret"), + "plaintext secret must not survive on disk" + ); + + // Keyring: the coordinate hydrate reads holds the projected env verbatim. + let key = harness_env_key("env-harness", &gen); + let stored = store + .load_key(&key) + .unwrap() + .expect("keyring must hold the projected env"); + assert_eq!(deserialize_env_map(&stored).unwrap(), env); +} + +#[test] +fn test_empty_env_is_noop_no_projection_no_rewrite() { + let store = FakeProjectionStore::new(); + let dir = tempfile::tempdir().unwrap(); + let path = write_harness_file( + dir.path(), + "bare", + &harness_def("bare", BTreeMap::new(), None), + ); + let before = std::fs::read_to_string(&path).unwrap(); + + migrate_harness_secrets_in_dir(&store, dir.path()); + + assert_eq!(store.len(), 0, "empty env must not mint a generation"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + before, + "a no-env harness file must be left byte-for-byte unchanged" + ); +} + +#[test] +fn test_rewrites_original_path_never_id_derived_name() { + // A hand-authored filename may differ from the harness id; relocating to + // .json would orphan the original and duplicate the entry. + let store = FakeProjectionStore::new(); + let dir = tempfile::tempdir().unwrap(); + let env = env_of(&[("K", "v")]); + let authored = write_harness_file( + dir.path(), + "hand-authored", + &harness_def("realid", env, None), + ); + + migrate_harness_secrets_in_dir(&store, dir.path()); + + assert!( + authored.exists(), + "the original hand-authored file must be rewritten in place" + ); + assert!( + !dir.path().join("realid.json").exists(), + "migration must not relocate to an id-derived filename" + ); + assert!( + read_back(&authored).env_ref.is_some(), + "the original file must carry the new ref" + ); +} + +// ── Phase 2: plaintext-artifact scrub, gated on verified extraction ────────── + +#[test] +fn test_parseable_backup_scrubbed_when_extraction_verified() { + let store = FakeProjectionStore::new(); + let dir = tempfile::tempdir().unwrap(); + write_harness_file( + dir.path(), + "h", + &harness_def("h", env_of(&[("K", "v")]), None), + ); + // A backup left by the save path still carries plaintext env. + let bak = dir.path().join("h.json.bak"); + std::fs::write( + &bak, + r#"{"id":"h","label":"h","command":"h-bin","env":{"ANTHROPIC_API_KEY":"sk-plaintext"}}"#, + ) + .unwrap(); + + migrate_harness_secrets_in_dir(&store, dir.path()); + + assert!( + bak.exists(), + "a parseable backup must survive as a scrubbed file" + ); + let scrubbed = std::fs::read_to_string(&bak).unwrap(); + assert!( + !scrubbed.contains("sk-plaintext"), + "backup plaintext env must be scrubbed" + ); + assert!( + scrubbed.contains("\"command\""), + "non-secret backup fields must survive" + ); +} + +#[test] +fn test_unparseable_backup_deleted_when_verified() { + let store = FakeProjectionStore::new(); + let dir = tempfile::tempdir().unwrap(); + write_harness_file( + dir.path(), + "h", + &harness_def("h", env_of(&[("K", "v")]), None), + ); + let bak = dir.path().join("h.json.bak"); + std::fs::write(&bak, b"not valid json holding sk-plaintext").unwrap(); + + migrate_harness_secrets_in_dir(&store, dir.path()); + + assert!( + !bak.exists(), + "an unparseable backup may hold plaintext and must be deleted" + ); +} + +#[test] +fn test_atomic_write_temp_deleted_when_verified() { + let store = FakeProjectionStore::new(); + let dir = tempfile::tempdir().unwrap(); + write_harness_file( + dir.path(), + "h", + &harness_def("h", env_of(&[("K", "v")]), None), + ); + let temp = dir.path().join("deadbeefcafe0123"); + std::fs::write(&temp, b"interrupted write with sk-plaintext").unwrap(); + + migrate_harness_secrets_in_dir(&store, dir.path()); + + assert!( + !temp.exists(), + "an atomic-write temp sibling must be deleted" + ); +} + +#[test] +fn test_scrub_skipped_when_ref_unreachable_backup_survives_intact() { + // A live file already projected on a prior boot (empty inline + ref) whose + // keyring entry is now unreachable: the backup may be the last recoverable + // plaintext, so it must NOT be scrubbed this boot. + let store = FakeProjectionStore::new(); // does NOT hold the referenced gen + let dir = tempfile::tempdir().unwrap(); + write_harness_file( + dir.path(), + "h", + &harness_def("h", BTreeMap::new(), Some("lostgen".to_string())), + ); + let bak = dir.path().join("h.json.bak"); + std::fs::write( + &bak, + r#"{"id":"h","label":"h","command":"h-bin","env":{"ANTHROPIC_API_KEY":"sk-plaintext"}}"#, + ) + .unwrap(); + + migrate_harness_secrets_in_dir(&store, dir.path()); + + assert!( + bak.exists(), + "a dangling ref must leave the plaintext backup in place" + ); + assert!( + std::fs::read_to_string(&bak) + .unwrap() + .contains("sk-plaintext"), + "the backup must retain its plaintext while the ref is unrecoverable" + ); +} + +#[test] +fn test_scrub_retried_once_ref_becomes_reachable() { + let store = FakeProjectionStore::new(); + let dir = tempfile::tempdir().unwrap(); + write_harness_file( + dir.path(), + "h", + &harness_def("h", BTreeMap::new(), Some("g1".to_string())), + ); + let bak = dir.path().join("h.json.bak"); + std::fs::write( + &bak, + r#"{"id":"h","label":"h","command":"h-bin","env":{"ANTHROPIC_API_KEY":"sk-plaintext"}}"#, + ) + .unwrap(); + + // Boot 1: keyring unreachable for g1 → scrub skipped, backup preserved. + migrate_harness_secrets_in_dir(&store, dir.path()); + assert!( + std::fs::read_to_string(&bak) + .unwrap() + .contains("sk-plaintext"), + "scrub must be skipped while the ref is unreachable" + ); + + // Keyring recovers the entry; Boot 2: verify passes → scrub runs. + store.insert( + &harness_env_key("h", "g1"), + &serialize_env_map(&env_of(&[("K", "v")])).unwrap(), + ); + migrate_harness_secrets_in_dir(&store, dir.path()); + assert!( + !std::fs::read_to_string(&bak) + .unwrap() + .contains("sk-plaintext"), + "scrub must run on the boot where the ref finally resolves" + ); +} + +// ── Idempotence ────────────────────────────────────────────────────────────── + +#[test] +fn test_two_launches_mint_no_second_generation() { + let store = FakeProjectionStore::new(); + let dir = tempfile::tempdir().unwrap(); + let path = write_harness_file( + dir.path(), + "h", + &harness_def("h", env_of(&[("K", "v")]), None), + ); + + migrate_harness_secrets_in_dir(&store, dir.path()); + let gen1 = read_back(&path).env_ref.expect("gen after first launch"); + let keys_after_1 = store.len(); + + migrate_harness_secrets_in_dir(&store, dir.path()); + let gen2 = read_back(&path).env_ref.expect("gen after second launch"); + + assert_eq!( + gen1, gen2, + "a re-read projected record must keep its generation" + ); + assert_eq!( + store.len(), + keys_after_1, + "second launch must not mint a new generation" + ); +} + +// ── Helper predicates and JSON strip ───────────────────────────────────────── + +#[test] +fn test_is_harness_backup_matches_bak_variants() { + assert!(is_harness_backup("h.json.bak")); + assert!(is_harness_backup("h.json.bak-20260813-000000")); + assert!(is_harness_backup("h.json.bak.20260813")); + assert!(!is_harness_backup("h.json")); + assert!(!is_harness_backup("h.json.tmp")); +} + +#[test] +fn test_is_atomic_write_temp_matches_hex_names_only() { + assert!(is_atomic_write_temp("deadbeefcafe0123")); + assert!(!is_atomic_write_temp("h.json")); // has extension + assert!(!is_atomic_write_temp("abc")); // too short + assert!(!is_atomic_write_temp("not-hex-name")); // non-hex +} + +#[test] +fn test_strip_env_from_json_removes_only_env() { + let clean = + strip_env_from_json(r#"{"id":"h","command":"h-bin","env":{"K":"secret"}}"#).unwrap(); + let v: serde_json::Value = serde_json::from_str(&clean).unwrap(); + assert!(v.get("env").is_none(), "env must be removed"); + assert_eq!(v.get("command").and_then(|c| c.as_str()), Some("h-bin")); + assert_eq!(v.get("id").and_then(|c| c.as_str()), Some("h")); +} + +#[test] +fn test_strip_env_from_json_returns_none_on_non_object() { + assert!(strip_env_from_json("not json").is_none()); + assert!(strip_env_from_json("[1,2,3]").is_none()); +} + +#[test] +fn test_extraction_verified_true_when_no_projected_refs() { + // A directory of purely inline-fallback (non-empty env, no ref) files has + // nothing to hydrate, so verification passes and the scrub may proceed. + let store = FakeProjectionStore::new(); + let dir = tempfile::tempdir().unwrap(); + write_harness_file( + dir.path(), + "a", + &harness_def("a", env_of(&[("K", "v")]), None), + ); + assert!(extraction_verified(&store, dir.path())); +} + +#[cfg(unix)] +#[test] +fn test_cleanup_skips_symlink_escaping_dir() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let target = outside.path().join("secret.txt"); + std::fs::write(&target, b"outside-secret").unwrap(); + + // A backup-shaped symlink pointing outside the harness dir. + symlink(&target, dir.path().join("escape.json.bak")).unwrap(); + + cleanup_harness_artifacts(dir.path()); + + assert!( + target.exists(), + "cleanup must not follow a symlink escaping the dir" + ); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "outside-secret"); +} + +// A pre-populated fake keeps the with_entry builder exercised for future use. +#[test] +fn test_verify_passes_for_reachable_pre_projected_ref() { + let dir = tempfile::tempdir().unwrap(); + write_harness_file( + dir.path(), + "h", + &harness_def("h", BTreeMap::new(), Some("g1".to_string())), + ); + let store = FakeProjectionStore::new().with_entry( + &harness_env_key("h", "g1"), + &serialize_env_map(&env_of(&[("K", "v")])).unwrap(), + ); + assert!(store.contains(&harness_env_key("h", "g1"))); + assert!(extraction_verified(&store, dir.path())); +} diff --git a/desktop/src-tauri/src/migration/migration_secrets.rs b/desktop/src-tauri/src/migration/migration_secrets.rs new file mode 100644 index 00000000000..a5a9add603d --- /dev/null +++ b/desktop/src-tauri/src/migration/migration_secrets.rs @@ -0,0 +1,861 @@ +//! Boot-time secret extraction: lifts inline env vars, auth tags, and +//! provider configs from JSON into the OS keyring via the generation-reference +//! protocol, then runs the two-cycle GC sweeps and artifact cleanup. +//! +//! Runs ONCE at the END of `run_boot_migrations_inner` (after +//! `materialize_agent_runtimes`) so raw-JSON migrations see inline values. +//! +//! Idempotent across launches, but NOT by "skipping records that already have +//! refs": it re-runs the field-granular migration seam over every record on +//! every launch. That seam projects only a non-empty inline value and +//! otherwise preserves an existing ref — it never re-reads an already-projected +//! record's empty inline field as a user-clear, so a second launch is a no-op +//! that leaves every committed ref intact (the strip-on-save seam would have +//! cleared them). See +//! [`crate::managed_agents::secret_seam::migrate_all_secrets_for_records`]. + +use tauri::Manager; + +/// Extract inline secrets (env vars, auth tags, provider configs) from JSON +/// into the keyring. Also runs the two-cycle GC sweeps and, when the keyring +/// is reachable, Phase 2 artifact cleanup. +pub(super) fn migrate_inline_secrets_to_keyring(app: &tauri::AppHandle) { + // Serialize the entire extraction + global save + GC against in-process + // saves that mutate the same JSON stores and keyring blob. GC's final JSON + // read-back and `remove_batch` MUST be indivisible against a save sitting + // between its keyring write and its atomic JSON commit — otherwise the + // sweep could read stale JSON, decide a just-written generation is + // unreferenced, and delete it. The store lock is the same one every save + // path (agent store, global config, card mint) takes. + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .unwrap_or_else(|e| e.into_inner()); + + // Extract inline secrets → keyring for the agent store. + let extraction_ok = if let Ok(mut records) = + crate::managed_agents::storage::load_agent_store_raw(app) + { + // Cross-process transaction lock: held across BOTH the generation + // writes (inside `migrate_inline_secrets_in_records`) and the JSON + // commit below, so a second Desktop process's GC cannot delete a + // just-written generation in the window before its ref lands in JSON. + // The in-process `managed_agents_store_lock` above only serializes THIS + // process; the file lock closes the cross-process interleave. + match crate::managed_agents::storage::acquire_secret_txn_lock(app) { + Ok(_txn) => { + let changed = migrate_inline_secrets_in_records(app, &mut records); + if changed { + if let Err(e) = + crate::managed_agents::storage::write_agent_store_raw(app, &records) + { + eprintln!("buzz-desktop: boot-migration: failed to write agent store: {e}"); + false + } else { + true + } + } else { + true + } + } + Err(e) => { + eprintln!( + "buzz-desktop: boot-migration: could not acquire secret transaction lock \ + ({e}); skipping agent-store extraction this boot" + ); + false + } + } + } else { + eprintln!("buzz-desktop: boot-migration: could not load agent store for secret migration"); + false + }; + + // Extract inline secrets → keyring for the global config. + let global_ok = + if let Ok(global) = crate::managed_agents::global_config::load_global_agent_config(app) { + if !global.env_vars.is_empty() || global.env_vars_ref.is_none() { + if let Err(e) = + crate::managed_agents::global_config::save_global_agent_config(app, &global) + { + eprintln!("buzz-desktop: boot-migration: global config save failed: {e}"); + false + } else { + true + } + } else { + true + } + } else { + false + }; + + run_secret_gc(app); + + // Phase 2: artifact cleanup — only when extraction was verified. + // + // "Verified" means: the agent store and global config were written + // successfully this boot AND every referenced generation reads back + // cleanly (no secrets_unavailable flags after a fresh reload). A bare + // keyring-handle check (`agent_secret_store_pub().is_some()`) does NOT + // suffice — the handle can be present while individual entries fail to + // read back, which would let cleanup delete the only copy of a secret. + if extraction_ok && global_ok && extraction_verified(app) { + if let Ok(agents_dir) = crate::managed_agents::storage::managed_agents_base_dir(app) { + cleanup_secret_artifacts(&agents_dir); + } + // Also clean the legacy Sprout app-data agents dir if it still exists. + // `migrate_legacy_app_data_dir` copies files from there but never removes + // the source — it can hold the original plaintext managed-agents.json and + // its backups indefinitely. Run the full cleanup on that dir, including + // its live managed-agents.json. + if let Ok(current_dir) = app.path().app_data_dir() { + if let Some(legacy_dir) = super::legacy_app_data_dir(¤t_dir) { + let legacy_agents_dir = legacy_dir.join("agents"); + if legacy_agents_dir.exists() { + cleanup_secret_artifacts(&legacy_agents_dir); + // The legacy live file (managed-agents.json, not a backup) + // is not touched by the generic cleanup sweep above — it + // handles backups and temps, not the live file. Explicitly + // scrub or remove it now that we have verified the + // destination projection is secret-free and hydratable. + scrub_legacy_live_file(&legacy_agents_dir.join("managed-agents.json")); + } + } + } + } +} + +/// Returns `true` when a fresh read-back of both live stores shows that every +/// referenced generation hydrates without error. Used to gate Phase 2 +/// artifact cleanup: we must never delete the last copy of a secret. +fn extraction_verified(app: &tauri::AppHandle) -> bool { + // Verify agent store: all records with refs must hydrate cleanly. + let store = match crate::managed_agents::storage::agent_secret_store_pub() { + Some(s) => s, + None => return false, // no keyring backend → no extraction → not verified + }; + let mut records = match crate::managed_agents::storage::load_agent_store_raw(app) { + Ok(r) => r, + Err(_) => return false, + }; + let unavailable = + crate::managed_agents::secret_seam::hydrate_all_secrets_for_records(store, &mut records); + if !unavailable.is_empty() { + eprintln!( + "buzz-desktop: extraction-verify: {} record(s) have unavailable secrets — \ + skipping Phase 2 cleanup", + unavailable.len() + ); + return false; + } + // Verify global config: if a ref is present it must hydrate cleanly (Err + // from load_global_agent_config means the ref exists but the entry is + // missing/corrupt). + let global_ok = match crate::managed_agents::global_config::load_global_agent_config(app) { + Ok(_) => true, + Err(e) => { + eprintln!( + "buzz-desktop: extraction-verify: global config ref unreadable — \ + skipping Phase 2 cleanup: {e}" + ); + false + } + }; + global_ok +} + +/// Scrub or remove the legacy live `managed-agents.json` after the destination +/// projection has been verified. Mirrors the backup scrub in +/// [`cleanup_secret_artifacts`] but targets the primary live file rather than +/// backup siblings. +/// +/// - Parseable: strip secret fields and overwrite at 0o600. +/// - Unparseable or does not exist: delete (or skip if not present). +/// - Errors are logged; callers must never panic on this path. +fn scrub_legacy_live_file(path: &std::path::Path) { + if !path.exists() { + return; + } + let content = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => { + eprintln!( + "buzz-desktop: artifact-cleanup: cannot read legacy live file {}: {e}", + path.display() + ); + return; + } + }; + match strip_secrets_from_json(&content) { + Some(clean) => { + match crate::managed_agents::storage::atomic_write_json_restricted(path, clean.as_bytes()) { + Ok(()) => eprintln!( + "buzz-desktop: artifact-cleanup: scrubbed legacy live file {}", + path.display() + ), + Err(e) => eprintln!( + "buzz-desktop: artifact-cleanup: cannot write scrubbed legacy live file {}: {e}", + path.display() + ), + } + } + None => { + if let Err(e) = std::fs::remove_file(path) { + eprintln!( + "buzz-desktop: artifact-cleanup: cannot remove unparseable legacy live file {}: {e}", + path.display() + ); + } else { + eprintln!( + "buzz-desktop: artifact-cleanup: removed unparseable legacy live file {}", + path.display() + ); + } + } + } +} + +/// Run the two-cycle GC sweeps for the secret projection. +pub(crate) fn run_secret_gc(app: &tauri::AppHandle) { + let agents_path = match crate::managed_agents::storage::managed_agents_store_path(app) { + Ok(p) => p, + Err(_) => return, + }; + let global_path = match app.path().app_data_dir() { + Ok(d) => d.join("agents/global-agent-config.json"), + Err(_) => return, + }; + if let Some(store) = crate::managed_agents::storage::agent_secret_store_pub() { + // Cross-process transaction lock: hold across BOTH sweeps' live-ref + // read → blob mutation so a second Desktop process's in-flight save + // cannot commit a JSON ref between this GC's read and its delete. The + // guard releases when this function returns. Leaf-level: GC is never + // called while another secret transaction lock is held (boot migration + // releases its extraction and global-save spans before calling here). + let _txn = match crate::managed_agents::storage::acquire_secret_txn_lock(app) { + Ok(guard) => guard, + Err(e) => { + eprintln!( + "buzz-desktop: secret GC: could not acquire transaction lock ({e}), skipping" + ); + return; + } + }; + // Two-cycle GC: DELETE candidates from the PREVIOUS boot first, THEN + // mark new candidates for this boot. This ordering is the safety + // invariant: a generation written and verified in boot N is only ever + // eligible for deletion in boot N+2 or later, giving a full boot cycle + // of grace for any cross-process save that is sitting between its + // keyring write and its JSON commit when GC runs. + crate::managed_agents::secret_projection::delete_gc_candidates( + store, + &agents_path, + &global_path, + ); + crate::managed_agents::secret_projection::mark_gc_candidates( + store, + &agents_path, + &global_path, + ); + } +} + +/// Phase 2 artifact cleanup, run after a verified extraction boot. +/// +/// Invariants: +/// - ONLY called when the keyring is reachable (caller gate). +/// - NEVER follows symlinks outside `agents_dir`. +/// - Parseable `*.bak-*` / `*.bak` backup files are re-serialized with all +/// secret fields stripped (env_vars, auth_tag, private_key_nsec, provider +/// config). +/// - Unparseable / `.invalid` / atomic-write temp siblings are DELETED — they +/// cannot be recovered and may carry plaintext credentials. +/// - All JSON-shaped files in `agents_dir` (top level only) receive a 0o600 +/// permission sweep. +/// - Errors on individual files are logged and never abort the sweep. +pub(crate) fn cleanup_secret_artifacts(agents_dir: &std::path::Path) { + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + // Canonicalize the agents dir so we have a stable root for escape checks. + let canonical_dir = match std::fs::canonicalize(agents_dir) { + Ok(p) => p, + Err(e) => { + eprintln!("buzz-desktop: artifact-cleanup: cannot canonicalize agents dir: {e}"); + return; + } + }; + + let entries = match std::fs::read_dir(&canonical_dir) { + Ok(e) => e, + Err(e) => { + eprintln!("buzz-desktop: artifact-cleanup: cannot read agents dir: {e}"); + return; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + + // Resolve the real path and reject anything that escapes agents_dir. + let real_path = match std::fs::canonicalize(&path) { + Ok(p) => p, + Err(_) => path.clone(), // dangling symlink or unresolvable — skip + }; + if !real_path.starts_with(&canonical_dir) { + eprintln!( + "buzz-desktop: artifact-cleanup: skipping symlink escape: {}", + path.display() + ); + continue; + } + + let meta = match std::fs::symlink_metadata(&path) { + Ok(m) => m, + Err(_) => continue, + }; + if !meta.is_file() { + continue; + } + + let fname = match path.file_name().and_then(|n| n.to_str()) { + Some(s) => s.to_string(), + None => continue, + }; + + // ── 0o600 sweep ────────────────────────────────────────────────── + // Tighten permissions on every store-shaped file up front, so even a + // backup whose scrub write later fails is not left group/world-readable + // with plaintext. Uses the same backup recognizer as the scrub below. + #[cfg(unix)] + if fname.ends_with(".json") + || is_backup_filename(&fname) + || fname.ends_with(".json.invalid") + { + let _ = std::fs::set_permissions(&real_path, std::fs::Permissions::from_mode(0o600)); + } + + // ── Atomic-write temp siblings ──────────────────────────────────── + // `atomic-write-file` names temps as random hex strings with no + // extension on Unix. Identify and delete them. + if is_atomic_write_temp(&fname) { + if let Err(e) = std::fs::remove_file(&real_path) { + eprintln!("buzz-desktop: artifact-cleanup: cannot remove temp file {fname}: {e}"); + } else { + eprintln!("buzz-desktop: artifact-cleanup: removed stale temp file {fname}"); + } + continue; + } + + // ── .invalid files ──────────────────────────────────────────────── + if fname.ends_with(".invalid") { + if let Err(e) = std::fs::remove_file(&real_path) { + eprintln!( + "buzz-desktop: artifact-cleanup: cannot remove .invalid file {fname}: {e}" + ); + } else { + eprintln!("buzz-desktop: artifact-cleanup: removed .invalid artifact {fname}"); + } + continue; + } + + // ── Backup files (.bak-*, .bak) ─────────────────────────────────── + if is_backup_filename(&fname) { + scrub_backup_file(&real_path, &fname); + } + } +} + +/// Returns true when `fname` looks like an atomic-write-file temp: no +/// extension, at least 8 characters, all ASCII hex digits. +fn is_atomic_write_temp(fname: &str) -> bool { + !fname.contains('.') && fname.len() >= 8 && fname.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Returns true when `fname` is a backup file we should scrub. +/// +/// Recognizes every managed-store backup naming family this repo produces — +/// each is `.json` with a backup suffix appended, and each can carry +/// pre-projection plaintext (env vars, auth tags, nsecs, provider config): +/// +/// - `managed-agents.json.bak`, `.bak-`, `.bak.` +/// - `managed-agents.json.pre-backfill.bak` ([`crate::migration::backfill`]) +/// - `managed-agents.json.pre-team-suffix-strip.bak` +/// ([`crate::migration::team_suffix`]) +/// - `personas.json.bak` ([`crate::migration::fold`]) / `teams.json.bak-*` +/// +/// The match is structural rather than an enumerated suffix list: a name +/// belonging to one of our stores whose `.json` base is followed by a `.bak` +/// marker anywhere. This catches a new `.json..bak` producer +/// without another edit here. Deliberately excludes the live `.json` +/// (no `.bak` after `.json`) and `.invalid` artifacts (handled separately). +fn is_backup_filename(fname: &str) -> bool { + let is_ours = fname.starts_with("managed-agents") + || fname.starts_with("personas") + || fname.starts_with("teams"); + let Some((_, after_json)) = fname.split_once(".json") else { + return false; + }; + is_ours && after_json.contains(".bak") +} + +/// Strip secret fields from a parseable backup and overwrite it at 0o600. +/// Delete the file when unparseable (may carry plaintext credentials). +fn scrub_backup_file(path: &std::path::Path, fname: &str) { + let content = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => { + eprintln!("buzz-desktop: artifact-cleanup: cannot read backup {fname}: {e}"); + return; + } + }; + + match strip_secrets_from_json(&content) { + Some(clean) => { + match crate::managed_agents::storage::atomic_write_json_restricted( + path, + clean.as_bytes(), + ) { + Ok(()) => eprintln!("buzz-desktop: artifact-cleanup: scrubbed backup {fname}"), + Err(e) => eprintln!( + "buzz-desktop: artifact-cleanup: cannot write scrubbed backup {fname}: {e}" + ), + } + } + None => { + // Unparseable → delete. + if let Err(e) = std::fs::remove_file(path) { + eprintln!( + "buzz-desktop: artifact-cleanup: cannot remove unparseable backup {fname}: {e}" + ); + } else { + eprintln!("buzz-desktop: artifact-cleanup: removed unparseable backup {fname}"); + } + } + } +} + +/// Attempt to strip secret fields from a JSON document. +/// +/// Handles two shapes: +/// - Array of agent records → strips `env_vars`, `auth_tag`, +/// `private_key_nsec`, and `BackendKind::Provider.config`. +/// - Object (global-agent-config) → strips `env_vars`. +/// +/// Returns `None` when the content cannot be parsed as either shape. +fn strip_secrets_from_json(content: &str) -> Option { + use serde_json::Value; + + let mut v: Value = serde_json::from_str(content).ok()?; + match &mut v { + Value::Array(records) => { + for record in records.iter_mut() { + if let Value::Object(map) = record { + map.remove("env_vars"); + map.remove("auth_tag"); + map.remove("private_key_nsec"); + // Strip BackendKind::Provider.config. + // BackendKind uses #[serde(tag = "type", rename_all = "snake_case")], + // so provider is stored as {"type": "provider", "id": "...", "config": {...}}. + if let Some(Value::Object(backend)) = map.get_mut("backend") { + let is_provider = backend + .get("type") + .and_then(Value::as_str) + .map(|t| t == "provider") + .unwrap_or(false); + if is_provider { + backend.remove("config"); + } + } + } + } + } + Value::Object(map) => { + map.remove("env_vars"); + } + _ => return None, + } + serde_json::to_string_pretty(&v).ok() +} + +/// Boot-migrate inline secrets to the keyring using the field-granular +/// migration seam — NOT the strip-on-save seam. +/// +/// This function runs on EVERY launch over records read straight off disk. +/// After the first launch those records are already projected: their inline +/// fields are empty and their `*_ref`s point at live generations. The +/// strip-on-save seam would read an empty inline field as a deliberate +/// user-clear and drop the ref (W1: silent credential loss on the second +/// launch, then GC deletes the orphaned generation). The migration seam does +/// not: it only ever projects a non-empty inline value or preserves an +/// existing ref, and never clears one it did not write. See +/// [`crate::managed_agents::secret_seam::migrate_all_secrets_for_records`]. +fn migrate_inline_secrets_in_records( + _app: &tauri::AppHandle, + records: &mut [crate::managed_agents::ManagedAgentRecord], +) -> bool { + let Some(store) = crate::managed_agents::storage::agent_secret_store_pub() else { + return false; + }; + crate::managed_agents::secret_seam::migrate_all_secrets_for_records(store, records) +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_atomic_write_temp_recognizes_hex_names() { + assert!(is_atomic_write_temp("deadbeefcafe0123")); + assert!(!is_atomic_write_temp("managed-agents.json")); + assert!(!is_atomic_write_temp("abc")); // too short + assert!(!is_atomic_write_temp("deadbeef.tmp")); // has extension + assert!(!is_atomic_write_temp("not-hex-at-all")); + } + + #[test] + fn test_is_backup_filename_recognizes_backups() { + assert!(is_backup_filename( + "managed-agents.json.bak-20260608-175938" + )); + assert!(is_backup_filename("managed-agents.json.bak")); + assert!(is_backup_filename("personas.json.bak-20260608-175938")); + assert!(is_backup_filename("teams.json.bak-20260608-175938")); + assert!(!is_backup_filename("managed-agents.json")); + assert!(!is_backup_filename("managed-agents.json.invalid")); + assert!(!is_backup_filename("global-agent-config.json.bak-20260608")); // not in our set + } + + #[test] + fn test_is_backup_filename_recognizes_phase_suffixed_producers() { + // W4: the exact backup filenames this repo's own migrations produce. + // Each is `.json` with a phase/marker suffix appended — NOT the + // literal `.json.bak` the old recognizer required — so each survived + // the scrub with pre-projection plaintext (env vars, auth tags, nsecs, + // provider config) intact. + assert!( + is_backup_filename("managed-agents.json.pre-backfill.bak"), + "backfill.rs pre-migration backup must be recognized" + ); + assert!( + is_backup_filename("managed-agents.json.pre-team-suffix-strip.bak"), + "team_suffix.rs pre-migration backup must be recognized" + ); + assert!( + is_backup_filename("personas.json.bak"), + "the persona-fold backup must be recognized" + ); + // `.bak.` ordering (marker then stamp) is ours too. + assert!(is_backup_filename( + "managed-agents.json.bak.20260608-175938" + )); + // A future `.json..bak` producer is caught structurally, + // without another edit here — this is why the match is not an + // enumerated suffix list. + assert!(is_backup_filename( + "managed-agents.json.some-future-phase.bak" + )); + + // A phase-suffixed backup of a store we do NOT own is not ours to + // scrub, even though it is shaped identically. + assert!(!is_backup_filename("credentials.json.pre-backfill.bak")); + } + + #[test] + fn test_strip_secrets_from_json_strips_agent_records() { + let input = r#"[ + { + "pubkey": "abc123", + "name": "test-agent", + "env_vars": {"ANTHROPIC_API_KEY": "sk-ant-secret"}, + "auth_tag": "some-auth-tag", + "private_key_nsec": "nsec1secret", + "backend": {"type": "provider", "id": "anthropic", "config": {"key": "secret"}}, + "created_at": "2026-01-01", + "updated_at": "2026-01-01" + } + ]"#; + let stripped = strip_secrets_from_json(input).unwrap(); + let v: serde_json::Value = serde_json::from_str(&stripped).unwrap(); + let record = &v[0]; + assert!( + record.get("env_vars").is_none(), + "env_vars must be stripped" + ); + assert!( + record.get("auth_tag").is_none(), + "auth_tag must be stripped" + ); + assert!( + record.get("private_key_nsec").is_none(), + "private_key_nsec must be stripped" + ); + let provider_config = record.get("backend").and_then(|b| b.get("config")); + assert!( + provider_config.is_none(), + "provider config must be stripped" + ); + // Non-secret fields survive. + assert_eq!( + record.get("name").and_then(|n| n.as_str()), + Some("test-agent") + ); + } + + #[test] + fn test_strip_secrets_from_json_preserves_local_backend() { + let input = r#"[ + { + "pubkey": "abc123", + "name": "local-agent", + "backend": {"type": "local"}, + "created_at": "2026-01-01", + "updated_at": "2026-01-01" + } + ]"#; + let stripped = strip_secrets_from_json(input).unwrap(); + let v: serde_json::Value = serde_json::from_str(&stripped).unwrap(); + // Local backend must survive unchanged — no config field to strip. + let backend = v[0].get("backend").expect("backend must be present"); + assert_eq!(backend.get("type").and_then(|t| t.as_str()), Some("local")); + assert!( + backend.get("config").is_none(), + "local backend has no config" + ); + } + + #[test] + fn test_strip_secrets_from_json_strips_global_config() { + let input = r#"{"env_vars": {"KEY": "value"}, "other_field": "keep"}"#; + let stripped = strip_secrets_from_json(input).unwrap(); + let v: serde_json::Value = serde_json::from_str(&stripped).unwrap(); + assert!(v.get("env_vars").is_none(), "env_vars must be stripped"); + assert_eq!(v.get("other_field").and_then(|f| f.as_str()), Some("keep")); + } + + #[test] + fn test_strip_secrets_from_json_returns_none_on_unparseable() { + assert!(strip_secrets_from_json("not valid json").is_none()); + assert!(strip_secrets_from_json("").is_none()); + } + + // ── cleanup_secret_artifacts filesystem tests ───────────────────────── + + /// Atomic-write-file temp siblings (hex-only names, no extension) are + /// deleted during cleanup. These can carry plaintext if a write was + /// interrupted before the atomic rename. + #[test] + fn test_cleanup_deletes_atomic_write_temps() { + let dir = tempfile::tempdir().expect("tempdir"); + let temp_path = dir.path().join("deadbeefcafe0123"); + std::fs::write(&temp_path, b"leftover content").expect("write temp"); + assert!(temp_path.exists()); + cleanup_secret_artifacts(dir.path()); + assert!( + !temp_path.exists(), + "atomic-write temp must be deleted by cleanup" + ); + } + + /// Unparseable `.invalid` files are deleted during cleanup. + #[test] + fn test_cleanup_deletes_invalid_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let invalid_path = dir.path().join("managed-agents.json.invalid"); + std::fs::write(&invalid_path, b"not valid json with sk-ant-secret").expect("write"); + cleanup_secret_artifacts(dir.path()); + assert!( + !invalid_path.exists(), + ".invalid file must be deleted by cleanup" + ); + } + + /// A backup file with parseable content is scrubbed (secrets stripped) rather + /// than deleted. The file should survive but without the secret value. + #[test] + fn test_cleanup_scrubs_parseable_backup() { + let dir = tempfile::tempdir().expect("tempdir"); + let backup_path = dir.path().join("managed-agents.json.bak-20260608-175938"); + let content = r#"[{"pubkey":"abc","name":"test","env_vars":{"K":"v"},"created_at":"2026","updated_at":"2026"}]"#; + std::fs::write(&backup_path, content).expect("write backup"); + cleanup_secret_artifacts(dir.path()); + assert!(backup_path.exists(), "parseable backup must survive"); + let result = std::fs::read_to_string(&backup_path).expect("read"); + assert!( + !result.contains("\"env_vars\""), + "secrets must be stripped from parseable backup" + ); + } + + /// W4 end-to-end: the phase-suffixed producer backups (`pre-backfill.bak`, + /// `pre-team-suffix-strip.bak`) carry pre-projection plaintext and must be + /// scrubbed by the full cleanup sweep — not just matched by the recognizer. + /// Before the structural recognizer these names lacked the literal + /// `.json.bak` substring and survived cleanup with secrets intact. + #[test] + fn test_cleanup_scrubs_phase_suffixed_producer_backups() { + let dir = tempfile::tempdir().expect("tempdir"); + let content = r#"[{"pubkey":"abc","name":"test","env_vars":{"ANTHROPIC_API_KEY":"sk-ant-secret"},"auth_tag":"tag-secret","created_at":"2026","updated_at":"2026"}]"#; + for name in [ + "managed-agents.json.pre-backfill.bak", + "managed-agents.json.pre-team-suffix-strip.bak", + ] { + let path = dir.path().join(name); + std::fs::write(&path, content).expect("write producer backup"); + + cleanup_secret_artifacts(dir.path()); + + assert!(path.exists(), "{name} must survive as a scrubbed backup"); + let result = std::fs::read_to_string(&path).expect("read"); + assert!( + !result.contains("sk-ant-secret"), + "{name} must have its env_vars secret stripped" + ); + assert!( + !result.contains("tag-secret"), + "{name} must have its auth_tag secret stripped" + ); + } + } + + /// Cleanup must not follow symlinks that escape the agents dir. + #[cfg(unix)] + #[test] + fn test_cleanup_skips_symlink_escape() { + let agents_dir = tempfile::tempdir().expect("agents dir"); + // Create a target OUTSIDE the agents dir. + let outside_dir = tempfile::tempdir().expect("outside dir"); + let outside_file = outside_dir.path().join("secret.txt"); + std::fs::write(&outside_file, b"outside-secret").expect("write outside"); + + // Create a symlink inside agents_dir that points outside. + let symlink_path = agents_dir.path().join("escape.json"); + std::os::unix::fs::symlink(&outside_file, &symlink_path).expect("create symlink"); + + cleanup_secret_artifacts(agents_dir.path()); + + // The file outside agents_dir must NOT be deleted. + assert!( + outside_file.exists(), + "symlink escape must not delete the target outside agents dir" + ); + } + + /// Cleanup must handle deletion failures gracefully: if a file cannot be + /// removed (e.g. read-only), the rest of the sweep continues and no panic + /// occurs. + #[cfg(unix)] + #[test] + fn test_cleanup_tolerates_deletion_failure() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + // Make a read-only .invalid file that cannot be deleted. + let undeletable = dir.path().join("managed-agents.json.invalid"); + std::fs::write(&undeletable, b"bad json").expect("write"); + + // Also add a normal .invalid file that CAN be deleted. + let deletable = dir.path().join("personas.json.invalid"); + std::fs::write(&deletable, b"also bad").expect("write"); + + // Set the dir to read-only to prevent deletion of its entries. + let dir_meta = std::fs::metadata(dir.path()).expect("dir meta"); + let mut perms = dir_meta.permissions(); + perms.set_mode(0o555); // r-xr-xr-x: no write + std::fs::set_permissions(dir.path(), perms.clone()).expect("set perms"); + + // Cleanup should not panic despite the failure. + cleanup_secret_artifacts(dir.path()); + + // Restore permissions so tempdir cleanup can succeed. + perms.set_mode(0o755); + std::fs::set_permissions(dir.path(), perms).ok(); + } + + // ── scrub_legacy_live_file tests ────────────────────────────────────── + + /// A parseable legacy live file is scrubbed in-place (secrets stripped, + /// file survives). + #[test] + fn test_scrub_legacy_live_file_strips_secrets_from_parseable_content() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("managed-agents.json"); + let content = r#"[{"pubkey":"abc","name":"test","env_vars":{"K":"v"},"created_at":"2026","updated_at":"2026"}]"#; + std::fs::write(&path, content).expect("write"); + + scrub_legacy_live_file(&path); + + assert!(path.exists(), "parseable legacy file must survive"); + let result = std::fs::read_to_string(&path).expect("read"); + assert!( + !result.contains("\"env_vars\""), + "secrets must be stripped from the legacy live file" + ); + } + + /// An unparseable legacy live file is deleted. + #[test] + fn test_scrub_legacy_live_file_deletes_unparseable_content() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("managed-agents.json"); + std::fs::write(&path, b"not valid json with sk-ant-secret").expect("write"); + + scrub_legacy_live_file(&path); + + assert!( + !path.exists(), + "unparseable legacy live file must be deleted" + ); + } + + /// Missing legacy live file is silently skipped (no panic, no error). + #[test] + fn test_scrub_legacy_live_file_ignores_missing_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("managed-agents.json"); + assert!(!path.exists()); + // Must not panic. + scrub_legacy_live_file(&path); + } + + // ── extraction_verified gate logic tests ───────────────────────────── + + /// The cleanup gate `extraction_ok && global_ok && extraction_verified` + /// is tested via the individual extraction booleans. `extraction_verified` + /// itself requires a real keyring (not unit-testable), so we test its + /// logical partners to document the invariant: all three must be true. + #[test] + fn test_extraction_gate_requires_all_three_conditions() { + // The logical gate: extraction_ok && global_ok && extraction_verified. + // All three must be true for cleanup to run. Document the invariant: + // every input combination that is NOT (true, true, true) must keep + // the gate closed. + let cases: &[(bool, bool, bool)] = &[ + (false, false, false), + (true, false, false), + (false, true, false), + (true, true, false), + (false, false, true), + (true, false, true), + (false, true, true), + ]; + for &(extraction_ok, global_ok, verified) in cases { + let gate = extraction_ok && global_ok && verified; + assert!( + !gate, + "gate must be closed unless all three conditions are true: \ + extraction_ok={extraction_ok}, global_ok={global_ok}, verified={verified}" + ); + } + // Only (true, true, true) opens the gate. + let all_ok = { + let (a, b, c) = (true, true, true); + a && b && c + }; + assert!(all_ok, "all three true must open the gate"); + } +} diff --git a/desktop/src-tauri/src/migration_avatar_tests.rs b/desktop/src-tauri/src/migration_avatar_tests.rs index 39dfc988ddf..80f1bfa1cf2 100644 --- a/desktop/src-tauri/src/migration_avatar_tests.rs +++ b/desktop/src-tauri/src/migration_avatar_tests.rs @@ -45,6 +45,7 @@ fn refresh_builtin_agent_avatars_updates_seeded_values_and_preserves_customizati parallelism: None, created_at: "before".to_string(), updated_at: "before".to_string(), + secrets_unavailable: false, }; let old_persona_version = crate::managed_agents::persona_events::persona_content_hash( &crate::managed_agents::persona_events::persona_event_content(&definition), diff --git a/desktop/src-tauri/src/secret_store.rs b/desktop/src-tauri/src/secret_store.rs index 43854761b50..febb10a50ab 100644 --- a/desktop/src-tauri/src/secret_store.rs +++ b/desktop/src-tauri/src/secret_store.rs @@ -86,6 +86,78 @@ fn blob_lockfile_path(service: &str) -> PathBuf { } } +/// Resolve the directory whose inode is the cross-process *transaction* lock +/// target for the store physically held by `store_file` +/// (`managed-agents.json`). +/// +/// The transaction lock is deliberately NOT keyed like the per-operation blob +/// lock. That lock is a service-keyed lockfile under `/tmp`; this one is the +/// store's own **directory inode**. Two properties fall out of that choice, +/// and both are load-bearing: +/// +/// - **Stable shared identity, independent of keyring service.** +/// `managed-agents.json` is symlinked *per file* across dev worktrees while +/// its parent directory is not (see `migration::SHARED_AGENT_FILES`), so +/// resolving the file through its symlink and taking the real parent yields +/// the one canonical directory every process sharing the store contends on — +/// even when `keyring_service()` hands those processes different scoped +/// services. Keying by service would let two processes share one JSON inode +/// while taking different locks; keying by the resolved directory cannot. +/// +/// - **Immunity to the unlink/recreate split.** A `/tmp` lockfile can be +/// unlinked by a temp cleaner while a process holds its `flock`; a second +/// process then recreates the pathname, locks a fresh inode, and both +/// "hold" the lock. A directory that holds the store files is non-empty and +/// lives in the owner's app-data tree: no tmp-cleaner unlinks it and +/// `rmdir` refuses a non-empty directory, so the inode a held lock refers to +/// cannot be swapped out underneath a second participant. +/// +/// Falls back to the file's own parent when the file does not exist yet (first +/// boot, before anything is written or shared) — there is no committed record +/// to lose in that window. +pub fn store_txn_lock_dir(store_file: &std::path::Path) -> PathBuf { + if let Ok(real) = std::fs::canonicalize(store_file) { + if let Some(parent) = real.parent() { + return parent.to_path_buf(); + } + } + store_file + .parent() + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| store_file.to_path_buf()) +} + +/// Upper bound on lockfile re-acquisition attempts when a tmp cleaner keeps +/// unlinking the blob lockfile out from under us. Reaching it means the file is +/// being churned faster than we can lock a live inode — fail loudly rather than +/// spin forever. +#[cfg(all(unix, feature = "system-keyring"))] +const MAX_BLOB_LOCK_REACQUIRE: u32 = 100; + +/// True iff `file` (an open, `flock`-held fd) is locked on the same inode the +/// pathname currently resolves to. +/// +/// Called after the lock is granted to detect a tmp-cleaner unlink/recreate: a +/// mismatch means our fd holds a lock on a now-nameless dead inode while the +/// live pathname is a *different* inode another process can lock in parallel — +/// two processes each "holding" the lock over different inodes, mutual +/// exclusion lost. A missing pathname (`stat` fails) counts as not-live so the +/// caller re-creates and re-locks the live inode. +#[cfg(all(unix, feature = "system-keyring"))] +fn locked_inode_is_live(file: &std::fs::File, path: &std::path::Path) -> bool { + use std::os::unix::fs::MetadataExt; + let Ok(locked) = file.metadata() else { + return false; + }; + // Compare (dev, ino): inode numbers are only unique within a device, and + // the open fd pins its inode number so a recreate under the same pathname + // is guaranteed a different one. + matches!( + std::fs::metadata(path), + Ok(live) if live.dev() == locked.dev() && live.ino() == locked.ino() + ) +} + /// Acquire an exclusive advisory file lock for the blob identified by `service`. /// /// Opens (or creates) the lockfile and blocks until the lock is acquired. @@ -119,20 +191,40 @@ impl BlobLockGuard { fn acquire(path: &std::path::Path) -> Result { #[cfg(unix)] { - let file = std::fs::OpenOptions::new() - .create(true) - .truncate(false) - .write(true) - .open(path) - .map_err(|e| format!("blob lock open {}: {e}", path.display()))?; use std::os::unix::io::AsRawFd; - // LOCK_EX blocks until the lock is acquired (no LOCK_NB). - let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; - if ret != 0 { - let err = std::io::Error::last_os_error(); - return Err(format!("blob lock flock: {err}")); + // Loop to survive a tmp cleaner unlinking the lockfile out from + // under us. The classic `/tmp` lock split: we `flock` an inode, the + // pathname is unlinked and recreated as a fresh inode, and a second + // process locks that fresh inode — both "hold" the lock over + // different inodes. Defeat it by rechecking, after the lock is + // granted, that the pathname still resolves to the inode we locked. + // If it does not, our lock is on a dead inode: drop it and retry so + // we contend on the live one. LOCK_EX blocks until granted, so a + // stable inode converges in one pass. + for _ in 0..MAX_BLOB_LOCK_REACQUIRE { + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(path) + .map_err(|e| format!("blob lock open {}: {e}", path.display()))?; + // LOCK_EX blocks until the lock is acquired (no LOCK_NB). + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; + if ret != 0 { + let err = std::io::Error::last_os_error(); + return Err(format!("blob lock flock: {err}")); + } + if locked_inode_is_live(&file, path) { + return Ok(BlobLockGuard { file }); + } + // Stale inode: the pathname was unlinked/recreated while we + // blocked. Drop this fd (releasing the dead-inode lock) and + // retry against the live pathname. } - return Ok(BlobLockGuard { file }); + return Err(format!( + "blob lock: pathname {} churned {MAX_BLOB_LOCK_REACQUIRE} times without a stable inode", + path.display() + )); } #[cfg(windows)] @@ -246,6 +338,143 @@ impl SecretStore { } } +/// Acquire the cross-process secret **transaction** lock on the store +/// directory identified by `store_dir` (the resolved canonical directory from +/// [`store_txn_lock_dir`]) and hold it for the returned guard's lifetime. +/// +/// This is the coarse lock every multi-step secret transaction must hold so +/// two Desktop processes sharing the store cannot interleave: a save holds it +/// from the other-half read through generation writes to the atomic JSON +/// commit; GC holds it from the live-ref read through the blob `remove_batch`. +/// Without it, process B's GC could delete a generation that process A wrote +/// but has not yet committed to JSON, leaving A's committed ref dangling; or A +/// could commit a stale pre-lock snapshot over B's committed record. +/// +/// The lock target is the store **directory inode**, not a `/tmp` lockfile and +/// not the keyring service — see [`store_txn_lock_dir`] for why (stable shared +/// identity across worktrees + immunity to the unlink/recreate split). +/// +/// Orthogonal to the per-operation `mutate_blob` lock, which flocks a separate +/// `/tmp` lockfile ([`blob_lockfile_path`]): a `mutate_blob` inside a +/// transaction takes a lock on a *different* object, so the two never +/// self-deadlock. Transaction callers must not nest this lock within +/// themselves (a second acquire in the same process blocks on its own held +/// exclusive lock); the save/GC/global-config entry points are all leaf-level. +/// +/// The guard is `#[must_use]` — dropping it early releases the lock. On a +/// build without the keyring feature this is a no-op guard. +#[cfg(feature = "system-keyring")] +#[must_use = "the transaction lock is released when the guard is dropped"] +pub fn transaction_lock_at(store_dir: &std::path::Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + // Open the directory read-only (never create/truncate — the store dir + // already exists, created by `managed_agents_base_dir`). `flock` on the + // directory inode blocks until the lock is acquired; no file inside can + // split it because a non-empty directory cannot be `rmdir`'d and its + // inode is fixed for the directory's lifetime. + let dir = std::fs::File::open(store_dir) + .map_err(|e| format!("txn lock open dir {}: {e}", store_dir.display()))?; + let ret = unsafe { libc::flock(dir.as_raw_fd(), libc::LOCK_EX) }; + if ret != 0 { + let err = std::io::Error::last_os_error(); + return Err(format!("txn lock flock {}: {err}", store_dir.display())); + } + Ok(SecretTxnGuard { _dir: dir }) + } + #[cfg(windows)] + { + // Windows cannot `flock` a directory handle the same way, so use a + // named kernel mutex whose name is a deterministic hash of the + // resolved directory path — the same stable-identity property as the + // Unix directory inode, and cross-build stable so a signed build and a + // dev build sharing the store contend on one mutex. + let name_str = format!("Local\\BuzzSecretTxn-{:016x}", fnv1a64(store_dir)); + let name_wide: Vec = name_str + .encode_utf16() + .chain(std::iter::once(0u16)) + .collect(); + use windows_sys::Win32::Foundation::WAIT_OBJECT_0; + use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; + use windows_sys::Win32::System::Threading::{CreateMutexW, WaitForSingleObject, INFINITE}; + let handle = unsafe { + CreateMutexW( + std::ptr::null::(), + 0, + name_wide.as_ptr(), + ) + }; + if handle.is_null() { + let err = std::io::Error::last_os_error(); + return Err(format!("txn lock CreateMutexW: {err}")); + } + let wait_result = unsafe { WaitForSingleObject(handle, INFINITE) }; + if wait_result != WAIT_OBJECT_0 + && wait_result != windows_sys::Win32::Foundation::WAIT_ABANDONED + { + let err = std::io::Error::last_os_error(); + unsafe { windows_sys::Win32::Foundation::CloseHandle(handle) }; + return Err(format!( + "txn lock WaitForSingleObject: {wait_result} / {err}" + )); + } + Ok(SecretTxnGuard { + mutex_handle: handle, + }) + } + #[cfg(not(any(unix, windows)))] + { + let _ = store_dir; + Err("txn lock: unsupported platform".to_string()) + } +} + +/// No-op transaction lock when the keyring feature is disabled: there are no +/// generation writes to serialize, so the guard holds nothing. +#[cfg(not(feature = "system-keyring"))] +#[must_use = "the transaction lock is released when the guard is dropped"] +pub fn transaction_lock_at(_store_dir: &std::path::Path) -> Result { + Ok(SecretTxnGuard {}) +} + +/// Deterministic 64-bit FNV-1a over a path's bytes, used only to name the +/// Windows transaction mutex. Stable across builds (no random seed), so two +/// Desktop builds sharing a store derive the same mutex name. +#[cfg(all(feature = "system-keyring", windows))] +fn fnv1a64(path: &std::path::Path) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in path.to_string_lossy().as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// RAII guard for the cross-process secret transaction lock. Held for the full +/// duration of a save (other-half read → generation writes → JSON commit) or a +/// GC pass (live-ref read → blob remove). See [`transaction_lock_at`]. +#[must_use = "the transaction lock is released when the guard is dropped"] +pub struct SecretTxnGuard { + /// The open directory fd. Never read — held purely for RAII: closing it + /// releases the `flock(LOCK_EX)` on the directory inode. + #[cfg(all(feature = "system-keyring", unix))] + #[allow(dead_code)] + _dir: std::fs::File, + #[cfg(all(feature = "system-keyring", windows))] + mutex_handle: windows_sys::Win32::Foundation::HANDLE, +} + +#[cfg(all(feature = "system-keyring", windows))] +impl Drop for SecretTxnGuard { + fn drop(&mut self) { + unsafe { + windows_sys::Win32::System::Threading::ReleaseMutex(self.mutex_handle); + windows_sys::Win32::Foundation::CloseHandle(self.mutex_handle); + } + } +} + /// Whether a keyring error string indicates the backend itself is unavailable /// (vs. a per-entry error like "not found"). Mirrors goose's discriminator /// (`crates/goose/src/config/base.rs`): treat dbus / Secret Service / platform @@ -368,13 +597,14 @@ impl SecretStore { /// Atomically load the blob, apply `f` to a candidate map, write back if /// changed, and only then advance the cache. /// - /// **Cross-process safety**: acquires an exclusive advisory file lock - /// (`flock(2)` on Unix, `LockFileEx` on Windows) before reading, mutating, - /// and writing. The lock is keyed by service name and stored in the system - /// temp directory, making it reachable from both the signed DMG build and - /// unsigned dev builds. Inside the lock a fresh `read_blob_raw()` is always - /// performed (even when the cache is warm) so a concurrent process's write - /// is never silently dropped. + /// **Cross-process safety**: acquires an exclusive cross-process lock + /// (`flock(2)` on a service-keyed lockfile in the system temp directory on + /// Unix, a named kernel mutex via `CreateMutexW` on Windows — see + /// [`BlobLockGuard`]) before reading, mutating, and writing. The Unix + /// lockfile is reachable from both the signed DMG build and unsigned dev + /// builds. Inside the lock a fresh `read_blob_raw()` is always performed + /// (even when the cache is warm) so a concurrent process's write is never + /// silently dropped. /// /// **Idempotent**: when `f` leaves the candidate equal to the freshly-read /// map, `write_blob_raw` is skipped entirely. On macOS the legacy @@ -922,385 +1152,5 @@ impl SecretStore { } #[cfg(all(test, feature = "system-keyring"))] -mod tests { - use super::*; - - // Test-only constructor: pre-seed the cache without touching the OS keychain. - impl SecretStore { - fn with_cache(service: &str, cache: Option>) -> Self { - SecretStore { - service: service.to_string(), - cache: Mutex::new(cache), - } - } - } - - #[test] - fn probe_returns_present_when_key_in_cache() { - let mut map = HashMap::new(); - map.insert("identity".to_string(), "nsec1test".to_string()); - let store = SecretStore::with_cache("buzz-test-cache-hit", Some(map)); - // Cache is warm and contains "identity" — probe must return Present - // without touching the keychain. - assert_eq!(store.probe("identity"), KeyringProbe::Present); - } - - #[test] - fn load_returns_value_when_key_in_cache() { - let mut map = HashMap::new(); - map.insert("identity".to_string(), "nsec1test".to_string()); - let store = SecretStore::with_cache("buzz-test-load-cache-hit", Some(map)); - // Cache is warm and contains "identity" — load must return the value - // without touching the keychain. - assert_eq!( - store.load("identity").unwrap(), - Some("nsec1test".to_string()) - ); - } - - // ── Cross-process race tests (require real OS keychain) ──────────────── - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn test_stale_warm_cache_add_observes_prior_write() { - // Simulates the cross-process race that stranded Will's agent keys. - // - // Setup: two SecretStore instances for the same service (= two - // "processes" with separate caches). Process A warms its cache to - // {k1}. Process B then writes {k1, k2}. Without the fix, A's next - // mutate_blob would build from its stale {k1} cache and write - // {k1, k3}, silently dropping k2. With the fix, A always re-reads - // from the keychain inside the lock, so the result is {k1, k2, k3}. - let svc = "buzz-test-race-stale-cache"; - - // Clean state. - let setup = SecretStore::keyring(svc); - let _ = setup.delete("k1"); - let _ = setup.delete("k2"); - let _ = setup.delete("k3"); - - // Process A: write k1, warming its cache. - let store_a = SecretStore::keyring(svc); - store_a.store("k1", "v1").unwrap(); - - // Process B: write k2 (separate instance = separate cache). - let store_b = SecretStore::keyring(svc); - store_b.store("k2", "v2").unwrap(); - - // Process A: write k3. With the fix, A re-reads inside the lock and - // sees {k1, k2} before appending k3 — result must be {k1, k2, k3}. - store_a.store("k3", "v3").unwrap(); - - // Verify via a third reader (clean cache). - let reader = SecretStore::keyring(svc); - assert_eq!( - reader.load("k1").unwrap(), - Some("v1".to_string()), - "k1 must survive" - ); - assert_eq!( - reader.load("k2").unwrap(), - Some("v2".to_string()), - "k2 must not be dropped" - ); - assert_eq!( - reader.load("k3").unwrap(), - Some("v3".to_string()), - "k3 must be written" - ); - - // Cleanup. - let _ = reader.delete("k1"); - let _ = reader.delete("k2"); - let _ = reader.delete("k3"); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn test_concurrent_adds_neither_key_dropped() { - // Two sequential stores from distinct instances (simulating two - // processes each adding one key) must both be durably visible. - let svc = "buzz-test-race-concurrent-add"; - - let setup = SecretStore::keyring(svc); - let _ = setup.delete("agent_a"); - let _ = setup.delete("agent_b"); - - let store1 = SecretStore::keyring(svc); - store1.store("agent_a", "nsec1aaa").unwrap(); - - let store2 = SecretStore::keyring(svc); - store2.store("agent_b", "nsec1bbb").unwrap(); - - let reader = SecretStore::keyring(svc); - assert_eq!( - reader.load("agent_a").unwrap(), - Some("nsec1aaa".to_string()), - "agent_a must not be dropped" - ); - assert_eq!( - reader.load("agent_b").unwrap(), - Some("nsec1bbb".to_string()), - "agent_b must not be dropped" - ); - - // Cleanup. - let _ = reader.delete("agent_a"); - let _ = reader.delete("agent_b"); - } - - #[test] - fn test_blob_lockfile_path_is_in_tmp_with_uid() { - // The lockfile must be at a deterministic per-user path under /tmp — - // invariant to $TMPDIR — so both a GUI-launched DMG (env-stripped by - // launchd) and a terminal-launched dev build resolve the same inode and - // achieve mutual exclusion. - let path = blob_lockfile_path("buzz-desktop"); - #[cfg(unix)] - { - let uid = unsafe { libc::getuid() }; - assert!( - path.starts_with("/tmp"), - "lockfile {path:?} must start with /tmp (not $TMPDIR)" - ); - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_default(); - assert!( - name.contains(&uid.to_string()), - "lockfile {path:?} must contain uid {uid}" - ); - assert!( - name.contains("buzz-keychain"), - "lockfile name must contain 'buzz-keychain'" - ); - } - #[cfg(not(unix))] - { - assert!( - path.file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.contains("buzz-keychain")), - "lockfile name must contain 'buzz-keychain'" - ); - } - } - - #[test] - fn test_blob_lock_acquire_and_release() { - // Verify the advisory lock can be acquired and released without errors. - // This exercises the real flock/mutex path on the current platform. - let guard = acquire_blob_lock("buzz-test-lock-smoke"); - assert!( - guard.is_ok(), - "advisory lock acquire must succeed: {:?}", - guard.err() - ); - // Drop the guard — lock is released. A second acquire must succeed. - drop(guard); - let guard2 = acquire_blob_lock("buzz-test-lock-smoke"); - assert!( - guard2.is_ok(), - "advisory lock re-acquire after release must succeed: {:?}", - guard2.err() - ); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn mutate_blob_does_not_advance_cache_on_write_failure() { - // Copy-on-write safety: if `write_blob_raw` fails (denied prompt, - // transient outage, ACL rejection), the cache must stay at the last - // known durable state. A subsequent `store()` for the same key/value - // must NOT be skipped as a no-op — the equality check must compare - // against the durable cache, not an unpersisted candidate. - // - // This is a real-keychain integration test. Run locally with: - // cargo test -p buzz-desktop -- --ignored mutate_blob_does_not_advance - // - // On a machine with a reachable keychain the `store()` call succeeds - // (result.is_ok()) and the write-failure branch is skipped — the test - // still passes. On a machine where the write is denied (e.g., user - // clicks Deny in the macOS prompt) result.is_err() and the assertions - // below verify the cache invariant. We verify that after an error: - // 1. The cache is not advanced (the previously cached key is intact). - // 2. The failed key is not present (the dirty candidate was discarded). - let mut map = HashMap::new(); - map.insert("existing".to_string(), "durable_val".to_string()); - let store = SecretStore::with_cache("buzz-test-cow-write-fail", Some(map)); - - // Attempt to add a new key — this calls write_blob_raw against the - // real keychain; with copy-on-write the cache must remain at {existing} - // if the write fails. - let result = store.store("new_key", "new_val"); - - if result.is_err() { - // Write failed (e.g., user denied the keychain prompt): confirm - // cache was not advanced — the existing key is still intact and - // the new key was never committed to the in-memory state. - assert_eq!( - store.load("existing").unwrap(), - Some("durable_val".to_string()), - "cache must remain at last durable state after write failure" - ); - // load("new_key") goes through the unchanged cache (no entry), - // then attempts migrate_legacy_key which also fails on a denied - // keychain, returning either Ok(None) or Err — either is correct - // since the key was never durably stored. - let after = store.load("new_key"); - assert!( - matches!(after, Ok(None) | Err(_)), - "a key whose write failed must not be visible via load: {after:?}" - ); - } - // If result.is_ok() the write succeeded — the cache-integrity invariant - // does not apply to the success path; no assertion needed here. - } - - #[test] - fn availability_error_discriminator() { - assert!(is_keyring_availability_error("dbus connection failed")); - assert!(is_keyring_availability_error( - "org.freedesktop.secrets not provided" - )); - assert!(is_keyring_availability_error("No Secret Service")); - assert!(is_keyring_availability_error( - "Platform secure storage failure" - )); - // A plain "not found" is per-entry, not an availability failure. - assert!(!is_keyring_availability_error("entry not found")); - } - - #[cfg(target_os = "macos")] - #[test] - fn dpk_error_discriminators() { - // errSecMissingEntitlement = -34018 signals unsigned dev build. - let e = SFError::from_code(-34018); - assert!(is_dpk_unavailable(&e)); - assert!(!is_not_found(&e)); - // errSecItemNotFound = -25300 is not a DPK-unavailable error. - let e = SFError::from_code(-25300); - assert!(is_not_found(&e)); - assert!(!is_dpk_unavailable(&e)); - } - - // Integration tests that exercise the real OS keychain. Skipped in CI - // (unsigned builds lack keychain entitlements); run locally with: - // cargo test -p buzz-desktop -- --ignored blob_ - // - // Each test uses a unique service name to avoid cross-test pollution. - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn blob_stores_and_retrieves_multiple_keys() { - let store = SecretStore::keyring("buzz-test-blob-multi"); - store.store("key_a", "val_a").unwrap(); - store.store("key_b", "val_b").unwrap(); - assert_eq!(store.load("key_a").unwrap(), Some("val_a".to_string())); - assert_eq!(store.load("key_b").unwrap(), Some("val_b".to_string())); - assert_eq!(store.load("key_c").unwrap(), None); - // Cleanup. - let _ = store.delete("key_a"); - let _ = store.delete("key_b"); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn blob_probe_present_absent_unreachable() { - let store = SecretStore::keyring("buzz-test-blob-probe"); - // No blob yet — key absent, backend reachable. - assert_eq!(store.probe("identity"), KeyringProbe::ReachableButEmpty); - store.store("identity", "nsec1test").unwrap(); - // Key now present. - assert_eq!(store.probe("identity"), KeyringProbe::Present); - // Different key — blob exists but key absent. - assert_eq!(store.probe("other"), KeyringProbe::ReachableButEmpty); - // Cleanup. - let _ = store.delete("identity"); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn blob_delete_removes_key_not_others() { - let store = SecretStore::keyring("buzz-test-blob-delete"); - store.store("keep", "keep_val").unwrap(); - store.store("remove", "remove_val").unwrap(); - store.delete("remove").unwrap(); - assert_eq!(store.load("keep").unwrap(), Some("keep_val".to_string())); - assert_eq!(store.load("remove").unwrap(), None); - // Cleanup. - let _ = store.delete("keep"); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn blob_migration_from_per_key_entry() { - let svc = "buzz-test-blob-migration"; - let key = "identity"; - let value = "nsec1migrationtest"; - - // Seed a per-key entry (old format) — no blob exists. - let entry = keyring_entry(svc, key).unwrap(); - entry.set_password(value).unwrap(); - - // Fresh store — no blob in the keychain yet. - let store = SecretStore::keyring(svc); - - // probe should find the legacy key. - assert_eq!(store.probe(key), KeyringProbe::Present); - - // load should migrate it into the blob and return the value. - assert_eq!(store.load(key).unwrap(), Some(value.to_string())); - - // Old per-key entry should be cleaned up. - let entry = keyring_entry(svc, key).unwrap(); - assert!(matches!(entry.get_password(), Err(keyring::Error::NoEntry))); - - // Key is now in the blob — probe confirms. - let store2 = SecretStore::keyring(svc); - assert_eq!(store2.probe(key), KeyringProbe::Present); - assert_eq!(store2.load(key).unwrap(), Some(value.to_string())); - - // Cleanup. - let _ = store2.delete(key); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn delete_all_with_legacy_cleanup_removes_per_key_identity() { - let svc = "buzz-test-delete-all-legacy"; - let key = "identity"; - let value = "nsec1legacytest"; - - // Seed a legacy per-key entry (old format, pre-blob migration). - let entry = keyring_entry(svc, key).unwrap(); - entry.set_password(value).unwrap(); - - // Also seed a blob with a different key to exercise the full path. - let store = SecretStore::keyring(svc); - store.store("agent:abc123", "nsec1agent").unwrap(); - - // Legacy per-key identity should be discoverable via probe. - let store2 = SecretStore::keyring(svc); - assert_eq!(store2.probe(key), KeyringProbe::Present); - - // Wipe everything via the sign-out path. - store2.delete_all_with_legacy_cleanup().unwrap(); - - // Fresh store — neither the blob nor the per-key entry should remain. - let store3 = SecretStore::keyring(svc); - assert_eq!( - store3.probe(key), - KeyringProbe::ReachableButEmpty, - "per-key identity must not survive delete_all_with_legacy_cleanup" - ); - assert_eq!( - store3.load(key).unwrap(), - None, - "load must not resurrect the legacy per-key identity" - ); - // Agent key should also be gone. - assert_eq!(store3.load("agent:abc123").unwrap(), None); - } -} +#[path = "secret_store_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/secret_store_tests.rs b/desktop/src-tauri/src/secret_store_tests.rs new file mode 100644 index 00000000000..58746c98fd1 --- /dev/null +++ b/desktop/src-tauri/src/secret_store_tests.rs @@ -0,0 +1,638 @@ +//! Unit tests for [`crate::secret_store`], extracted to a sibling file to +//! keep `secret_store.rs` under the desktop file-size ratchet. Wired in via +//! `#[path = "secret_store_tests.rs"] mod tests;` under the same cfg gate. + +use super::*; + +// Test-only constructor: pre-seed the cache without touching the OS keychain. +impl SecretStore { + fn with_cache(service: &str, cache: Option>) -> Self { + SecretStore { + service: service.to_string(), + cache: Mutex::new(cache), + } + } +} + +#[test] +fn probe_returns_present_when_key_in_cache() { + let mut map = HashMap::new(); + map.insert("identity".to_string(), "nsec1test".to_string()); + let store = SecretStore::with_cache("buzz-test-cache-hit", Some(map)); + // Cache is warm and contains "identity" — probe must return Present + // without touching the keychain. + assert_eq!(store.probe("identity"), KeyringProbe::Present); +} + +#[test] +fn load_returns_value_when_key_in_cache() { + let mut map = HashMap::new(); + map.insert("identity".to_string(), "nsec1test".to_string()); + let store = SecretStore::with_cache("buzz-test-load-cache-hit", Some(map)); + // Cache is warm and contains "identity" — load must return the value + // without touching the keychain. + assert_eq!( + store.load("identity").unwrap(), + Some("nsec1test".to_string()) + ); +} + +// ── Cross-process race tests (require real OS keychain) ──────────────── + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn test_stale_warm_cache_add_observes_prior_write() { + // Simulates the cross-process race that stranded Will's agent keys. + // + // Setup: two SecretStore instances for the same service (= two + // "processes" with separate caches). Process A warms its cache to + // {k1}. Process B then writes {k1, k2}. Without the fix, A's next + // mutate_blob would build from its stale {k1} cache and write + // {k1, k3}, silently dropping k2. With the fix, A always re-reads + // from the keychain inside the lock, so the result is {k1, k2, k3}. + let svc = "buzz-test-race-stale-cache"; + + // Clean state. + let setup = SecretStore::keyring(svc); + let _ = setup.delete("k1"); + let _ = setup.delete("k2"); + let _ = setup.delete("k3"); + + // Process A: write k1, warming its cache. + let store_a = SecretStore::keyring(svc); + store_a.store("k1", "v1").unwrap(); + + // Process B: write k2 (separate instance = separate cache). + let store_b = SecretStore::keyring(svc); + store_b.store("k2", "v2").unwrap(); + + // Process A: write k3. With the fix, A re-reads inside the lock and + // sees {k1, k2} before appending k3 — result must be {k1, k2, k3}. + store_a.store("k3", "v3").unwrap(); + + // Verify via a third reader (clean cache). + let reader = SecretStore::keyring(svc); + assert_eq!( + reader.load("k1").unwrap(), + Some("v1".to_string()), + "k1 must survive" + ); + assert_eq!( + reader.load("k2").unwrap(), + Some("v2".to_string()), + "k2 must not be dropped" + ); + assert_eq!( + reader.load("k3").unwrap(), + Some("v3".to_string()), + "k3 must be written" + ); + + // Cleanup. + let _ = reader.delete("k1"); + let _ = reader.delete("k2"); + let _ = reader.delete("k3"); +} + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn test_concurrent_adds_neither_key_dropped() { + // Two sequential stores from distinct instances (simulating two + // processes each adding one key) must both be durably visible. + let svc = "buzz-test-race-concurrent-add"; + + let setup = SecretStore::keyring(svc); + let _ = setup.delete("agent_a"); + let _ = setup.delete("agent_b"); + + let store1 = SecretStore::keyring(svc); + store1.store("agent_a", "nsec1aaa").unwrap(); + + let store2 = SecretStore::keyring(svc); + store2.store("agent_b", "nsec1bbb").unwrap(); + + let reader = SecretStore::keyring(svc); + assert_eq!( + reader.load("agent_a").unwrap(), + Some("nsec1aaa".to_string()), + "agent_a must not be dropped" + ); + assert_eq!( + reader.load("agent_b").unwrap(), + Some("nsec1bbb".to_string()), + "agent_b must not be dropped" + ); + + // Cleanup. + let _ = reader.delete("agent_a"); + let _ = reader.delete("agent_b"); +} + +#[test] +fn test_blob_lockfile_path_is_in_tmp_with_uid() { + // The lockfile must be at a deterministic per-user path under /tmp — + // invariant to $TMPDIR — so both a GUI-launched DMG (env-stripped by + // launchd) and a terminal-launched dev build resolve the same inode and + // achieve mutual exclusion. + let path = blob_lockfile_path("buzz-desktop"); + #[cfg(unix)] + { + let uid = unsafe { libc::getuid() }; + assert!( + path.starts_with("/tmp"), + "lockfile {path:?} must start with /tmp (not $TMPDIR)" + ); + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + assert!( + name.contains(&uid.to_string()), + "lockfile {path:?} must contain uid {uid}" + ); + assert!( + name.contains("buzz-keychain"), + "lockfile name must contain 'buzz-keychain'" + ); + } + #[cfg(not(unix))] + { + assert!( + path.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.contains("buzz-keychain")), + "lockfile name must contain 'buzz-keychain'" + ); + } +} + +#[test] +fn test_blob_lock_acquire_and_release() { + // Verify the advisory lock can be acquired and released without errors. + // This exercises the real flock/mutex path on the current platform. + let guard = acquire_blob_lock("buzz-test-lock-smoke"); + assert!( + guard.is_ok(), + "advisory lock acquire must succeed: {:?}", + guard.err() + ); + // Drop the guard — lock is released. A second acquire must succeed. + drop(guard); + let guard2 = acquire_blob_lock("buzz-test-lock-smoke"); + assert!( + guard2.is_ok(), + "advisory lock re-acquire after release must succeed: {:?}", + guard2.err() + ); +} + +// ── W6-B: blob lockfile hardening against tmp-cleaner unlink/recreate ────── +// +// The blob lock is a service-keyed `/tmp` lockfile (distinct from the +// directory-inode transaction lock). A tmp cleaner can unlink it while a holder +// keeps its `flock`, after which a recreate under the same pathname is a fresh +// inode a second process can lock in parallel — the classic split. The fix is +// an inode recheck after the lock is granted: a holder whose locked inode no +// longer matches the live pathname re-acquires against the live one, so all +// contenders converge on a single inode. + +#[cfg(all(unix, feature = "system-keyring"))] +#[test] +fn test_locked_inode_is_live_true_for_untouched_file() { + // A freshly opened lockfile that no one has churned must read as live. + let path = std::env::temp_dir().join(format!("buzz-w6b-live-{}.lock", std::process::id())); + let _ = std::fs::remove_file(&path); + let file = std::fs::File::create(&path).expect("create lockfile"); + assert!( + locked_inode_is_live(&file, &path), + "an untouched lockfile must resolve to its own locked inode" + ); + let _ = std::fs::remove_file(&path); +} + +#[cfg(all(unix, feature = "system-keyring"))] +#[test] +fn test_locked_inode_is_live_false_after_unlink_recreate() { + // A held fd whose pathname was unlinked and recreated points at a dead + // inode while the pathname resolves to a fresh one — the split condition + // the recheck exists to catch. + let path = std::env::temp_dir().join(format!("buzz-w6b-churn-{}.lock", std::process::id())); + let _ = std::fs::remove_file(&path); + let held = std::fs::File::create(&path).expect("create original lockfile"); + std::fs::remove_file(&path).expect("unlink original"); + std::fs::File::create(&path).expect("recreate lockfile (fresh inode)"); + assert!( + !locked_inode_is_live(&held, &path), + "a recreated pathname must NOT match the dead inode a holder still owns" + ); + let _ = std::fs::remove_file(&path); +} + +#[cfg(all(unix, feature = "system-keyring"))] +#[test] +fn test_locked_inode_is_live_false_when_pathname_absent() { + // Unlinked with no recreate: the pathname does not resolve, so the holder's + // inode cannot be the live one. The acquire loop must then re-create it. + let path = std::env::temp_dir().join(format!("buzz-w6b-gone-{}.lock", std::process::id())); + let _ = std::fs::remove_file(&path); + let held = std::fs::File::create(&path).expect("create lockfile"); + std::fs::remove_file(&path).expect("unlink lockfile"); + assert!( + !locked_inode_is_live(&held, &path), + "an absent pathname must read as not-live" + ); +} + +#[cfg(all(unix, feature = "system-keyring"))] +#[test] +fn test_blob_lock_converges_on_live_inode_after_recreate() { + // End-to-end: a holder acquires the lock, a tmp cleaner unlinks+recreates + // the lockfile (leaving the first guard on a dead inode), and a second + // acquire must land on the LIVE inode — not the dead one — so a peer + // process contending on that live pathname is still excluded. + use std::os::unix::fs::MetadataExt; + use std::os::unix::io::AsRawFd; + + let service = format!("buzz-w6b-converge-{}", std::process::id()); + let path = blob_lockfile_path(&service); + let _ = std::fs::remove_file(&path); + + let first = acquire_blob_lock(&service).expect("first acquire"); + let dead_ino = first.file.metadata().expect("first meta").ino(); + + // Tmp cleaner churns the pathname out from under the first holder. + std::fs::remove_file(&path).expect("unlink lockfile"); + std::fs::File::create(&path).expect("recreate lockfile"); + let live_ino = std::fs::metadata(&path).expect("live meta").ino(); + assert_ne!(dead_ino, live_ino, "recreate must yield a fresh inode"); + + // A second acquire (the analog of process B) converges on the live inode. + let second = acquire_blob_lock(&service).expect("second acquire"); + assert_eq!( + second.file.metadata().expect("second meta").ino(), + live_ino, + "the hardened acquire must lock the LIVE inode, not the dead one" + ); + + // A peer opening the live pathname must be excluded while `second` holds. + let peer = std::fs::File::open(&path).expect("peer opens live lockfile"); + let rc = unsafe { libc::flock(peer.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + assert_eq!( + rc, -1, + "a peer must NOT acquire the live inode while the hardened guard holds it" + ); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EWOULDBLOCK), + "peer exclusion must fail with EWOULDBLOCK" + ); + + drop(second); + drop(first); + let _ = std::fs::remove_file(&path); +} + +// ── F5a: cross-process transaction lock (directory-inode based) ─────── + +#[test] +fn test_store_txn_lock_dir_resolves_symlinked_file_to_canonical_parent() { + // Two dev worktrees each symlink `managed-agents.json` to one canonical + // file (the parent dir is NOT symlinked — see `SHARED_AGENT_FILES`). The + // transaction lock must key by the RESOLVED canonical directory so both + // worktrees contend on the same inode; keying by the worktree's own path + // would give them different locks over the same store. + let root = std::env::temp_dir().join(format!("buzz-txn-key-{}", std::process::id())); + let canonical = root.join("canonical/agents"); + std::fs::create_dir_all(&canonical).expect("create canonical agents dir"); + let real_file = canonical.join("managed-agents.json"); + std::fs::write(&real_file, b"[]").expect("write canonical store"); + + let mut resolved = Vec::new(); + for wt in ["wtA", "wtB"] { + let wt_dir = root.join(wt).join("agents"); + std::fs::create_dir_all(&wt_dir).expect("create worktree agents dir"); + let link = wt_dir.join("managed-agents.json"); + let _ = std::fs::remove_file(&link); + #[cfg(unix)] + std::os::unix::fs::symlink(&real_file, &link).expect("symlink store file"); + resolved.push(store_txn_lock_dir(&link)); + } + + #[cfg(unix)] + { + let want = std::fs::canonicalize(&canonical).expect("canonicalize canonical dir"); + assert_eq!( + resolved[0], want, + "worktree A must resolve to canonical dir" + ); + assert_eq!( + resolved[1], want, + "worktree B must resolve to canonical dir" + ); + assert_eq!( + resolved[0], resolved[1], + "both worktrees must key the transaction lock by ONE canonical dir inode" + ); + } + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn test_store_txn_lock_dir_falls_back_to_parent_when_file_absent() { + // First boot: the store file does not exist yet. The lock dir must still + // resolve to the file's parent so the very first save serializes (there is + // no committed record to lose in that window, but the API must not panic + // or hand back a nonsense path). + let dir = std::env::temp_dir().join(format!("buzz-txn-absent-{}/agents", std::process::id())); + let absent = dir.join("managed-agents.json"); + assert_eq!( + store_txn_lock_dir(&absent), + dir, + "an absent store file must key by its parent directory" + ); +} + +/// Two INDEPENDENT participants (distinct guards on the same resolved store +/// directory — the analog of two Desktop processes contending on one shared +/// canonical `agents/` dir) must mutually exclude: while one holds the +/// transaction lock, a non-blocking acquire by the other must fail. +/// +/// Uses the real `flock` path over a unique temp directory so it needs no OS +/// keychain and does not touch the shared store. +#[cfg(all(unix, feature = "system-keyring"))] +#[test] +fn test_txn_lock_excludes_a_second_participant() { + use std::os::unix::io::AsRawFd; + + let dir = std::env::temp_dir().join(format!("buzz-txn-excl-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create store dir"); + std::fs::write(dir.join("managed-agents.json"), b"[]").expect("seed store file"); + + // Participant A takes the transaction lock on the directory inode. + let guard_a = transaction_lock_at(&dir).expect("participant A acquires txn lock"); + + // Participant B is an independent open file description on the SAME + // directory (what a second process would have). A non-blocking exclusive + // flock must fail with EWOULDBLOCK while A holds the lock. + let dir_b = std::fs::File::open(&dir).expect("participant B opens dir"); + let rc = unsafe { libc::flock(dir_b.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + assert_eq!( + rc, -1, + "a second participant must NOT acquire the txn lock while A holds it" + ); + let err = std::io::Error::last_os_error(); + assert!( + matches!(err.raw_os_error(), Some(libc::EWOULDBLOCK)), + "second acquire must fail with EWOULDBLOCK, got {err:?}" + ); + + // After A releases, B's non-blocking acquire succeeds. + drop(guard_a); + let rc2 = unsafe { libc::flock(dir_b.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + assert_eq!(rc2, 0, "second participant must acquire once A releases"); + let _ = unsafe { libc::flock(dir_b.as_raw_fd(), libc::LOCK_UN) }; + let _ = std::fs::remove_dir_all(&dir); +} + +/// Finding 3 adversarial regression: the lock must survive a tmp-cleaner-style +/// unlink+recreate of a FILE inside the store directory. The old `/tmp` +/// lockfile split here — A held an flock on an inode, the pathname was +/// unlinked and recreated, and B locked a fresh inode with both "holding" the +/// lock. Because the transaction lock is on the DIRECTORY inode (not a file +/// inside it), churning files within cannot swap the locked inode: B must +/// still be excluded while A holds. +#[cfg(all(unix, feature = "system-keyring"))] +#[test] +fn test_txn_lock_survives_unlink_recreate_of_file_inside() { + use std::os::unix::io::AsRawFd; + + let dir = std::env::temp_dir().join(format!("buzz-txn-unlink-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create store dir"); + let store_file = dir.join("managed-agents.json"); + std::fs::write(&store_file, b"[]").expect("seed store file"); + + // A holds the directory-inode transaction lock. + let guard_a = transaction_lock_at(&dir).expect("participant A acquires txn lock"); + + // A tmp-cleaner unlinks and recreates the store file (fresh file inode). + // The DIRECTORY inode is unchanged — it is non-empty and cannot be rmdir'd. + std::fs::remove_file(&store_file).expect("unlink store file"); + std::fs::write(&store_file, b"[]").expect("recreate store file"); + + // B (independent fd on the same directory) must STILL be excluded — the + // exact failure the /tmp-lockfile design could not prevent. + let dir_b = std::fs::File::open(&dir).expect("participant B opens dir"); + let rc = unsafe { libc::flock(dir_b.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + assert_eq!( + rc, -1, + "B must NOT acquire after an unlink/recreate of a file inside the locked dir" + ); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EWOULDBLOCK), + "exclusion must hold across the file churn" + ); + + drop(guard_a); + let rc2 = unsafe { libc::flock(dir_b.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + assert_eq!(rc2, 0, "B acquires once A releases"); + let _ = unsafe { libc::flock(dir_b.as_raw_fd(), libc::LOCK_UN) }; + let _ = std::fs::remove_dir_all(&dir); +} + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn mutate_blob_does_not_advance_cache_on_write_failure() { + // Copy-on-write safety: if `write_blob_raw` fails (denied prompt, + // transient outage, ACL rejection), the cache must stay at the last + // known durable state. A subsequent `store()` for the same key/value + // must NOT be skipped as a no-op — the equality check must compare + // against the durable cache, not an unpersisted candidate. + // + // This is a real-keychain integration test. Run locally with: + // cargo test -p buzz-desktop -- --ignored mutate_blob_does_not_advance + // + // On a machine with a reachable keychain the `store()` call succeeds + // (result.is_ok()) and the write-failure branch is skipped — the test + // still passes. On a machine where the write is denied (e.g., user + // clicks Deny in the macOS prompt) result.is_err() and the assertions + // below verify the cache invariant. We verify that after an error: + // 1. The cache is not advanced (the previously cached key is intact). + // 2. The failed key is not present (the dirty candidate was discarded). + let mut map = HashMap::new(); + map.insert("existing".to_string(), "durable_val".to_string()); + let store = SecretStore::with_cache("buzz-test-cow-write-fail", Some(map)); + + // Attempt to add a new key — this calls write_blob_raw against the + // real keychain; with copy-on-write the cache must remain at {existing} + // if the write fails. + let result = store.store("new_key", "new_val"); + + if result.is_err() { + // Write failed (e.g., user denied the keychain prompt): confirm + // cache was not advanced — the existing key is still intact and + // the new key was never committed to the in-memory state. + assert_eq!( + store.load("existing").unwrap(), + Some("durable_val".to_string()), + "cache must remain at last durable state after write failure" + ); + // load("new_key") goes through the unchanged cache (no entry), + // then attempts migrate_legacy_key which also fails on a denied + // keychain, returning either Ok(None) or Err — either is correct + // since the key was never durably stored. + let after = store.load("new_key"); + assert!( + matches!(after, Ok(None) | Err(_)), + "a key whose write failed must not be visible via load: {after:?}" + ); + } + // If result.is_ok() the write succeeded — the cache-integrity invariant + // does not apply to the success path; no assertion needed here. +} + +#[test] +fn availability_error_discriminator() { + assert!(is_keyring_availability_error("dbus connection failed")); + assert!(is_keyring_availability_error( + "org.freedesktop.secrets not provided" + )); + assert!(is_keyring_availability_error("No Secret Service")); + assert!(is_keyring_availability_error( + "Platform secure storage failure" + )); + // A plain "not found" is per-entry, not an availability failure. + assert!(!is_keyring_availability_error("entry not found")); +} + +#[cfg(target_os = "macos")] +#[test] +fn dpk_error_discriminators() { + // errSecMissingEntitlement = -34018 signals unsigned dev build. + let e = SFError::from_code(-34018); + assert!(is_dpk_unavailable(&e)); + assert!(!is_not_found(&e)); + // errSecItemNotFound = -25300 is not a DPK-unavailable error. + let e = SFError::from_code(-25300); + assert!(is_not_found(&e)); + assert!(!is_dpk_unavailable(&e)); +} + +// Integration tests that exercise the real OS keychain. Skipped in CI +// (unsigned builds lack keychain entitlements); run locally with: +// cargo test -p buzz-desktop -- --ignored blob_ +// +// Each test uses a unique service name to avoid cross-test pollution. + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn blob_stores_and_retrieves_multiple_keys() { + let store = SecretStore::keyring("buzz-test-blob-multi"); + store.store("key_a", "val_a").unwrap(); + store.store("key_b", "val_b").unwrap(); + assert_eq!(store.load("key_a").unwrap(), Some("val_a".to_string())); + assert_eq!(store.load("key_b").unwrap(), Some("val_b".to_string())); + assert_eq!(store.load("key_c").unwrap(), None); + // Cleanup. + let _ = store.delete("key_a"); + let _ = store.delete("key_b"); +} + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn blob_probe_present_absent_unreachable() { + let store = SecretStore::keyring("buzz-test-blob-probe"); + // No blob yet — key absent, backend reachable. + assert_eq!(store.probe("identity"), KeyringProbe::ReachableButEmpty); + store.store("identity", "nsec1test").unwrap(); + // Key now present. + assert_eq!(store.probe("identity"), KeyringProbe::Present); + // Different key — blob exists but key absent. + assert_eq!(store.probe("other"), KeyringProbe::ReachableButEmpty); + // Cleanup. + let _ = store.delete("identity"); +} + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn blob_delete_removes_key_not_others() { + let store = SecretStore::keyring("buzz-test-blob-delete"); + store.store("keep", "keep_val").unwrap(); + store.store("remove", "remove_val").unwrap(); + store.delete("remove").unwrap(); + assert_eq!(store.load("keep").unwrap(), Some("keep_val".to_string())); + assert_eq!(store.load("remove").unwrap(), None); + // Cleanup. + let _ = store.delete("keep"); +} + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn blob_migration_from_per_key_entry() { + let svc = "buzz-test-blob-migration"; + let key = "identity"; + let value = "nsec1migrationtest"; + + // Seed a per-key entry (old format) — no blob exists. + let entry = keyring_entry(svc, key).unwrap(); + entry.set_password(value).unwrap(); + + // Fresh store — no blob in the keychain yet. + let store = SecretStore::keyring(svc); + + // probe should find the legacy key. + assert_eq!(store.probe(key), KeyringProbe::Present); + + // load should migrate it into the blob and return the value. + assert_eq!(store.load(key).unwrap(), Some(value.to_string())); + + // Old per-key entry should be cleaned up. + let entry = keyring_entry(svc, key).unwrap(); + assert!(matches!(entry.get_password(), Err(keyring::Error::NoEntry))); + + // Key is now in the blob — probe confirms. + let store2 = SecretStore::keyring(svc); + assert_eq!(store2.probe(key), KeyringProbe::Present); + assert_eq!(store2.load(key).unwrap(), Some(value.to_string())); + + // Cleanup. + let _ = store2.delete(key); +} + +#[ignore = "requires real OS keychain (run locally)"] +#[test] +fn delete_all_with_legacy_cleanup_removes_per_key_identity() { + let svc = "buzz-test-delete-all-legacy"; + let key = "identity"; + let value = "nsec1legacytest"; + + // Seed a legacy per-key entry (old format, pre-blob migration). + let entry = keyring_entry(svc, key).unwrap(); + entry.set_password(value).unwrap(); + + // Also seed a blob with a different key to exercise the full path. + let store = SecretStore::keyring(svc); + store.store("agent:abc123", "nsec1agent").unwrap(); + + // Legacy per-key identity should be discoverable via probe. + let store2 = SecretStore::keyring(svc); + assert_eq!(store2.probe(key), KeyringProbe::Present); + + // Wipe everything via the sign-out path. + store2.delete_all_with_legacy_cleanup().unwrap(); + + // Fresh store — neither the blob nor the per-key entry should remain. + let store3 = SecretStore::keyring(svc); + assert_eq!( + store3.probe(key), + KeyringProbe::ReachableButEmpty, + "per-key identity must not survive delete_all_with_legacy_cleanup" + ); + assert_eq!( + store3.load(key).unwrap(), + None, + "load must not resurrect the legacy per-key identity" + ); + // Agent key should also be gone. + assert_eq!(store3.load("agent:abc123").unwrap(), None); +}