mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
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:
@@ -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::<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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¤t_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, ¤t_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, ¤t_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, ¤t_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, ¤t_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(
|
||||
¤t_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);
|
||||
|
||||
@@ -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(
|
||||
¤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-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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(¤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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user