From ef10f1882decfbb021cae9f9dfaef026dcc99611 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 7 Sep 2026 16:01:28 -0400 Subject: [PATCH] fix(desktop): make lifecycle persistence credential neutral Read current credentials without migration, reserve new-key writes for explicit mint/import, and merge lifecycle bookkeeping into raw storage. Revalidate named launch credentials at the shared post-teardown spawn boundary. Bind production credential revocation regressions through ordinary Stop, local Start, bulk and restore, preserving destination provisioning and inline fallback. Signed-off-by: Logan Johnson --- .../src-tauri/src/commands/agent_discovery.rs | 10 +- .../src/commands/agent_models_update_tests.rs | 2 +- desktop/src-tauri/src/commands/agents.rs | 20 +- .../src/commands/global_agent_config.rs | 8 +- .../src/commands/personas/snapshot/import.rs | 4 +- .../src-tauri/src/commands/team_snapshot.rs | 4 +- .../src-tauri/src/managed_agents/restore.rs | 4 +- .../src-tauri/src/managed_agents/runtime.rs | 11 +- .../src/managed_agents/runtime_commands.rs | 20 +- .../orchestration_tests.rs | 6 +- .../runtime_configurations/tests.rs | 4 + .../tests/credential_persistence.rs | 387 ++++++++++++++++++ .../tests/remote_credentials.rs | 18 +- .../src-tauri/src/managed_agents/storage.rs | 168 +++++--- .../src/managed_agents/storage_tests.rs | 2 +- desktop/src-tauri/src/mesh_llm/recovery.rs | 4 +- desktop/src-tauri/src/secret_store.rs | 44 ++ desktop/src-tauri/src/shutdown.rs | 4 +- docs/named-runtime-configurations.md | 5 +- 19 files changed, 606 insertions(+), 119 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/runtime_configurations/tests/credential_persistence.rs diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index ccca7c4abf..cc63d4aecd 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -505,7 +505,7 @@ async fn restart_single_agent_after_install( 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, + resolve_effective_agent_env, storage::save_runtime_metadata_batch, stop_managed_agent_process, sync_managed_agent_processes, AgentReadiness, BackendKind, }, }; @@ -536,7 +536,7 @@ async fn restart_single_agent_after_install( ¤t_instance_id(&app_for_stop), ); if sync_changed { - save_managed_agents(&app_for_stop, &records)?; + save_runtime_metadata_batch(&app_for_stop, &records)?; } // Re-verify eligibility under lock. @@ -590,7 +590,7 @@ async fn restart_single_agent_after_install( // 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)?; + save_runtime_metadata_batch(&app_for_stop, &records)?; Ok(runtime_keys) }) @@ -644,7 +644,7 @@ fn persist_last_error_on_install( ) -> Result<(), String> { use crate::{ app_state::AppState, - managed_agents::{find_managed_agent_mut, load_managed_agents, save_managed_agents}, + managed_agents::{find_managed_agent_mut, load_managed_agents, storage::save_runtime_metadata_batch}, }; use tauri::Manager; let state = app.state::(); @@ -656,7 +656,7 @@ fn persist_last_error_on_install( let record = find_managed_agent_mut(&mut records, pubkey)?; record.last_error = Some(error.to_string()); record.updated_at = crate::util::now_iso(); - save_managed_agents(app, &records) + save_runtime_metadata_batch(app, &records) } /// Build the `-l -c` argument list for the install shell. diff --git a/desktop/src-tauri/src/commands/agent_models_update_tests.rs b/desktop/src-tauri/src/commands/agent_models_update_tests.rs index 28a50e7b15..bd965dab0c 100644 --- a/desktop/src-tauri/src/commands/agent_models_update_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_update_tests.rs @@ -343,7 +343,7 @@ fn record_field_updates_persist_effort_to_disk() { "last_stopped_at": null, "last_exit_code": null, "last_error": null })) .unwrap(); - save_managed_agents(app.handle(), &[seed]).unwrap(); + crate::managed_agents::storage::save_managed_agents_with_new_keys(app.handle(), &[seed]).unwrap(); // Drive the production seam: load → apply_record_field_updates → // stamp_record_updated_at → save. This is the exact sequence that diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 2e41810235..7596feb5ad 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -377,7 +377,7 @@ where resume.as_ref(), Some(&prepared), )?; - save_managed_agents(app, &records)?; + crate::managed_agents::storage::save_runtime_metadata_batch(app, &records)?; if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { retain_managed_agent_pending(app, state, saved_record); } @@ -421,7 +421,7 @@ pub async fn list_managed_agents(app: AppHandle) -> Result Result<(), let record = find_managed_agent_mut(&mut records, pubkey)?; record.last_error = Some(error.to_string()); record.updated_at = crate::util::now_iso(); - save_managed_agents(app, &records) + save_runtime_metadata_batch(app, &records) } /// Pure predicate: should an agent be restarted given resolved readiness and diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index da2d4c766c..afaaf43bdb 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -18,7 +18,7 @@ use crate::{ decrypt_envelope, parse_chunk_payload, resolve_unlock_secret, ChunkPayload, LOCKED_CARD_REFUSAL, }, - load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition, + load_managed_agents, load_personas, storage::save_managed_agents_with_new_keys, save_personas, AgentDefinition, ManagedAgentRecord, RespondTo, }, relay::{effective_agent_relay_url, relay_ws_url_with_override}, @@ -667,7 +667,7 @@ pub async fn confirm_agent_snapshot_import( }; records.push(record.clone()); - save_managed_agents(&app, &records)?; + save_managed_agents_with_new_keys(&app, &records)?; // Enqueue the kind:30177 managed-agent event via retention. // (Uses the same pattern as agents.rs::retain_managed_agent_pending diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 2c6b7d24fd..57838ee5c0 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -17,7 +17,7 @@ use crate::{ }, managed_agents::{ agent_snapshot::{build_snapshot, AgentSnapshot, AgentSnapshotMemoryEntry, MemoryLevel}, - load_managed_agents, load_personas, load_teams, load_teams_readonly, save_managed_agents, + load_managed_agents, load_personas, load_teams, load_teams_readonly, storage::save_managed_agents_with_new_keys, save_personas, save_teams, AgentDefinition, ManagedAgentRecord, TeamRecord, }, relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile}, @@ -730,7 +730,7 @@ pub async fn confirm_team_snapshot_import( for m in &minted { records.push(m.record.clone()); } - if let Err(e) = save_managed_agents(&app, &records) { + if let Err(e) = save_managed_agents_with_new_keys(&app, &records) { return Err(rollback_agents(e)); } diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 16c173fc55..aca7cccbb3 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -532,7 +532,7 @@ where }) .collect(); - save_managed_agents(app, &records)?; + super::storage::save_runtime_metadata_batch(app, &records)?; drop(runtimes); drop(_store_guard); drop(restore_transition); @@ -612,7 +612,7 @@ fn persist_restore_error( let record = find_managed_agent_mut(&mut records, pubkey)?; record.updated_at = util::now_iso(); record.last_error = Some(error); - save_managed_agents(app, &records) + super::storage::save_runtime_metadata(app, record) } #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 23e1ee47f6..ed5c0f0b4c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -515,7 +515,16 @@ pub(crate) fn spawn_agent_child_prepared( // Keep projected settings immutable, but deliver only the current credential // that just passed revalidation, never the captured plan's copy. let mut launch_record = plan.record.clone(); - launch_record.private_key_nsec = record.private_key_nsec.clone(); + launch_record.private_key_nsec = if plan.configuration().is_some() { + let current = super::storage::load_managed_agents_for_launch(app)? + .into_iter() + .find(|saved| saved.pubkey == record.pubkey) + .ok_or("Agent removed before launch")?; + plan.revalidate(¤t, &personas, &global)?; + current.private_key_nsec + } else { + record.private_key_nsec.clone() + }; let record = &launch_record; let effective_cfg = plan.effective.clone(); let descriptor = &plan.descriptor; diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 2aca58a0fd..c23d7c6dc6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -5,7 +5,7 @@ use tauri::{AppHandle, Emitter, Manager}; use super::{ agent_readiness, current_instance_id, find_managed_agent_mut, 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, terminate_process, + record_agent_command, resolve_effective_agent_env, storage::save_runtime_metadata_batch, terminate_process, terminate_untracked_pair_runtime, write_agent_runtime_receipt, AgentReadiness, BackendKind, ManagedAgentPairRuntime, ManagedAgentRuntimeKey, ManagedAgentRuntimeLifecycle, ManagedAgentRuntimeReceipt, ManagedAgentRuntimeStatus, @@ -216,7 +216,7 @@ pub async fn list_managed_agent_runtimes( // Records are only mutated above when a runtime exited — skip the store // rewrite on the common nothing-changed poll. if records_changed { - save_managed_agents(&app, &records)?; + save_runtime_metadata_batch(&app, &records)?; } Ok(statuses) }) @@ -520,11 +520,7 @@ pub(crate) fn start_pair_captured_locked( status.lifecycle = ManagedAgentRuntimeLifecycle::Failed; status.error = Some(error); drop(runtimes); - if plan.configuration().is_some() { - super::storage::save_runtime_metadata(&app, record)?; - } else { - save_managed_agents(&app, &records)?; - } + super::storage::save_runtime_metadata(&app, record)?; emit_status(&app, &status); return Ok(status); } @@ -540,11 +536,7 @@ pub(crate) fn start_pair_captured_locked( super::remote_stop::finish_resume(&app, &key, &relay_url, Some(&owner), resume)?; let status = status_for(&app, record, &key, runtimes.get(&key), None); drop(runtimes); - if plan.configuration().is_some() { - super::storage::save_runtime_metadata(&app, record)?; - } else { - save_managed_agents(&app, &records)?; - } + super::storage::save_runtime_metadata(&app, record)?; emit_status(&app, &status); Ok(status) } @@ -612,7 +604,7 @@ pub(crate) fn stop_pair_locked( Ok(status_for(&app, record, &key, None, None)) })?; drop(runtimes); - save_managed_agents(&app, &records)?; + super::storage::save_runtime_metadata(&app, record)?; emit_status(&app, &status); Ok(status) } @@ -1041,7 +1033,7 @@ mod stop_scope_tests { ); record.pubkey = pubkey.clone(); record.updated_at = "before".into(); - super::super::save_managed_agents(app.handle(), &[record.clone()]).unwrap(); + super::super::storage::save_managed_agents_with_new_keys(app.handle(), &[record.clone()]).unwrap(); let stored_key = super::ManagedAgentRuntimeKey::new(&pubkey, stored_relay).unwrap(); let requested_key = super::ManagedAgentRuntimeKey::new(&pubkey, requested_relay).unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/orchestration_tests.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/orchestration_tests.rs index 3bf0160df4..75b9cb35ac 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_configurations/orchestration_tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/orchestration_tests.rs @@ -67,7 +67,11 @@ impl Fixture { save(&mut self.record, &self.owner, relay, entry) } fn persist(&self) { - agents::save_managed_agents(self.app.handle(), std::slice::from_ref(&self.record)).unwrap(); + agents::storage::save_managed_agents_with_new_keys( + self.app.handle(), + std::slice::from_ref(&self.record), + ) + .unwrap(); } fn finish_children(&self) -> Vec<(String, Option)> { let state = self.app.state::(); diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs index d783e19f0a..731bc7970d 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests.rs @@ -413,6 +413,10 @@ async fn shared_spawn_registers_exact_plan_and_rejects_edits_or_lost_identity() std::fs::set_permissions(&mcp_path, std::fs::Permissions::from_mode(0o700)).unwrap(); let relay = crate::relay::bind_expected_relay_scope(None, community.into()).unwrap(); let mut runtimes = std::collections::HashMap::new(); + let path = agents::storage::managed_agents_store_path(app.handle()).unwrap(); + agents::storage::atomic_write_json_restricted( + &path, &serde_json::to_vec(&[record.clone()]).unwrap(), + ).unwrap(); agents::start_managed_agent_process_prepared( app.handle(), &mut record, diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/credential_persistence.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/credential_persistence.rs new file mode 100644 index 0000000000..ad5f340d40 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/credential_persistence.rs @@ -0,0 +1,387 @@ +//! F6 production lifecycle boundaries, not standalone cache invalidation tests. +//! Parent fixture owns only synthetic identities, temporary files and fake children. +use super::*; +use std::sync::{Arc, Mutex}; + +fn write_raw(fixture: &Fixture, records: &[ManagedAgentRecord]) { + let path = agents::storage::managed_agents_store_path(fixture.app.handle()).unwrap(); + agents::storage::atomic_write_json_restricted(&path, &serde_json::to_vec(records).unwrap()) + .unwrap(); +} + +fn raw(fixture: &Fixture) -> ManagedAgentRecord { + agents::storage::load_agent_store(fixture.app.handle()) + .unwrap() + .into_iter() + .find(|r| r.pubkey == fixture.record.pubkey) + .unwrap() +} + +struct Synthetic { + backend: Arc>>, + store: &'static crate::secret_store::SecretStore, + name: String, + _guard: agents::storage::TestAgentSecretStore, +} +impl Synthetic { + fn warm(fixture: &Fixture) -> Self { + let name = format!("agent:{}", fixture.record.pubkey); + let backend = Arc::new(Mutex::new(std::collections::HashMap::from([ + (name.clone(), fixture.record.private_key_nsec.clone()), + ("identity".into(), "unrelated-synthetic-identity".into()), + ("agent:other".into(), "rotated-synthetic-key".into()), + ]))); + // Bounded to this fixture suite; the override requires a static store, + // and is always removed by its guard before another fixture runs. + let store = Box::leak(Box::new(crate::secret_store::SecretStore::synthetic( + backend.clone(), + ))); + assert_eq!( + store.load(&name).unwrap(), + Some(fixture.record.private_key_nsec.clone()) + ); + let mut record = raw(fixture); + record.private_key_nsec.clear(); + write_raw(fixture, &[record]); + Self { + backend, + store, + name, + _guard: agents::storage::TestAgentSecretStore::install(store), + } + } + fn revoke(&self) { + self.backend.lock().unwrap().remove(&self.name); + // A fresh read MUST NOT be mistaken for cache invalidation as the fix. + assert!(self + .store + .load_fresh_readonly(&self.name) + .unwrap() + .is_none()); + assert!(self.store.load(&self.name).unwrap().is_some()); + } + fn assert_absent(&self, fixture: &Fixture) { + let map = self.backend.lock().unwrap(); + assert!(!map.contains_key(&self.name)); + assert_eq!(map.get("identity").unwrap(), "unrelated-synthetic-identity"); + assert_eq!(map.get("agent:other").unwrap(), "rotated-synthetic-key"); + assert!(raw(fixture).private_key_nsec.is_empty()); + } +} + +// Acquire the process-environment lock outside the executor: no synchronous +// mutex acquisition can block a competing async fixture's executor thread. +fn with_path_lock(test: impl std::future::Future) { + let _guard = agents::lock_path_mutex(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(test); +} + +async fn assert_ineligible(fixture: &Fixture) { + let catalog = fixture.action(Action::Catalog, None).await; + assert!( + !catalog + .observation + .unwrap() + .catalog + .unwrap() + .entry + .unwrap() + .eligible + ); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Ineligible + ); + assert!(fixture.running().is_none()); +} + +#[test] +fn warm_external_revocation_survives_ordinary_signed_stop() { + with_path_lock(async { + let fixture = Fixture::new(); + let backend = Synthetic::warm(&fixture); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + fixture.launched("fixture-model|fixture-model"); + let pid = fixture.running().unwrap().0; + backend.revoke(); + let catalog = fixture.action(Action::Catalog, None).await; + assert!( + !catalog + .observation + .unwrap() + .catalog + .unwrap() + .entry + .unwrap() + .eligible + ); + assert_eq!(fixture.running().unwrap().0, pid); + assert_eq!(fixture.stop().await, StopOutcome::Stopped); + assert!(!agents::process_is_running(pid)); + backend.assert_absent(&fixture); + assert_ineligible(&fixture).await; + backend.assert_absent(&fixture); + }); +} + +#[test] +fn inline_revocation_during_ordinary_stop_survives_same_host_start() { + with_path_lock(async { + for launch in ["signed-stop", "local-start", "restore"] { + let mut fixture = Fixture::new(); + fixture.record.start_on_app_launch = true; + fixture.persist(); + let path = agents::storage::managed_agents_store_path(fixture.app.handle()).unwrap(); + let revoked_path = fixture.temp.path().join("ordinary-stop-revoked.json"); + let mut revoked = raw(&fixture); + revoked.private_key_nsec.clear(); + std::fs::write(&revoked_path, serde_json::to_vec(&[revoked]).unwrap()).unwrap(); + on_stop( + &fixture, + &format!( + "/bin/cp \"{}\" \"{}\"; exit 0", + revoked_path.display(), + path.display() + ), + ); + assert_eq!( + fixture.action(Action::Start, None).await.outcome, + Outcome::Running + ); + fixture.launched("fixture-model|fixture-model"); + let pid = fixture.running().unwrap().0; + if launch == "signed-stop" { + assert_eq!(fixture.stop().await, StopOutcome::Stopped); + } else { + // Simulate a previous Desktop session: keep only the durable receipt. + // Teardown now happens INSIDE ordinary local Start / restore, after + // preflight has captured the still-valid inline credential. + let state = fixture.app.state::(); + let key = + agents::ManagedAgentRuntimeKey::new(&fixture.record.pubkey, COMMUNITY).unwrap(); + let mut old_runtime = state + .managed_agent_processes + .lock() + .unwrap() + .remove(&key) + .unwrap(); + if launch == "local-start" { + assert!(crate::commands::start_local_agent_with_preflight_using( + fixture.app.handle(), + &state, + &fixture.record.pubkey, + crate::commands::LocalStartIntent::Explicit, + Some(COMMUNITY), + Some(&fixture.owner.public_key().to_hex()), + None, + Some(Some(&fixture.named.reference())), + |_, _| async { Ok(()) }, + ) + .await + .is_err()); + } else { + agents::restore_with( + fixture.app.handle(), + &std::sync::atomic::AtomicBool::new(false), + |_, _| async { Ok(()) }, + |_| {}, + |_, _| panic!("post-teardown revoked restore must not publish"), + ) + .await + .unwrap(); + } + // Reap the synthetic child even though Desktop no longer tracks it. + let _ = agents::terminate_process(old_runtime.child.id()); + let _ = old_runtime.child.wait(); + } + assert!(!agents::process_is_running(pid)); + assert!(raw(&fixture).private_key_nsec.is_empty()); + assert_ineligible(&fixture).await; + assert!(raw(&fixture).private_key_nsec.is_empty()); + assert_eq!( + std::fs::read_to_string(fixture.temp.path().join("launches")) + .unwrap() + .lines() + .count(), + 1 + ); + } + }); +} + +#[test] +fn ordinary_local_named_start_cannot_provision_from_warm_cache() { + with_path_lock(async { + for before_preparation in [true, false] { + for preflight_succeeds in [true, false] { + let fixture = Fixture::new(); + let backend = Synthetic::warm(&fixture); + if before_preparation { + backend.revoke(); + } + let state = fixture.app.state::(); + let result = crate::commands::start_local_agent_with_preflight_using( + fixture.app.handle(), + &state, + &fixture.record.pubkey, + crate::commands::LocalStartIntent::Explicit, + Some(COMMUNITY), + Some(&fixture.owner.public_key().to_hex()), + None, + Some(Some(&fixture.named.reference())), + |_, _| { + if !before_preparation { + backend.revoke(); + } + backend.assert_absent(&fixture); + async move { + if preflight_succeeds { + Ok(()) + } else { + Err("synthetic provider refusal".into()) + } + } + }, + ) + .await; + assert!(result.is_err()); + backend.assert_absent(&fixture); + assert_ineligible(&fixture).await; + assert!(!fixture.temp.path().join("launches").exists()); + } + } + }); +} + +#[test] +fn bulk_and_restore_preparation_never_restore_cached_credentials() { + with_path_lock(async { + for restore in [false, true] { + let mut fixture = Fixture::new(); + fixture.record.start_on_app_launch = true; + fixture.persist(); + let backend = Synthetic::warm(&fixture); + if restore { + let shutdown = std::sync::atomic::AtomicBool::new(false); + agents::restore_with( + fixture.app.handle(), + &shutdown, + |_, _| { + backend.revoke(); + async { Ok(()) } + }, + |_| {}, + |_, _| panic!("revoked restore must not publish"), + ) + .await + .unwrap(); + } else { + let state = fixture.app.state::(); + let result = crate::commands::start_local_agent_pairs_with_preflight_using( + fixture.app.handle(), + &state, + &fixture.record.pubkey, + &[COMMUNITY.into()], + |_, _| { + backend.revoke(); + async { Ok(()) } + }, + ) + .await; + assert!(result.is_err()); + } + backend.assert_absent(&fixture); + assert_ineligible(&fixture).await; + assert!(!fixture.temp.path().join("launches").exists()); + } + }); +} + +#[test] +fn metadata_merge_preserves_rotations_scopes_and_removed_records() { + with_path_lock(async { + let fixture = Fixture::new(); + let stale = raw(&fixture); + let mut current = stale.clone(); + current.private_key_nsec = "deliberately-rotated-inline-fixture".into(); + current.name = "newer definition".into(); + save( + &mut current, + &fixture.owner.public_key().to_hex(), + "wss://other-scope.example", + config("other-host"), + ); + let mut other = current.clone(); + other.pubkey = nostr::Keys::generate().public_key().to_hex(); + other.private_key_nsec = "other-inline-fixture".into(); + write_raw(&fixture, &[current.clone(), other.clone()]); + agents::storage::save_runtime_metadata_batch( + fixture.app.handle(), + std::slice::from_ref(&stale), + ) + .unwrap(); + let saved = raw(&fixture); + assert_eq!(saved.private_key_nsec, current.private_key_nsec); + assert_eq!(saved.name, current.name); + assert_eq!( + serde_json::to_value(saved.runtime_configurations).unwrap(), + serde_json::to_value(current.runtime_configurations).unwrap() + ); + assert_eq!( + agents::storage::load_agent_store(fixture.app.handle()).unwrap()[1].private_key_nsec, + other.private_key_nsec + ); + // Ordinary edits also have no key-write authority; a stale key cannot undo + // a legitimate rotation, and no missing identity is implicitly provisioned. + agents::save_managed_agents(fixture.app.handle(), &[stale.clone(), other.clone()]).unwrap(); + assert_eq!( + raw(&fixture).private_key_nsec, + "deliberately-rotated-inline-fixture" + ); + write_raw(&fixture, &[other]); + agents::storage::save_runtime_metadata_batch( + fixture.app.handle(), + std::slice::from_ref(&stale), + ) + .unwrap(); + assert_eq!( + agents::storage::load_agent_store(fixture.app.handle()) + .unwrap() + .len(), + 1 + ); + assert!(agents::save_managed_agents(fixture.app.handle(), &[stale]).is_err()); + }); +} + +#[test] +fn deliberate_new_identity_provisioning_does_not_rewrite_existing_keys() { + with_path_lock(async { + let fixture = Fixture::new(); + let backend = Synthetic::warm(&fixture); + backend.revoke(); + let new_record = record(); + let new_name = format!("agent:{}", new_record.pubkey); + assert_ne!(new_record.pubkey, fixture.record.pubkey); + let records = [fixture.record.clone(), new_record.clone()]; + assert!(agents::save_managed_agents(fixture.app.handle(), &records).is_err()); + backend.assert_absent(&fixture); + agents::storage::save_managed_agents_with_new_keys(fixture.app.handle(), &records).unwrap(); + backend.assert_absent(&fixture); + assert_eq!( + backend.store.load_fresh_readonly(&new_name).unwrap(), + Some(new_record.private_key_nsec) + ); + assert!(agents::storage::load_agent_store(fixture.app.handle()) + .unwrap() + .iter() + .all(|r| r.private_key_nsec.is_empty())); + }); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/remote_credentials.rs b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/remote_credentials.rs index 150a90c926..0a48c2d042 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/remote_credentials.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_configurations/tests/remote_credentials.rs @@ -89,7 +89,14 @@ impl Fixture { fixture } fn persist(&self) { - agents::save_managed_agents(self.app.handle(), &[self.record.clone()]).unwrap(); + agents::storage::save_managed_agents_with_new_keys(self.app.handle(), &[self.record.clone()]).unwrap(); + } + // Simulate an independent destination credential writer, not a metadata edit. + fn write_raw(&self, record: &ManagedAgentRecord) { + let path = agents::storage::managed_agents_store_path(self.app.handle()).unwrap(); + agents::storage::atomic_write_json_restricted( + &path, &serde_json::to_vec(&[record]).unwrap(), + ).unwrap(); } fn target(&self) -> StopTarget { StopTarget { @@ -300,7 +307,7 @@ async fn unavailable_identity_or_runtime_excludes_catalog_and_refuses_start() { std::fs::remove_file(&fixture.record.acp_command).unwrap(); } } - fixture.persist(); + fixture.write_raw(&fixture.record); let catalog = fixture.action(Action::Catalog, None).await; assert_eq!(catalog.outcome, Outcome::Ready); assert!( @@ -333,7 +340,7 @@ async fn revoked_key_keeps_existing_process_visible_and_restart_refuses_without_ fixture.launched("fixture-model|fixture-model"); let pid = fixture.running().unwrap().0; fixture.record.private_key_nsec.clear(); - fixture.persist(); + fixture.write_raw(&fixture.record); let status = fixture.action(Action::Status, None).await; assert_eq!(status.outcome, Outcome::Running); assert_eq!( @@ -357,7 +364,7 @@ async fn key_loss_during_preflight_is_rechecked_for_start_and_catalog() { .receive(fixture.request(action, None), |_, _| { let mut revoked = fixture.record.clone(); revoked.private_key_nsec.clear(); - agents::save_managed_agents(fixture.app.handle(), &[revoked]).unwrap(); + fixture.write_raw(&revoked); async { Ok(()) } }) .await; @@ -531,3 +538,6 @@ async fn post_stop_expiry_persists_truthful_failed_without_second_spawn() { let saved = assert_failed_without_child(&fixture); assert_eq!(saved.private_key_nsec, fixture.record.private_key_nsec); } + +#[path = "credential_persistence.rs"] +mod credential_persistence; diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 96710b489a..b34a1fc7fe 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -25,6 +25,10 @@ fn agent_keyring_name(pubkey: &str) -> String { /// and therefore one in-memory cache and one mutex — preventing last-writer-wins /// races on concurrent blob writes. fn agent_secret_store() -> Option<&'static SecretStore> { + #[cfg(all(test, unix, not(feature = "system-keyring")))] + if let Some(store) = *TEST_AGENT_SECRET_STORE.lock().unwrap() { + return Some(store); + } if cfg!(feature = "system-keyring") { Some(SecretStore::shared(keyring_service())) } else { @@ -141,7 +145,7 @@ trait KeyStore { fn probe(&self, name: &str) -> KeyringProbe; /// Read a key. `Ok(None)` is "no such entry" (absent); `Err` is a backend /// failure (keyring unreachable) — the caller MUST NOT collapse the two. - fn load(&self, name: &str) -> Result, String>; + fn load_fresh_readonly(&self, name: &str) -> Result, String>; /// Read the entire blob as a map without any side effects. /// `Ok(None)` when no blob exists yet; `Err` only on backend failure. /// Callers must not call `migrate_legacy_key` — this is a read-only view. @@ -157,8 +161,8 @@ impl KeyStore for SecretStore { fn probe(&self, name: &str) -> KeyringProbe { SecretStore::probe(self, name) } - fn load(&self, name: &str) -> Result, String> { - SecretStore::load(self, name) + fn load_fresh_readonly(&self, name: &str) -> Result, String> { + SecretStore::load_fresh_readonly(self, name) } fn load_all_readonly(&self) -> Result>, String> { SecretStore::load_all_readonly(self) @@ -195,9 +199,8 @@ enum KeyMigration { /// verify. Pure decision logic — does NOT mutate the record, so the caller /// chooses whether to strip the inline copy based on the returned outcome. /// -/// 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 +/// The single source of truth for the migrate-vs-keep decision, used only by the +/// explicit new-identity provisioning chokepoint ([`persist_agent_keys`]). 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 { @@ -225,7 +228,7 @@ fn migrate_inline_key(store: &impl KeyStore, record: &ManagedAgentRecord) -> Key } /// Refuse to spawn an agent whose private key is unavailable. Returns -/// `Some(error)` when `private_key_nsec` is empty — after [`hydrate_keys`] an +/// `Some(error)` when `private_key_nsec` is empty — after [`load_managed_agents`] an /// empty key means a keyring outage or a genuinely absent secret, NOT a /// deliberately keyless agent. Spawning anyway would inject an empty /// `BUZZ_PRIVATE_KEY`/`NOSTR_PRIVATE_KEY`, launching with no identity. Callers @@ -265,15 +268,12 @@ pub(crate) fn load_agent_store( } /// Load the keyed agent *instances*. Key-less definitions (former personas, -/// folded into the same store) are filtered out so every pre-fold call site -/// keeps seeing exactly the records it always did. +/// folded into the same store) are filtered out. Reads observe currently +/// provisioned credentials without cache hydration or migration side effects. pub fn load_managed_agents( app: &AppHandle, ) -> Result, String> { - let mut records = load_agent_store(app)?; - records.retain(|record| !record.pubkey.is_empty()); - hydrate_keys(&mut records); - Ok(records) + load_managed_agents_for_launch(app) } /// Read launch inputs without migrating keys or trusting a warm keyring cache. @@ -285,15 +285,7 @@ pub(crate) fn load_managed_agents_for_launch( let mut records = load_agent_store(app)?; records.retain(|record| !record.pubkey.is_empty()); if let Some(store) = agent_secret_store() { - for record in &mut records { - if record.private_key_nsec.is_empty() { - record.private_key_nsec = store - .load_fresh_readonly(&agent_keyring_name(&record.pubkey)) - .ok() - .flatten() - .unwrap_or_default(); - } - } + hydrate_keys_with(store, &mut records); } Ok(records) } @@ -326,24 +318,7 @@ pub(crate) fn backup_invalid_store(path: &Path) { } } -/// Fill in each record's in-memory `private_key_nsec` from the keyring, and -/// opportunistically re-migrate any key that is still inline. -/// -/// - Empty key → fetch it from the keyring (the normal keyring-backed case). -/// - Non-empty key → the JSON carried it inline because the keyring was -/// unreachable at its last save. Re-migrate it now ([`migrate_inline_key`]): -/// if the keyring is reachable this boot, write-verify-strip so the next save -/// writes clean JSON and plaintext stops lingering on disk; if still -/// unreachable, leave it inline. This makes the strip deterministic on the -/// next reachable boot rather than waiting for a non-deterministic save. -fn hydrate_keys(records: &mut [ManagedAgentRecord]) { - let Some(store) = agent_secret_store() else { - return; - }; - hydrate_keys_with(store, records); -} - -/// Testable core of [`hydrate_keys`], generic over the [`KeyStore`] seam. +/// Fresh read-only hydration, shared by ordinary reads and launch admission. /// /// A keyring LOAD error (`Err`) is an OUTAGE — distinct from `Ok(None)` /// (genuinely absent). On an outage the key is left empty and the record is @@ -359,7 +334,7 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) continue; } if record.private_key_nsec.is_empty() { - match store.load(&agent_keyring_name(&record.pubkey)) { + match store.load_fresh_readonly(&agent_keyring_name(&record.pubkey)) { Ok(Some(nsec)) => record.private_key_nsec = nsec, Ok(None) => { eprintln!( @@ -378,56 +353,93 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) ); } } - } else { - // Inline residue from a prior keyring-unreachable save. Lift it - // into the keyring now (side effect) but KEEP it in memory — the - // returned record must carry the key for readers. The next save - // then strips it from JSON. Outcome is intentionally ignored: - // on failure the key simply stays inline until a later boot. - let _ = migrate_inline_key(store, record); } } } -/// Save the keyed agent *instances*, preserving the key-less definitions that -/// share the unified store: callers pass exactly the records they loaded via -/// [`load_managed_agents`], and this re-reads the definition half from disk -/// before the wholesale rewrite so a definition is never dropped by an -/// instance-side save (and vice versa via [`save_agent_definitions`]). +/// Save instance edits/deletions without credential-write authority. Existing +/// inline keys come from the current raw store, never the supplied hydrated +/// records; the secret backend is not written. New identities must use the +/// explicit creation/provisioning entry point. Caller holds the store lock. pub fn save_managed_agents( app: &AppHandle, records: &[ManagedAgentRecord], ) -> Result<(), String> { - let definitions = load_agent_definitions(app).unwrap_or_default(); + save_agent_edits(app, records, false) +} + +/// Persist deliberately created identities, migrating ONLY newly inserted keys. +/// Existing agents' credentials are still owned by current storage. This is for +/// mint/import commits, never lifecycle status, cleanup, or launch preparation. +pub(crate) fn save_managed_agents_with_new_keys( + app: &AppHandle, + records: &[ManagedAgentRecord], +) -> Result<(), String> { + save_agent_edits(app, records, true) +} + +fn save_agent_edits( + app: &AppHandle, + records: &[ManagedAgentRecord], + provision_new: bool, +) -> Result<(), String> { + let current = load_agent_store(app)?; 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. sorted.retain(|record| !record.pubkey.is_empty()); + // Validate the whole edit before performing any intentional provisioning. + if !provision_new + && sorted + .iter() + .any(|r| !current.iter().any(|c| c.pubkey == r.pubkey)) + { + return Err("New agent keys require explicit provisioning".into()); + } + for record in &mut sorted { + if let Some(saved) = current.iter().find(|r| r.pubkey == record.pubkey) { + record.private_key_nsec = saved.private_key_nsec.clone(); + } else { + persist_agent_keys(std::slice::from_mut(record)); + } + } sorted.sort_by(|left, right| { left.name .to_lowercase() .cmp(&right.name.to_lowercase()) .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); - + let definitions = current + .into_iter() + .filter(|r| r.pubkey.is_empty()) + .collect(); write_agent_store(app, definitions, sorted) } -/// Persist only lifecycle bookkeeping from a named launch. Caller holds the +/// Persist only lifecycle bookkeeping from a launch, Stop, or cleanup. Caller holds the /// store lock. Never feed captured/hydrated keys into the migration save path: /// the fresh raw store owns inline credentials, and the keyring is not written. /// Re-read even on failure so a revoked key or removed record stays removed. pub(crate) fn save_runtime_metadata( app: &AppHandle, record: &ManagedAgentRecord, +) -> Result<(), String> { + save_runtime_metadata_batch(app, std::slice::from_ref(record)) +} + +/// Merge lifecycle bookkeeping into current raw records. Retains unrelated +/// agents, definitions, configuration scopes and current credentials, including +/// rotation or revocation during teardown. Never re-adds a removed record. +pub(crate) fn save_runtime_metadata_batch( + app: &AppHandle, + records: &[ManagedAgentRecord], ) -> Result<(), String> { let mut current = load_agent_store(app)?; - if let Some(saved) = current.iter_mut().find(|r| r.pubkey == record.pubkey) { + for saved in &mut current { + let Some(record) = records + .iter() + .find(|r| !r.pubkey.is_empty() && r.pubkey == saved.pubkey) + else { + continue; + }; saved.runtime_pid = record.runtime_pid; saved.updated_at = record.updated_at.clone(); saved.last_started_at = record.last_started_at.clone(); @@ -512,8 +524,8 @@ fn persist_agent_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRec /// untouched — a dev build and a prod install can coexist without sharing /// keys after this migration. /// -/// Call this at boot before `hydrate_keys` runs (i.e. before -/// `load_managed_agents` is called) so agents find their keys on first boot +/// Call this explicit migration at boot before +/// `load_managed_agents` is called so agents find their keys on first boot /// after the service-name change. #[cfg(debug_assertions)] pub fn migrate_agent_keys_to_dev_service(app: &tauri::AppHandle) { @@ -1040,3 +1052,27 @@ pub fn meaningful_agent_error_from_log(path: &Path) -> Option { #[cfg(test)] #[path = "storage_tests.rs"] mod tests; + +// Fixtures serialize with the existing path-test lock. Only synthetic stores +// are installed here, and the guard resets the override before releasing it. +#[cfg(all(test, unix, not(feature = "system-keyring")))] +static TEST_AGENT_SECRET_STORE: std::sync::Mutex> = + std::sync::Mutex::new(None); + +#[cfg(all(test, unix, not(feature = "system-keyring")))] +pub(crate) struct TestAgentSecretStore; + +#[cfg(all(test, unix, not(feature = "system-keyring")))] +impl TestAgentSecretStore { + pub(crate) fn install(store: &'static SecretStore) -> Self { + *TEST_AGENT_SECRET_STORE.lock().unwrap() = Some(store); + Self + } +} + +#[cfg(all(test, unix, not(feature = "system-keyring")))] +impl Drop for TestAgentSecretStore { + fn drop(&mut self) { + *TEST_AGENT_SECRET_STORE.lock().unwrap() = None; + } +} diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index d39fcf4100..8ff12efc0f 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -72,7 +72,7 @@ impl KeyStore for FakeKeyStore { KeyringProbe::Unreachable } } - fn load(&self, name: &str) -> Result, String> { + fn load_fresh_readonly(&self, name: &str) -> Result, String> { // An unreachable backend errors on read (outage), distinct from a // reachable backend returning `Ok(None)` for an absent entry. if !self.reachable { diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index 768a9c47e8..6e0dbcfd1d 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -413,7 +413,7 @@ fn persist_mesh_last_error(app: &AppHandle, pubkey: &str, error: &str) -> Result let record = crate::managed_agents::find_managed_agent_mut(&mut records, pubkey)?; record.last_error = Some(error.to_string()); record.updated_at = crate::util::now_iso(); - crate::managed_agents::save_managed_agents(app, &records) + crate::managed_agents::storage::save_runtime_metadata(app, record) } fn clear_mesh_last_error_if_set(app: &AppHandle, pubkey: &str) -> Result<(), String> { @@ -433,7 +433,7 @@ fn clear_mesh_last_error_if_set(app: &AppHandle, pubkey: &str) -> Result<(), Str } record.last_error = None; record.updated_at = crate::util::now_iso(); - crate::managed_agents::save_managed_agents(app, &records) + crate::managed_agents::storage::save_runtime_metadata(app, record) } #[cfg(test)] diff --git a/desktop/src-tauri/src/secret_store.rs b/desktop/src-tauri/src/secret_store.rs index f3ccf2be73..8673e6eeb7 100644 --- a/desktop/src-tauri/src/secret_store.rs +++ b/desktop/src-tauri/src/secret_store.rs @@ -217,6 +217,10 @@ impl Drop for BlobLockGuard { /// single JSON blob entry (one OS prompt per process lifetime). pub struct SecretStore { service: String, + // Isolated production-boundary fixtures can supply a synthetic backend. + // No OS keyring operation is reachable when this is set. + #[cfg(test)] + test_backend: Option>>>, /// In-memory cache of the deserialized blob. `None` means "not yet loaded". cache: Mutex>>, } @@ -228,6 +232,8 @@ impl SecretStore { pub fn keyring(service: impl Into) -> Self { SecretStore { service: service.into(), + #[cfg(test)] + test_backend: None, cache: Mutex::new(None), } } @@ -474,6 +480,14 @@ impl SecretStore { /// Probe whether `key` exists and whether the backend is reachable. pub fn probe(&self, key: &str) -> KeyringProbe { + #[cfg(test)] + if let Some(backend) = &self.test_backend { + return if backend.lock().unwrap().contains_key(key) { + KeyringProbe::Present + } else { + KeyringProbe::ReachableButEmpty + }; + } #[cfg(feature = "system-keyring")] { match self.load_blob() { @@ -547,6 +561,12 @@ impl SecretStore { /// migration fires when the blob exists but the key is absent, covering /// partial-migration scenarios (e.g. identity migrated first, agents not yet). pub fn load(&self, key: &str) -> Result, String> { + #[cfg(test)] + if let Some(backend) = &self.test_backend { + let mut cache = self.cache.lock().map_err(|e| e.to_string())?; + let map = cache.get_or_insert_with(|| backend.lock().unwrap().clone()); + return Ok(map.get(key).cloned()); + } #[cfg(feature = "system-keyring")] { match self.load_blob() { @@ -596,6 +616,10 @@ impl SecretStore { /// Read one already-provisioned secret from the backend, bypassing the cache. /// Launch admission must observe revocation and outages; never migrate or write. pub(crate) fn load_fresh_readonly(&self, key: &str) -> Result, String> { + #[cfg(test)] + if let Some(backend) = &self.test_backend { + return Ok(backend.lock().map_err(|e| e.to_string())?.get(key).cloned()); + } #[cfg(feature = "system-keyring")] { let Some(raw) = self.read_blob_raw()? else { @@ -746,6 +770,13 @@ impl SecretStore { /// Store `value` for `key`. Reports `Err` on availability failures — callers /// decide whether to fall back to file storage. pub fn store(&self, key: &str, value: &str) -> Result<(), String> { + #[cfg(test)] + if let Some(backend) = &self.test_backend { + let mut map = backend.lock().map_err(|e| e.to_string())?; + map.insert(key.into(), value.into()); + *self.cache.lock().map_err(|e| e.to_string())? = Some(map.clone()); + return Ok(()); + } #[cfg(feature = "system-keyring")] { self.mutate_blob(|map| { @@ -949,6 +980,7 @@ mod tests { fn with_cache(service: &str, cache: Option>) -> Self { SecretStore { service: service.to_string(), + test_backend: None, cache: Mutex::new(cache), } } @@ -1323,3 +1355,15 @@ mod tests { assert_eq!(store3.load("agent:abc123").unwrap(), None); } } + +#[cfg(all(test, unix, not(feature = "system-keyring")))] +impl SecretStore { + /// Synthetic backend with an independent warm cache; never opens a keyring. + pub(crate) fn synthetic(backend: std::sync::Arc>>) -> Self { + Self { + service: "synthetic-agent-credentials".into(), + cache: Mutex::new(None), + test_backend: Some(backend), + } + } +} diff --git a/desktop/src-tauri/src/shutdown.rs b/desktop/src-tauri/src/shutdown.rs index b1548c6937..c2c7044aa0 100644 --- a/desktop/src-tauri/src/shutdown.rs +++ b/desktop/src-tauri/src/shutdown.rs @@ -2,7 +2,7 @@ use tauri::Manager; use crate::app_state::AppState; use crate::managed_agents::{ - self, kill_stale_tracked_processes, load_managed_agents, save_managed_agents, + self, kill_stale_tracked_processes, load_managed_agents, storage::save_runtime_metadata_batch, sync_managed_agent_processes, BackendKind, }; use crate::{prevent_sleep, util}; @@ -263,7 +263,7 @@ pub(crate) fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), Stri managed_agents::reap_dead_instance_agents(&managed_agents::current_instance_id(app), &[]); if changed { - save_managed_agents(app, &records)?; + save_runtime_metadata_batch(app, &records)?; } Ok(()) diff --git a/docs/named-runtime-configurations.md b/docs/named-runtime-configurations.md index 5ce5b74dbc..f9c7ebafc4 100644 --- a/docs/named-runtime-configurations.md +++ b/docs/named-runtime-configurations.md @@ -80,7 +80,7 @@ after async preflight. It is advisory and expires, never launch authority. Execution repeats those checks under the shared admission lock before destructive Restart and after teardown, so a revoked key cannot be recovered from a captured -plan. Named launch success/failure saves only lifecycle metadata into a fresh raw +plan. Lifecycle success/failure, Stop, and cleanup save only metadata into a fresh raw store; it never persists captured/hydrated credentials or migrates keys. Ownership and generation reads likewise do not hydrate or migrate keys. The request deadline also survives through Stop into shared spawn. Failure after Stop is Failed, not @@ -101,5 +101,6 @@ receiver and shared launch with synthetic identities and bounded child processes including actual Stop-time credential/executable loss and post-Stop expiry. Run it on Unix with `cargo test --manifest-path desktop/src-tauri/Cargo.toml --no-default-features managed_agents::runtime_configurations::tests::remote_credentials --- --test-threads=1` (seven tests; default system-keyring builds exclude the fixture). +-- --test-threads=1` (13 tests, including the credential-persistence child module; default system-keyring +builds exclude the fixture). These fixture suites do not constitute real-model or two-Desktop acceptance.