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 <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-07 16:01:28 -04:00
parent 61943e15ec
commit ef10f1882d
19 changed files with 606 additions and 119 deletions

View File

@@ -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(
&current_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::<AppState>();
@@ -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.

View File

@@ -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

View File

@@ -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<Vec<ManagedAgentSumma
let (sync_changed, exited_pubkeys) =
sync_managed_agent_processes(&mut records, &mut runtimes, &current_instance_id(&app));
if sync_changed {
save_managed_agents(&app, &records)?;
crate::managed_agents::storage::save_runtime_metadata_batch(&app, &records)?;
}
for pubkey in &exited_pubkeys {
state.clear_agent_session_caches(pubkey);
@@ -505,7 +505,7 @@ pub async fn create_managed_agent(
let (sync_changed, exited_pubkeys) =
sync_managed_agent_processes(&mut records, &mut runtimes, &current_instance_id(&app));
if sync_changed {
save_managed_agents(&app, &records)?;
crate::managed_agents::storage::save_runtime_metadata_batch(&app, &records)?;
}
for pubkey in &exited_pubkeys {
state.clear_agent_session_caches(pubkey);
@@ -576,7 +576,7 @@ pub async fn create_managed_agent(
let (sync_changed, exited_pubkeys) =
sync_managed_agent_processes(&mut records, &mut runtimes, &current_instance_id(&app));
if sync_changed {
save_managed_agents(&app, &records)?;
crate::managed_agents::storage::save_runtime_metadata_batch(&app, &records)?;
}
for pubkey in &exited_pubkeys {
state.clear_agent_session_caches(pubkey);
@@ -806,7 +806,7 @@ pub async fn create_managed_agent(
records.push(record);
save_managed_agents(&app, &records)?;
crate::managed_agents::storage::save_managed_agents_with_new_keys(&app, &records)?;
let record = records
.iter()
@@ -854,7 +854,7 @@ pub async fn create_managed_agent(
let record = find_managed_agent_mut(&mut records, &pubkey)?;
record.updated_at = now_iso();
record.last_error = Some(error.clone());
save_managed_agents(&app, &records)?;
crate::managed_agents::storage::save_runtime_metadata_batch(&app, &records)?;
spawn_error = Some(error);
let record = records
.iter()
@@ -1006,7 +1006,7 @@ pub async fn start_managed_agent(
let (sync_changed, exited_pubkeys) =
sync_managed_agent_processes(&mut records, &mut runtimes, &current_instance_id(&app));
if sync_changed {
save_managed_agents(&app, &records)?;
crate::managed_agents::storage::save_runtime_metadata_batch(&app, &records)?;
}
for pubkey in &exited_pubkeys {
state.clear_agent_session_caches(pubkey);
@@ -1155,7 +1155,7 @@ pub async fn stop_managed_agent(
let (sync_changed, exited_pubkeys) =
sync_managed_agent_processes(&mut records, &mut runtimes, &current_instance_id(&app));
if sync_changed {
save_managed_agents(&app, &records)?;
crate::managed_agents::storage::save_runtime_metadata_batch(&app, &records)?;
}
for pubkey in &exited_pubkeys {
state.clear_agent_session_caches(pubkey);
@@ -1174,7 +1174,7 @@ pub async fn stop_managed_agent(
// the config-restart flows still drain every pair.
stop_managed_agent_workspace_pair(&app, record, &mut runtimes)?;
}
save_managed_agents(&app, &records)?;
crate::managed_agents::storage::save_runtime_metadata_batch(&app, &records)?;
let record = records
.iter()
.find(|record| record.pubkey == pubkey)
@@ -1233,7 +1233,7 @@ pub async fn delete_managed_agent(
&current_instance_id(&app),
);
if sync_changed {
save_managed_agents(&app, &records)?;
crate::managed_agents::storage::save_runtime_metadata_batch(&app, &records)?;
}
for pubkey in &exited_pubkeys {
state.clear_agent_session_caches(pubkey);

View File

@@ -18,7 +18,7 @@ use crate::{
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,
resolve_effective_agent_env, save_global_agent_config, storage::save_runtime_metadata_batch,
stop_managed_agent_process, sync_managed_agent_processes, validate_global_config,
AgentReadiness, BackendKind, GlobalAgentConfig,
},
@@ -280,7 +280,7 @@ async fn restart_local_agent_on_config_change(
&current_instance_id(&app_for_stop),
);
if sync_changed {
save_managed_agents(&app_for_stop, &records)?;
save_runtime_metadata_batch(&app_for_stop, &records)?;
}
// Re-check eligibility under lock with current record state.
@@ -325,7 +325,7 @@ async fn restart_local_agent_on_config_change(
// 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)
})
@@ -386,7 +386,7 @@ fn persist_last_error(app: &AppHandle, pubkey: &str, error: &str) -> 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

View File

@@ -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

View File

@@ -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));
}

View File

@@ -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<R: tauri::Runtime>(
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)]

View File

@@ -515,7 +515,16 @@ pub(crate) fn spawn_agent_child_prepared<R: tauri::Runtime>(
// 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(&current, &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;

View File

@@ -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<R: tauri::Runtime>(
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<R: tauri::Runtime>(
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<R: tauri::Runtime>(
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();

View File

@@ -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<RuntimeConfigurationRef>)> {
let state = self.app.state::<crate::app_state::AppState>();

View File

@@ -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,

View File

@@ -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<Mutex<std::collections::HashMap<String, String>>>,
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<Output = ()>) {
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::<crate::app_state::AppState>();
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::<crate::app_state::AppState>();
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::<crate::app_state::AppState>();
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()));
});
}

View File

@@ -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;

View File

@@ -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<Option<String>, String>;
fn load_fresh_readonly(&self, name: &str) -> Result<Option<String>, 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<Option<String>, String> {
SecretStore::load(self, name)
fn load_fresh_readonly(&self, name: &str) -> Result<Option<String>, String> {
SecretStore::load_fresh_readonly(self, name)
}
fn load_all_readonly(&self) -> Result<Option<HashMap<String, String>>, 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<R: tauri::Runtime>(
}
/// 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<R: tauri::Runtime>(
app: &AppHandle<R>,
) -> Result<Vec<ManagedAgentRecord>, 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<R: tauri::Runtime>(
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<R: tauri::Runtime>(
app: &AppHandle<R>,
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<R: tauri::Runtime>(
app: &AppHandle<R>,
records: &[ManagedAgentRecord],
) -> Result<(), String> {
save_agent_edits(app, records, true)
}
fn save_agent_edits<R: tauri::Runtime>(
app: &AppHandle<R>,
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<R: tauri::Runtime>(
app: &AppHandle<R>,
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<R: tauri::Runtime>(
app: &AppHandle<R>,
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<AgentLogError> {
#[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<Option<&'static SecretStore>> =
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;
}
}

View File

@@ -72,7 +72,7 @@ impl KeyStore for FakeKeyStore {
KeyringProbe::Unreachable
}
}
fn load(&self, name: &str) -> Result<Option<String>, String> {
fn load_fresh_readonly(&self, name: &str) -> Result<Option<String>, String> {
// An unreachable backend errors on read (outage), distinct from a
// reachable backend returning `Ok(None)` for an absent entry.
if !self.reachable {

View File

@@ -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)]

View File

@@ -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<std::sync::Arc<Mutex<HashMap<String, String>>>>,
/// In-memory cache of the deserialized blob. `None` means "not yet loaded".
cache: Mutex<Option<HashMap<String, String>>>,
}
@@ -228,6 +232,8 @@ impl SecretStore {
pub fn keyring(service: impl Into<String>) -> 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<Option<String>, 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<Option<String>, 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<HashMap<String, String>>) -> 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<Mutex<HashMap<String, String>>>) -> Self {
Self {
service: "synthetic-agent-credentials".into(),
cache: Mutex::new(None),
test_backend: Some(backend),
}
}
}

View File

@@ -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(())

View File

@@ -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.