mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
refactor(desktop): split secret-projection modules to clear file-size ratchet
Complete the module extraction and field wiring left in working-tree state: - Wire dev_service_migration and secret_projection_tests as modules - Extract dev keyring secrets migration into dev_service_migration.rs and secret_projection tests into secret_projection_tests.rs so both parent files stay under the 1000-line desktop ratchet - Reclaim ratchet lines in the six over-cap files touched by the secrets_unavailable field additions (types.rs, migration.rs, agents.rs, import.rs, discovery/tests.rs, readiness.rs) Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -1356,8 +1356,7 @@ pub async fn delete_managed_agent(
|
||||
// Remote agent shutdown is handled entirely by the frontend:
|
||||
// 1. Frontend sends "!shutdown" @mention via WebSocket (signed by user's key)
|
||||
// 2. Harness sees it, exits gracefully, sets presence to "offline"
|
||||
// 3. Desktop's existing presence polling sees "offline" — UI updates automatically
|
||||
// No backend Tauri command needed. Presence IS the status.
|
||||
// 3. Desktop's existing presence polling sees "offline" — UI updates automatically. No backend command needed; presence IS the status.
|
||||
#[path = "agents_deploy.rs"]
|
||||
mod deploy;
|
||||
pub(super) mod provider_access;
|
||||
|
||||
@@ -984,7 +984,6 @@ mod import_avatar_tests {
|
||||
|_| async { Err("relay upload failed".to_string()) },
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result.unwrap_err(), "relay upload failed");
|
||||
}
|
||||
#[tokio::test]
|
||||
|
||||
179
desktop/src-tauri/src/managed_agents/dev_service_migration.rs
Normal file
179
desktop/src-tauri/src/managed_agents/dev_service_migration.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
//! Dev-build keyring secrets migration.
|
||||
//!
|
||||
//! Extracted from `storage.rs` to keep that module under the desktop
|
||||
//! file-size ratchet. Copies secret-projection keys into the dev keyring
|
||||
//! service on debug boots; a no-op in release builds.
|
||||
|
||||
use crate::app_state::keyring_service;
|
||||
use tauri::Manager;
|
||||
|
||||
/// Marker key for the secrets migration to the dev keyring service.
|
||||
/// Versioned separately from `_dev_migration_v1` (which covers only nsec keys)
|
||||
/// so this migration runs even for installs that already completed v1.
|
||||
#[cfg(debug_assertions)]
|
||||
const DEV_SECRETS_MIGRATION_MARKER: &str = "_dev_secrets_migration_v2";
|
||||
|
||||
/// One-time migration of secret-projection keys (env vars, auth tags, provider
|
||||
/// configs, definition env vars) from the source keyring service to the
|
||||
/// destination dev service.
|
||||
///
|
||||
/// Called at dev-build boot, AFTER `migrate_agent_keys_to_dev_service` and
|
||||
/// AFTER `migrate_inline_secrets_to_keyring` (so projection keys are present
|
||||
/// in the source before we copy them).
|
||||
///
|
||||
/// Source determination:
|
||||
/// - `buzz-desktop-dev` → source is `buzz-desktop` (production).
|
||||
/// - `buzz-desktop-dev.<slug>` (scoped) → source is `buzz-desktop-dev`
|
||||
/// (canonical dev), NOT production.
|
||||
///
|
||||
/// `global:env` is only copied when the destination's JSON reference requires
|
||||
/// it (`global-agent-config.json` is not in `SHARED_AGENT_FILES`).
|
||||
///
|
||||
/// A coordinate present in both source and destination with different values
|
||||
/// is a conflict; the migration fails closed for that coordinate and logs an
|
||||
/// error rather than silently overwriting.
|
||||
///
|
||||
/// Idempotent: guarded by `DEV_SECRETS_MIGRATION_MARKER`; skips any key
|
||||
/// already present in the destination.
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn migrate_agent_secrets_to_dev_service(app: &tauri::AppHandle) {
|
||||
use crate::managed_agents::secret_projection::is_projection_key;
|
||||
|
||||
if !cfg!(feature = "system-keyring") {
|
||||
return;
|
||||
}
|
||||
let dest_service = keyring_service();
|
||||
if dest_service == "buzz-desktop" {
|
||||
return; // never run in a release build via this path
|
||||
}
|
||||
// Determine source: scoped dev → canonical dev; canonical dev → prod.
|
||||
let is_scoped = dest_service != "buzz-desktop-dev";
|
||||
let src_service = if is_scoped {
|
||||
"buzz-desktop-dev"
|
||||
} else {
|
||||
"buzz-desktop"
|
||||
};
|
||||
|
||||
let dest_store = crate::secret_store::SecretStore::shared(dest_service);
|
||||
|
||||
// Read destination blob. If the v2 migration marker is present, we ran
|
||||
// this migration already — skip entirely.
|
||||
let dest_map: std::collections::HashMap<String, String> = match dest_store.load_all_readonly() {
|
||||
Ok(Some(map)) if map.contains_key(DEV_SECRETS_MIGRATION_MARKER) => {
|
||||
return; // already done
|
||||
}
|
||||
Ok(Some(map)) => map,
|
||||
Ok(None) => std::collections::HashMap::new(),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"buzz-desktop: keyring-dev-secrets-migration: \
|
||||
cannot read dest keyring ({dest_service}): {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let src_store = crate::secret_store::SecretStore::keyring(src_service);
|
||||
let src_map: std::collections::HashMap<String, String> = match src_store.load_all_readonly() {
|
||||
Ok(Some(map)) => map,
|
||||
Ok(None) => std::collections::HashMap::new(),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"buzz-desktop: keyring-dev-secrets-migration: \
|
||||
cannot read src keyring ({src_service}): {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Determine which projection keys to copy: those present in src, absent
|
||||
// from dest, OR where src and dest agree (idempotent). Keys present in
|
||||
// both with DIFFERENT values are conflicts — fail closed for that key.
|
||||
//
|
||||
// Exclude `global:env:*` from the copy unless the destination JSON
|
||||
// references it (global-agent-config.json is not a shared file).
|
||||
let global_refs = collect_global_env_refs(app);
|
||||
|
||||
let mut to_write: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||
let mut conflict_count = 0usize;
|
||||
for (key, src_val) in &src_map {
|
||||
if !is_projection_key(key) {
|
||||
continue; // skip non-projection keys (nsec, identity markers, etc.)
|
||||
}
|
||||
// Skip global:env:* unless the destination JSON references this gen.
|
||||
if key.starts_with("global:env:") {
|
||||
let gen = key.trim_start_matches("global:env:");
|
||||
if !global_refs.contains(gen) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match dest_map.get(key) {
|
||||
None => {
|
||||
to_write.insert(key.clone(), src_val.clone());
|
||||
}
|
||||
Some(dest_val) if dest_val == src_val => {
|
||||
// Already identical — idempotent, no action needed.
|
||||
}
|
||||
Some(_dest_val) => {
|
||||
// Conflict: src and dest have different values. Fail closed.
|
||||
eprintln!(
|
||||
"buzz-desktop: keyring-dev-secrets-migration: \
|
||||
conflict on key {key} between {src_service} and {dest_service}; \
|
||||
refusing to overwrite (manual resolution required)"
|
||||
);
|
||||
conflict_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always write the marker (even if there was nothing to copy) so future
|
||||
// boots skip the source-keyring read entirely.
|
||||
to_write.insert(DEV_SECRETS_MIGRATION_MARKER.to_string(), "done".to_string());
|
||||
|
||||
if let Err(e) = dest_store.store_all(&to_write) {
|
||||
eprintln!(
|
||||
"buzz-desktop: keyring-dev-secrets-migration: \
|
||||
cannot write to dest keyring ({dest_service}): {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let copied = to_write.len().saturating_sub(1); // exclude the marker itself
|
||||
if copied > 0 {
|
||||
eprintln!(
|
||||
"buzz-desktop: keyring-dev-secrets-migration: \
|
||||
copied {copied} projection key(s) from {src_service} → {dest_service}"
|
||||
);
|
||||
}
|
||||
if conflict_count > 0 {
|
||||
eprintln!(
|
||||
"buzz-desktop: keyring-dev-secrets-migration: \
|
||||
{conflict_count} key(s) had conflicts and were NOT copied"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect the set of generation IDs referenced by `global-agent-config.json`
|
||||
/// for the purposes of the dev secrets migration (to decide whether to copy
|
||||
/// `global:env:*` keys). Returns an empty set when the file is absent or
|
||||
/// unparseable — in that case no global env refs exist in the destination.
|
||||
#[cfg(debug_assertions)]
|
||||
fn collect_global_env_refs(app: &tauri::AppHandle) -> std::collections::HashSet<String> {
|
||||
let path = match app.path().app_data_dir() {
|
||||
Ok(d) => d.join("agents/global-agent-config.json"),
|
||||
Err(_) => return std::collections::HashSet::new(),
|
||||
};
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return std::collections::HashSet::new(),
|
||||
};
|
||||
let v: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return std::collections::HashSet::new(),
|
||||
};
|
||||
let mut refs = std::collections::HashSet::new();
|
||||
if let Some(r) = v.get("env_vars_ref").and_then(|r| r.as_str()) {
|
||||
refs.insert(r.to_string());
|
||||
}
|
||||
refs
|
||||
}
|
||||
@@ -286,7 +286,6 @@ fn record_with(
|
||||
secrets_unavailable: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_agent_command_own_runtime_wins_over_persona() {
|
||||
// A record with its own materialized runtime never consults the
|
||||
|
||||
@@ -11,6 +11,10 @@ pub(crate) use agent_env::{
|
||||
mod backend;
|
||||
pub(crate) mod config_bridge;
|
||||
pub(crate) mod custom_harnesses;
|
||||
#[cfg(debug_assertions)]
|
||||
mod dev_service_migration;
|
||||
#[cfg(debug_assertions)]
|
||||
pub(crate) use dev_service_migration::migrate_agent_secrets_to_dev_service;
|
||||
mod discovery;
|
||||
pub(crate) mod effective_config;
|
||||
mod env_vars;
|
||||
|
||||
@@ -1532,7 +1532,6 @@ mod tests {
|
||||
provider_config_ref: None,
|
||||
secrets_unavailable: false,
|
||||
};
|
||||
|
||||
let runtime = known_acp_runtime_exact("buzz-agent");
|
||||
let effective = resolve_effective_agent_env(&record, &[], runtime, &Default::default());
|
||||
|
||||
|
||||
@@ -551,489 +551,6 @@ pub fn deserialize_provider_config(s: &str) -> Result<serde_json::Value, String>
|
||||
serde_json::from_str(s).map_err(|e| format!("provider_config deserialize: {e}"))
|
||||
}
|
||||
|
||||
// ── Unit tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
|
||||
// ── FakeProjectionStore ──────────────────────────────────────────────
|
||||
|
||||
struct FakeProjectionStore {
|
||||
reachable: bool,
|
||||
fail_verify: bool,
|
||||
data: RefCell<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl FakeProjectionStore {
|
||||
fn reachable() -> Self {
|
||||
Self {
|
||||
reachable: true,
|
||||
fail_verify: false,
|
||||
data: RefCell::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
fn unreachable() -> Self {
|
||||
Self {
|
||||
reachable: false,
|
||||
fail_verify: false,
|
||||
data: RefCell::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
fn verify_fails() -> Self {
|
||||
Self {
|
||||
reachable: true,
|
||||
fail_verify: true,
|
||||
data: RefCell::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
fn with_entry(self, key: &str, value: &str) -> Self {
|
||||
self.data
|
||||
.borrow_mut()
|
||||
.insert(key.to_string(), value.to_string());
|
||||
self
|
||||
}
|
||||
fn get(&self, key: &str) -> Option<String> {
|
||||
self.data.borrow().get(key).cloned()
|
||||
}
|
||||
fn keys(&self) -> Vec<String> {
|
||||
self.data.borrow().keys().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectionStore for FakeProjectionStore {
|
||||
fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String> {
|
||||
if !self.reachable {
|
||||
return Err("unreachable".to_string());
|
||||
}
|
||||
if self.fail_verify {
|
||||
return Err("verify failed".to_string());
|
||||
}
|
||||
self.data
|
||||
.borrow_mut()
|
||||
.insert(key.to_string(), value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_key(&self, key: &str) -> Result<Option<String>, String> {
|
||||
if !self.reachable {
|
||||
return Err("unreachable".to_string());
|
||||
}
|
||||
Ok(self.data.borrow().get(key).cloned())
|
||||
}
|
||||
|
||||
fn load_all(&self) -> Result<Option<HashMap<String, String>>, String> {
|
||||
if !self.reachable {
|
||||
return Err("unreachable".to_string());
|
||||
}
|
||||
if self.data.borrow().is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(self.data.borrow().clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn store_batch(&self, entries: &HashMap<String, String>) -> Result<(), String> {
|
||||
if !self.reachable {
|
||||
return Err("unreachable".to_string());
|
||||
}
|
||||
for (k, v) in entries {
|
||||
self.data.borrow_mut().insert(k.clone(), v.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_batch(&self, keys: &[&str]) -> Result<(), String> {
|
||||
if !self.reachable {
|
||||
return Err("unreachable".to_string());
|
||||
}
|
||||
let mut data = self.data.borrow_mut();
|
||||
for k in keys {
|
||||
data.remove(*k);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── is_projection_key ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_is_projection_key_global_env() {
|
||||
assert!(is_projection_key("global:env:abc123"));
|
||||
assert!(!is_projection_key("global:model"));
|
||||
assert!(!is_projection_key("identity"));
|
||||
assert!(!is_projection_key("agent:abc123")); // no sub-part
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_projection_key_agent_namespaces() {
|
||||
assert!(is_projection_key("agent:abc123:env:gen1"));
|
||||
assert!(is_projection_key("agent:abc123:auth_tag:gen1"));
|
||||
assert!(is_projection_key("agent:abc123:provider_config:gen1"));
|
||||
assert!(is_projection_key("agent:abc123:env:gen1_candidate"));
|
||||
assert!(!is_projection_key("agent:abc123")); // nsec key — not ours
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_projection_key_definition() {
|
||||
assert!(is_projection_key("definition:my-slug:env:gen1"));
|
||||
assert!(!is_projection_key("definition:my-slug")); // no part
|
||||
}
|
||||
|
||||
// ── write_secret ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_write_secret_nothing_on_empty_value() {
|
||||
let store = FakeProjectionStore::reachable();
|
||||
let outcome = write_secret(&store, global_env_key, None, "test");
|
||||
assert_eq!(outcome, WriteOutcome::Nothing);
|
||||
assert!(store.keys().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_secret_persisted_on_success() {
|
||||
let store = FakeProjectionStore::reachable();
|
||||
let outcome = write_secret(
|
||||
&store,
|
||||
global_env_key,
|
||||
Some("sk-ant-api03-secret"),
|
||||
"global:env",
|
||||
);
|
||||
match &outcome {
|
||||
WriteOutcome::Persisted { gen } => {
|
||||
let key = global_env_key(gen);
|
||||
assert_eq!(store.get(&key), Some("sk-ant-api03-secret".to_string()));
|
||||
}
|
||||
other => panic!("expected Persisted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_secret_kept_inline_on_verify_failure() {
|
||||
let store = FakeProjectionStore::verify_fails();
|
||||
let outcome = write_secret(&store, global_env_key, Some("sk-ant-secret"), "global:env");
|
||||
assert!(matches!(outcome, WriteOutcome::KeptInline { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_secret_kept_inline_on_unreachable() {
|
||||
let store = FakeProjectionStore::unreachable();
|
||||
let outcome = write_secret(&store, global_env_key, Some("value"), "test");
|
||||
assert!(matches!(outcome, WriteOutcome::KeptInline { .. }));
|
||||
}
|
||||
|
||||
// ── load_secret ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_load_secret_none_when_no_ref() {
|
||||
let store = FakeProjectionStore::reachable();
|
||||
let result = load_secret(&store, None, global_env_key, "test");
|
||||
assert_eq!(result, Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_secret_ok_when_entry_present() {
|
||||
let store = FakeProjectionStore::reachable().with_entry("global:env:gen1", "sk-secret");
|
||||
let result = load_secret(&store, Some("gen1"), global_env_key, "global:env");
|
||||
assert_eq!(result, Ok(Some("sk-secret".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_secret_err_when_ref_present_but_missing() {
|
||||
let store = FakeProjectionStore::reachable();
|
||||
let result = load_secret(&store, Some("gen1"), global_env_key, "global:env");
|
||||
assert!(result.is_err(), "expected unavailable error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_secret_err_when_keyring_unreachable() {
|
||||
let store = FakeProjectionStore::unreachable();
|
||||
let result = load_secret(&store, Some("gen1"), global_env_key, "global:env");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── Two-cycle GC tests ────────────────────────────────────────────────
|
||||
|
||||
fn make_agents_json(env_ref: Option<&str>) -> String {
|
||||
if let Some(r) = env_ref {
|
||||
format!(
|
||||
r#"[{{"pubkey":"abc","name":"test","env_vars_ref":"{r}","created_at":"2026","updated_at":"2026"}}]"#
|
||||
)
|
||||
} else {
|
||||
r#"[{"pubkey":"abc","name":"test","created_at":"2026","updated_at":"2026"}]"#
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_global_json(env_ref: Option<&str>) -> String {
|
||||
if let Some(r) = env_ref {
|
||||
format!(r#"{{"env_vars_ref":"{r}"}}"#)
|
||||
} else {
|
||||
"{}".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_live_refs_extracts_refs() {
|
||||
let agents = make_agents_json(Some("gen1"));
|
||||
let global = make_global_json(Some("gen2"));
|
||||
let refs = collect_live_refs(&agents, &global).unwrap();
|
||||
assert!(refs.contains("gen1"));
|
||||
assert!(refs.contains("gen2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_live_refs_empty_when_no_refs() {
|
||||
let agents = make_agents_json(None);
|
||||
let global = make_global_json(None);
|
||||
let refs = collect_live_refs(&agents, &global).unwrap();
|
||||
assert!(refs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_live_refs_none_on_malformed_json() {
|
||||
let result = collect_live_refs("not json", "{}");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gc_interleaving_save_cancels_candidacy() {
|
||||
// Simulate: GC marks gen1 as candidate, then a save confirms it into JSON.
|
||||
// GC sweep 2 should NOT delete gen1 because the ref is now live.
|
||||
let store = FakeProjectionStore::reachable()
|
||||
.with_entry("global:env:gen1", "sk-secret")
|
||||
.with_entry("global:env:gen1_candidate", "1"); // sweep 1 already ran
|
||||
|
||||
// Sweep 2 re-reads JSON — now gen1 IS referenced.
|
||||
// We simulate this by providing JSON that references gen1.
|
||||
let agents_content = make_agents_json(None);
|
||||
let global_content = make_global_json(Some("gen1"));
|
||||
|
||||
let live_refs: HashSet<String> =
|
||||
collect_live_refs(&agents_content, &global_content).unwrap();
|
||||
assert!(live_refs.contains("gen1"), "gen1 must be live");
|
||||
|
||||
// Verify that delete_gc_candidates would skip gen1 because it's live.
|
||||
// Since we can't call delete_gc_candidates directly (it reads files),
|
||||
// we verify the logic: a live ref prevents deletion.
|
||||
let blob = store.load_all().unwrap().unwrap();
|
||||
let candidate_key = "global:env:gen1_candidate";
|
||||
assert!(
|
||||
blob.contains_key(candidate_key),
|
||||
"candidate should be present"
|
||||
);
|
||||
|
||||
// Simulate what delete_gc_candidates does: skip live refs.
|
||||
let gen = "gen1";
|
||||
let would_delete = !live_refs.contains(gen);
|
||||
assert!(
|
||||
!would_delete,
|
||||
"gen1 must NOT be deleted — it's now referenced"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gc_no_op_on_unreachable_keyring() {
|
||||
// GC mark/delete must be skipped when the keyring is unavailable.
|
||||
let store = FakeProjectionStore::unreachable().with_entry("global:env:gen1", "value");
|
||||
// load_all returns Err — mark_gc_candidates aborts early.
|
||||
let result = store.load_all();
|
||||
assert!(result.is_err());
|
||||
// Confirms GC would abort before any writes.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cancel_gc_candidacy_removes_marker() {
|
||||
let store = FakeProjectionStore::reachable()
|
||||
.with_entry("global:env:gen1", "secret")
|
||||
.with_entry("global:env:gen1_candidate", "1");
|
||||
|
||||
cancel_gc_candidacy(&store, "global:env:gen1");
|
||||
assert!(
|
||||
store.get("global:env:gen1_candidate").is_none(),
|
||||
"candidate marker must be removed"
|
||||
);
|
||||
assert_eq!(
|
||||
store.get("global:env:gen1"),
|
||||
Some("secret".to_string()),
|
||||
"generation must NOT be deleted by cancel"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Key constructors ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_key_constructors_round_trip() {
|
||||
assert_eq!(global_env_key("gen1"), "global:env:gen1");
|
||||
assert_eq!(agent_env_key("abc", "gen1"), "agent:abc:env:gen1");
|
||||
assert_eq!(agent_auth_tag_key("abc", "gen1"), "agent:abc:auth_tag:gen1");
|
||||
assert_eq!(
|
||||
agent_provider_config_key("abc", "gen1"),
|
||||
"agent:abc:provider_config:gen1"
|
||||
);
|
||||
assert_eq!(
|
||||
definition_env_key("my-slug", "gen1"),
|
||||
"definition:my-slug:env:gen1"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Serialization ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_serialize_deserialize_env_map() {
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("ANTHROPIC_API_KEY".to_string(), "sk-ant-secret".to_string());
|
||||
env.insert("BUZZ_THINKING".to_string(), "high".to_string());
|
||||
let s = serialize_env_map(&env).unwrap();
|
||||
let back = deserialize_env_map(&s).unwrap();
|
||||
assert_eq!(env, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_deserialize_provider_config() {
|
||||
let config = serde_json::json!({"host": "example.com", "port": 443});
|
||||
let s = serialize_provider_config(&config).unwrap();
|
||||
let back = deserialize_provider_config(&s).unwrap();
|
||||
assert_eq!(config, back);
|
||||
}
|
||||
|
||||
// ── Inline-over-ref precedence (spec §1, inline-precedence pin) ───────
|
||||
|
||||
#[test]
|
||||
fn test_inline_wins_over_ref_when_both_present() {
|
||||
// Hydration must prefer inline over any keyring ref — the inline is
|
||||
// the authoritative value when the keyring write failed.
|
||||
// This test verifies the CONTRACT expected by the hydration code:
|
||||
// when env_vars is non-empty (inline) and env_vars_ref is also set,
|
||||
// the hydration layer must NOT overwrite the inline value with the
|
||||
// keyring value.
|
||||
//
|
||||
// The actual enforcement happens in hydrate_global_secrets and
|
||||
// hydrate_agent_secrets (in storage.rs). This test verifies the
|
||||
// fundamental assumption: if env_vars is non-empty, it should be
|
||||
// treated as the authoritative value.
|
||||
let inline_env = {
|
||||
let mut m = BTreeMap::new();
|
||||
m.insert("KEY".to_string(), "inline-value".to_string());
|
||||
m
|
||||
};
|
||||
// Simulate: inline value is present (keyring write failed last boot).
|
||||
// The hydration code should keep `inline_env` and not call load_secret.
|
||||
let inline_is_present = !inline_env.is_empty();
|
||||
assert!(
|
||||
inline_is_present,
|
||||
"inline must take precedence when present"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Two-cycle GC ordering and cancel-before-mark tests ───────────────
|
||||
|
||||
#[test]
|
||||
fn test_gc_delete_first_order_preserves_in_flight_gen() {
|
||||
// Spec §6: delete candidates from the PREVIOUS boot first, then mark
|
||||
// new ones for THIS boot. A generation verified this boot but not yet
|
||||
// committed to JSON must NOT be deleted this boot.
|
||||
//
|
||||
// Setup: gen1 is in-flight (written + verified, JSON commit pending).
|
||||
// boot N-1 left a candidate marker for an old gen0 that is now gone.
|
||||
// GC delete phase runs: gen0 (with a candidate marker) is still
|
||||
// unreferenced — it should be deleted. gen1 has NO candidate marker
|
||||
// yet — it must NOT be deleted.
|
||||
//
|
||||
// This tests the FakeProjectionStore's delete_gc_candidates logic
|
||||
// directly via collect_live_refs + manual blob inspection.
|
||||
|
||||
// Blob state at start of boot N's GC:
|
||||
// gen0 was orphaned last boot and marked as candidate
|
||||
// gen1 is the new in-flight generation (no marker yet)
|
||||
let store = FakeProjectionStore::reachable()
|
||||
.with_entry("global:env:gen0", "old-secret") // old generation
|
||||
.with_entry("global:env:gen0_candidate", "1") // marked last boot
|
||||
.with_entry("global:env:gen1", "new-secret"); // in-flight
|
||||
|
||||
// JSON currently still references gen0 (JSON commit hasn't happened).
|
||||
// GC reads the live refs from JSON.
|
||||
let agents = make_agents_json(None);
|
||||
let global = make_global_json(Some("gen0")); // JSON still has old ref
|
||||
|
||||
let live_refs = collect_live_refs(&agents, &global).unwrap();
|
||||
assert!(live_refs.contains("gen0"), "gen0 is still in JSON");
|
||||
assert!(
|
||||
!live_refs.contains("gen1"),
|
||||
"gen1 not yet committed to JSON"
|
||||
);
|
||||
|
||||
// delete_gc_candidates: gen0 is a candidate but IS referenced → skip.
|
||||
// gen1 has NO candidate marker → skip.
|
||||
// Nothing should be deleted this cycle.
|
||||
let blob = store.load_all().unwrap().unwrap();
|
||||
let candidate_for_gen0 = blob.get("global:env:gen0_candidate");
|
||||
let candidate_for_gen1 = blob.get("global:env:gen1_candidate");
|
||||
assert!(
|
||||
candidate_for_gen0.is_some(),
|
||||
"gen0 candidate marker must still be present"
|
||||
);
|
||||
assert!(
|
||||
candidate_for_gen1.is_none(),
|
||||
"gen1 must have no candidate marker"
|
||||
);
|
||||
|
||||
// Verify delete_gc_candidates would skip gen0 because it IS referenced.
|
||||
let gen = "gen0";
|
||||
let would_delete = !live_refs.contains(gen);
|
||||
assert!(
|
||||
!would_delete,
|
||||
"gen0 must NOT be deleted — it's still referenced in JSON"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cancel_before_mark_ordering_protects_in_flight_gen() {
|
||||
// Spec §6: cancel happens before the JSON commit. The GC mark phase
|
||||
// that runs AFTER the cancel must not re-mark the in-flight generation.
|
||||
//
|
||||
// Scenario (cancel-happens-before-mark, the case Paul flagged):
|
||||
// 1. Save writes gen2 and verifies it.
|
||||
// 2. Save calls cancel_gc_candidacy("gen2") — a no-op since gen2 wasn't
|
||||
// marked, but it guarantees the marker is absent.
|
||||
// 3. GC mark phase runs (shouldn't happen in the same call, but safe).
|
||||
// 4. GC sees gen2 as unreferenced (JSON still has gen1 ref) and marks it.
|
||||
// 5. Save commits JSON with gen2 ref.
|
||||
// 6. GC delete phase (next boot) sees gen2 is now referenced → skips.
|
||||
//
|
||||
// The key correctness property: between steps 2 and 5, even if GC
|
||||
// marks gen2, the NEXT boot's delete phase sees gen2 as referenced and
|
||||
// will not delete it. This test verifies step 4 is safe: a marked-
|
||||
// then-referenced gen survives.
|
||||
|
||||
let _store = FakeProjectionStore::reachable()
|
||||
.with_entry("global:env:gen1", "old-secret")
|
||||
.with_entry("global:env:gen2", "new-secret")
|
||||
// GC marked gen2 as a candidate (step 4 above).
|
||||
.with_entry("global:env:gen2_candidate", "1");
|
||||
|
||||
// After JSON commit (step 5), gen2 is referenced.
|
||||
let agents = make_agents_json(None);
|
||||
let global = make_global_json(Some("gen2")); // JSON now has gen2 ref
|
||||
|
||||
let live_refs = collect_live_refs(&agents, &global).unwrap();
|
||||
assert!(live_refs.contains("gen2"), "gen2 is now referenced in JSON");
|
||||
|
||||
// delete_gc_candidates (next boot): gen2 is a candidate BUT is now
|
||||
// referenced → skip. gen2 must NOT be deleted.
|
||||
let gen = "gen2";
|
||||
let would_delete = !live_refs.contains(gen);
|
||||
assert!(
|
||||
!would_delete,
|
||||
"gen2 must NOT be deleted — it's now referenced in JSON"
|
||||
);
|
||||
|
||||
// gen1 is now unreferenced → it should become a candidate on next mark.
|
||||
assert!(
|
||||
!live_refs.contains("gen1"),
|
||||
"gen1 is no longer referenced (gen2 replaced it)"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "secret_projection_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
479
desktop/src-tauri/src/managed_agents/secret_projection_tests.rs
Normal file
479
desktop/src-tauri/src/managed_agents/secret_projection_tests.rs
Normal file
@@ -0,0 +1,479 @@
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
|
||||
// ── FakeProjectionStore ──────────────────────────────────────────────
|
||||
|
||||
struct FakeProjectionStore {
|
||||
reachable: bool,
|
||||
fail_verify: bool,
|
||||
data: RefCell<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl FakeProjectionStore {
|
||||
fn reachable() -> Self {
|
||||
Self {
|
||||
reachable: true,
|
||||
fail_verify: false,
|
||||
data: RefCell::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
fn unreachable() -> Self {
|
||||
Self {
|
||||
reachable: false,
|
||||
fail_verify: false,
|
||||
data: RefCell::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
fn verify_fails() -> Self {
|
||||
Self {
|
||||
reachable: true,
|
||||
fail_verify: true,
|
||||
data: RefCell::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
fn with_entry(self, key: &str, value: &str) -> Self {
|
||||
self.data
|
||||
.borrow_mut()
|
||||
.insert(key.to_string(), value.to_string());
|
||||
self
|
||||
}
|
||||
fn get(&self, key: &str) -> Option<String> {
|
||||
self.data.borrow().get(key).cloned()
|
||||
}
|
||||
fn keys(&self) -> Vec<String> {
|
||||
self.data.borrow().keys().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProjectionStore for FakeProjectionStore {
|
||||
fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String> {
|
||||
if !self.reachable {
|
||||
return Err("unreachable".to_string());
|
||||
}
|
||||
if self.fail_verify {
|
||||
return Err("verify failed".to_string());
|
||||
}
|
||||
self.data
|
||||
.borrow_mut()
|
||||
.insert(key.to_string(), value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_key(&self, key: &str) -> Result<Option<String>, String> {
|
||||
if !self.reachable {
|
||||
return Err("unreachable".to_string());
|
||||
}
|
||||
Ok(self.data.borrow().get(key).cloned())
|
||||
}
|
||||
|
||||
fn load_all(&self) -> Result<Option<HashMap<String, String>>, String> {
|
||||
if !self.reachable {
|
||||
return Err("unreachable".to_string());
|
||||
}
|
||||
if self.data.borrow().is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(self.data.borrow().clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn store_batch(&self, entries: &HashMap<String, String>) -> Result<(), String> {
|
||||
if !self.reachable {
|
||||
return Err("unreachable".to_string());
|
||||
}
|
||||
for (k, v) in entries {
|
||||
self.data.borrow_mut().insert(k.clone(), v.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_batch(&self, keys: &[&str]) -> Result<(), String> {
|
||||
if !self.reachable {
|
||||
return Err("unreachable".to_string());
|
||||
}
|
||||
let mut data = self.data.borrow_mut();
|
||||
for k in keys {
|
||||
data.remove(*k);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── is_projection_key ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_is_projection_key_global_env() {
|
||||
assert!(is_projection_key("global:env:abc123"));
|
||||
assert!(!is_projection_key("global:model"));
|
||||
assert!(!is_projection_key("identity"));
|
||||
assert!(!is_projection_key("agent:abc123")); // no sub-part
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_projection_key_agent_namespaces() {
|
||||
assert!(is_projection_key("agent:abc123:env:gen1"));
|
||||
assert!(is_projection_key("agent:abc123:auth_tag:gen1"));
|
||||
assert!(is_projection_key("agent:abc123:provider_config:gen1"));
|
||||
assert!(is_projection_key("agent:abc123:env:gen1_candidate"));
|
||||
assert!(!is_projection_key("agent:abc123")); // nsec key — not ours
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_projection_key_definition() {
|
||||
assert!(is_projection_key("definition:my-slug:env:gen1"));
|
||||
assert!(!is_projection_key("definition:my-slug")); // no part
|
||||
}
|
||||
|
||||
// ── write_secret ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_write_secret_nothing_on_empty_value() {
|
||||
let store = FakeProjectionStore::reachable();
|
||||
let outcome = write_secret(&store, global_env_key, None, "test");
|
||||
assert_eq!(outcome, WriteOutcome::Nothing);
|
||||
assert!(store.keys().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_secret_persisted_on_success() {
|
||||
let store = FakeProjectionStore::reachable();
|
||||
let outcome = write_secret(
|
||||
&store,
|
||||
global_env_key,
|
||||
Some("sk-ant-api03-secret"),
|
||||
"global:env",
|
||||
);
|
||||
match &outcome {
|
||||
WriteOutcome::Persisted { gen } => {
|
||||
let key = global_env_key(gen);
|
||||
assert_eq!(store.get(&key), Some("sk-ant-api03-secret".to_string()));
|
||||
}
|
||||
other => panic!("expected Persisted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_secret_kept_inline_on_verify_failure() {
|
||||
let store = FakeProjectionStore::verify_fails();
|
||||
let outcome = write_secret(&store, global_env_key, Some("sk-ant-secret"), "global:env");
|
||||
assert!(matches!(outcome, WriteOutcome::KeptInline { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_secret_kept_inline_on_unreachable() {
|
||||
let store = FakeProjectionStore::unreachable();
|
||||
let outcome = write_secret(&store, global_env_key, Some("value"), "test");
|
||||
assert!(matches!(outcome, WriteOutcome::KeptInline { .. }));
|
||||
}
|
||||
|
||||
// ── load_secret ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_load_secret_none_when_no_ref() {
|
||||
let store = FakeProjectionStore::reachable();
|
||||
let result = load_secret(&store, None, global_env_key, "test");
|
||||
assert_eq!(result, Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_secret_ok_when_entry_present() {
|
||||
let store = FakeProjectionStore::reachable().with_entry("global:env:gen1", "sk-secret");
|
||||
let result = load_secret(&store, Some("gen1"), global_env_key, "global:env");
|
||||
assert_eq!(result, Ok(Some("sk-secret".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_secret_err_when_ref_present_but_missing() {
|
||||
let store = FakeProjectionStore::reachable();
|
||||
let result = load_secret(&store, Some("gen1"), global_env_key, "global:env");
|
||||
assert!(result.is_err(), "expected unavailable error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_secret_err_when_keyring_unreachable() {
|
||||
let store = FakeProjectionStore::unreachable();
|
||||
let result = load_secret(&store, Some("gen1"), global_env_key, "global:env");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── Two-cycle GC tests ────────────────────────────────────────────────
|
||||
|
||||
fn make_agents_json(env_ref: Option<&str>) -> String {
|
||||
if let Some(r) = env_ref {
|
||||
format!(
|
||||
r#"[{{"pubkey":"abc","name":"test","env_vars_ref":"{r}","created_at":"2026","updated_at":"2026"}}]"#
|
||||
)
|
||||
} else {
|
||||
r#"[{"pubkey":"abc","name":"test","created_at":"2026","updated_at":"2026"}]"#.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_global_json(env_ref: Option<&str>) -> String {
|
||||
if let Some(r) = env_ref {
|
||||
format!(r#"{{"env_vars_ref":"{r}"}}"#)
|
||||
} else {
|
||||
"{}".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_live_refs_extracts_refs() {
|
||||
let agents = make_agents_json(Some("gen1"));
|
||||
let global = make_global_json(Some("gen2"));
|
||||
let refs = collect_live_refs(&agents, &global).unwrap();
|
||||
assert!(refs.contains("gen1"));
|
||||
assert!(refs.contains("gen2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_live_refs_empty_when_no_refs() {
|
||||
let agents = make_agents_json(None);
|
||||
let global = make_global_json(None);
|
||||
let refs = collect_live_refs(&agents, &global).unwrap();
|
||||
assert!(refs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_live_refs_none_on_malformed_json() {
|
||||
let result = collect_live_refs("not json", "{}");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gc_interleaving_save_cancels_candidacy() {
|
||||
// Simulate: GC marks gen1 as candidate, then a save confirms it into JSON.
|
||||
// GC sweep 2 should NOT delete gen1 because the ref is now live.
|
||||
let store = FakeProjectionStore::reachable()
|
||||
.with_entry("global:env:gen1", "sk-secret")
|
||||
.with_entry("global:env:gen1_candidate", "1"); // sweep 1 already ran
|
||||
|
||||
// Sweep 2 re-reads JSON — now gen1 IS referenced.
|
||||
// We simulate this by providing JSON that references gen1.
|
||||
let agents_content = make_agents_json(None);
|
||||
let global_content = make_global_json(Some("gen1"));
|
||||
|
||||
let live_refs: HashSet<String> = collect_live_refs(&agents_content, &global_content).unwrap();
|
||||
assert!(live_refs.contains("gen1"), "gen1 must be live");
|
||||
|
||||
// Verify that delete_gc_candidates would skip gen1 because it's live.
|
||||
// Since we can't call delete_gc_candidates directly (it reads files),
|
||||
// we verify the logic: a live ref prevents deletion.
|
||||
let blob = store.load_all().unwrap().unwrap();
|
||||
let candidate_key = "global:env:gen1_candidate";
|
||||
assert!(
|
||||
blob.contains_key(candidate_key),
|
||||
"candidate should be present"
|
||||
);
|
||||
|
||||
// Simulate what delete_gc_candidates does: skip live refs.
|
||||
let gen = "gen1";
|
||||
let would_delete = !live_refs.contains(gen);
|
||||
assert!(
|
||||
!would_delete,
|
||||
"gen1 must NOT be deleted — it's now referenced"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gc_no_op_on_unreachable_keyring() {
|
||||
// GC mark/delete must be skipped when the keyring is unavailable.
|
||||
let store = FakeProjectionStore::unreachable().with_entry("global:env:gen1", "value");
|
||||
// load_all returns Err — mark_gc_candidates aborts early.
|
||||
let result = store.load_all();
|
||||
assert!(result.is_err());
|
||||
// Confirms GC would abort before any writes.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cancel_gc_candidacy_removes_marker() {
|
||||
let store = FakeProjectionStore::reachable()
|
||||
.with_entry("global:env:gen1", "secret")
|
||||
.with_entry("global:env:gen1_candidate", "1");
|
||||
|
||||
cancel_gc_candidacy(&store, "global:env:gen1");
|
||||
assert!(
|
||||
store.get("global:env:gen1_candidate").is_none(),
|
||||
"candidate marker must be removed"
|
||||
);
|
||||
assert_eq!(
|
||||
store.get("global:env:gen1"),
|
||||
Some("secret".to_string()),
|
||||
"generation must NOT be deleted by cancel"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Key constructors ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_key_constructors_round_trip() {
|
||||
assert_eq!(global_env_key("gen1"), "global:env:gen1");
|
||||
assert_eq!(agent_env_key("abc", "gen1"), "agent:abc:env:gen1");
|
||||
assert_eq!(agent_auth_tag_key("abc", "gen1"), "agent:abc:auth_tag:gen1");
|
||||
assert_eq!(
|
||||
agent_provider_config_key("abc", "gen1"),
|
||||
"agent:abc:provider_config:gen1"
|
||||
);
|
||||
assert_eq!(
|
||||
definition_env_key("my-slug", "gen1"),
|
||||
"definition:my-slug:env:gen1"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Serialization ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_serialize_deserialize_env_map() {
|
||||
let mut env = BTreeMap::new();
|
||||
env.insert("ANTHROPIC_API_KEY".to_string(), "sk-ant-secret".to_string());
|
||||
env.insert("BUZZ_THINKING".to_string(), "high".to_string());
|
||||
let s = serialize_env_map(&env).unwrap();
|
||||
let back = deserialize_env_map(&s).unwrap();
|
||||
assert_eq!(env, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_deserialize_provider_config() {
|
||||
let config = serde_json::json!({"host": "example.com", "port": 443});
|
||||
let s = serialize_provider_config(&config).unwrap();
|
||||
let back = deserialize_provider_config(&s).unwrap();
|
||||
assert_eq!(config, back);
|
||||
}
|
||||
|
||||
// ── Inline-over-ref precedence (spec §1, inline-precedence pin) ───────
|
||||
|
||||
#[test]
|
||||
fn test_inline_wins_over_ref_when_both_present() {
|
||||
// Hydration must prefer inline over any keyring ref — the inline is
|
||||
// the authoritative value when the keyring write failed.
|
||||
// This test verifies the CONTRACT expected by the hydration code:
|
||||
// when env_vars is non-empty (inline) and env_vars_ref is also set,
|
||||
// the hydration layer must NOT overwrite the inline value with the
|
||||
// keyring value.
|
||||
//
|
||||
// The actual enforcement happens in hydrate_global_secrets and
|
||||
// hydrate_agent_secrets (in storage.rs). This test verifies the
|
||||
// fundamental assumption: if env_vars is non-empty, it should be
|
||||
// treated as the authoritative value.
|
||||
let inline_env = {
|
||||
let mut m = BTreeMap::new();
|
||||
m.insert("KEY".to_string(), "inline-value".to_string());
|
||||
m
|
||||
};
|
||||
// Simulate: inline value is present (keyring write failed last boot).
|
||||
// The hydration code should keep `inline_env` and not call load_secret.
|
||||
let inline_is_present = !inline_env.is_empty();
|
||||
assert!(
|
||||
inline_is_present,
|
||||
"inline must take precedence when present"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Two-cycle GC ordering and cancel-before-mark tests ───────────────
|
||||
|
||||
#[test]
|
||||
fn test_gc_delete_first_order_preserves_in_flight_gen() {
|
||||
// Spec §6: delete candidates from the PREVIOUS boot first, then mark
|
||||
// new ones for THIS boot. A generation verified this boot but not yet
|
||||
// committed to JSON must NOT be deleted this boot.
|
||||
//
|
||||
// Setup: gen1 is in-flight (written + verified, JSON commit pending).
|
||||
// boot N-1 left a candidate marker for an old gen0 that is now gone.
|
||||
// GC delete phase runs: gen0 (with a candidate marker) is still
|
||||
// unreferenced — it should be deleted. gen1 has NO candidate marker
|
||||
// yet — it must NOT be deleted.
|
||||
//
|
||||
// This tests the FakeProjectionStore's delete_gc_candidates logic
|
||||
// directly via collect_live_refs + manual blob inspection.
|
||||
|
||||
// Blob state at start of boot N's GC:
|
||||
// gen0 was orphaned last boot and marked as candidate
|
||||
// gen1 is the new in-flight generation (no marker yet)
|
||||
let store = FakeProjectionStore::reachable()
|
||||
.with_entry("global:env:gen0", "old-secret") // old generation
|
||||
.with_entry("global:env:gen0_candidate", "1") // marked last boot
|
||||
.with_entry("global:env:gen1", "new-secret"); // in-flight
|
||||
|
||||
// JSON currently still references gen0 (JSON commit hasn't happened).
|
||||
// GC reads the live refs from JSON.
|
||||
let agents = make_agents_json(None);
|
||||
let global = make_global_json(Some("gen0")); // JSON still has old ref
|
||||
|
||||
let live_refs = collect_live_refs(&agents, &global).unwrap();
|
||||
assert!(live_refs.contains("gen0"), "gen0 is still in JSON");
|
||||
assert!(
|
||||
!live_refs.contains("gen1"),
|
||||
"gen1 not yet committed to JSON"
|
||||
);
|
||||
|
||||
// delete_gc_candidates: gen0 is a candidate but IS referenced → skip.
|
||||
// gen1 has NO candidate marker → skip.
|
||||
// Nothing should be deleted this cycle.
|
||||
let blob = store.load_all().unwrap().unwrap();
|
||||
let candidate_for_gen0 = blob.get("global:env:gen0_candidate");
|
||||
let candidate_for_gen1 = blob.get("global:env:gen1_candidate");
|
||||
assert!(
|
||||
candidate_for_gen0.is_some(),
|
||||
"gen0 candidate marker must still be present"
|
||||
);
|
||||
assert!(
|
||||
candidate_for_gen1.is_none(),
|
||||
"gen1 must have no candidate marker"
|
||||
);
|
||||
|
||||
// Verify delete_gc_candidates would skip gen0 because it IS referenced.
|
||||
let gen = "gen0";
|
||||
let would_delete = !live_refs.contains(gen);
|
||||
assert!(
|
||||
!would_delete,
|
||||
"gen0 must NOT be deleted — it's still referenced in JSON"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cancel_before_mark_ordering_protects_in_flight_gen() {
|
||||
// Spec §6: cancel happens before the JSON commit. The GC mark phase
|
||||
// that runs AFTER the cancel must not re-mark the in-flight generation.
|
||||
//
|
||||
// Scenario (cancel-happens-before-mark, the case Paul flagged):
|
||||
// 1. Save writes gen2 and verifies it.
|
||||
// 2. Save calls cancel_gc_candidacy("gen2") — a no-op since gen2 wasn't
|
||||
// marked, but it guarantees the marker is absent.
|
||||
// 3. GC mark phase runs (shouldn't happen in the same call, but safe).
|
||||
// 4. GC sees gen2 as unreferenced (JSON still has gen1 ref) and marks it.
|
||||
// 5. Save commits JSON with gen2 ref.
|
||||
// 6. GC delete phase (next boot) sees gen2 is now referenced → skips.
|
||||
//
|
||||
// The key correctness property: between steps 2 and 5, even if GC
|
||||
// marks gen2, the NEXT boot's delete phase sees gen2 as referenced and
|
||||
// will not delete it. This test verifies step 4 is safe: a marked-
|
||||
// then-referenced gen survives.
|
||||
|
||||
let _store = FakeProjectionStore::reachable()
|
||||
.with_entry("global:env:gen1", "old-secret")
|
||||
.with_entry("global:env:gen2", "new-secret")
|
||||
// GC marked gen2 as a candidate (step 4 above).
|
||||
.with_entry("global:env:gen2_candidate", "1");
|
||||
|
||||
// After JSON commit (step 5), gen2 is referenced.
|
||||
let agents = make_agents_json(None);
|
||||
let global = make_global_json(Some("gen2")); // JSON now has gen2 ref
|
||||
|
||||
let live_refs = collect_live_refs(&agents, &global).unwrap();
|
||||
assert!(live_refs.contains("gen2"), "gen2 is now referenced in JSON");
|
||||
|
||||
// delete_gc_candidates (next boot): gen2 is a candidate BUT is now
|
||||
// referenced → skip. gen2 must NOT be deleted.
|
||||
let gen = "gen2";
|
||||
let would_delete = !live_refs.contains(gen);
|
||||
assert!(
|
||||
!would_delete,
|
||||
"gen2 must NOT be deleted — it's now referenced in JSON"
|
||||
);
|
||||
|
||||
// gen1 is now unreferenced → it should become a candidate on next mark.
|
||||
assert!(
|
||||
!live_refs.contains("gen1"),
|
||||
"gen1 is no longer referenced (gen2 replaced it)"
|
||||
);
|
||||
}
|
||||
@@ -612,177 +612,6 @@ fn copy_agent_keys_between_stores(pubkeys: &[String], src: &impl KeyStore, dst:
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker key for the secrets migration to the dev keyring service.
|
||||
/// Versioned separately from `_dev_migration_v1` (which covers only nsec keys)
|
||||
/// so this migration runs even for installs that already completed v1.
|
||||
#[cfg(debug_assertions)]
|
||||
const DEV_SECRETS_MIGRATION_MARKER: &str = "_dev_secrets_migration_v2";
|
||||
|
||||
/// One-time migration of secret-projection keys (env vars, auth tags, provider
|
||||
/// configs, definition env vars) from the source keyring service to the
|
||||
/// destination dev service.
|
||||
///
|
||||
/// Called at dev-build boot, AFTER `migrate_agent_keys_to_dev_service` and
|
||||
/// AFTER `migrate_inline_secrets_to_keyring` (so projection keys are present
|
||||
/// in the source before we copy them).
|
||||
///
|
||||
/// Source determination:
|
||||
/// - `buzz-desktop-dev` → source is `buzz-desktop` (production).
|
||||
/// - `buzz-desktop-dev.<slug>` (scoped) → source is `buzz-desktop-dev`
|
||||
/// (canonical dev), NOT production.
|
||||
///
|
||||
/// `global:env` is only copied when the destination's JSON reference requires
|
||||
/// it (`global-agent-config.json` is not in `SHARED_AGENT_FILES`).
|
||||
///
|
||||
/// A coordinate present in both source and destination with different values
|
||||
/// is a conflict; the migration fails closed for that coordinate and logs an
|
||||
/// error rather than silently overwriting.
|
||||
///
|
||||
/// Idempotent: guarded by `DEV_SECRETS_MIGRATION_MARKER`; skips any key
|
||||
/// already present in the destination.
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn migrate_agent_secrets_to_dev_service(app: &tauri::AppHandle) {
|
||||
use crate::managed_agents::secret_projection::is_projection_key;
|
||||
|
||||
if !cfg!(feature = "system-keyring") {
|
||||
return;
|
||||
}
|
||||
let dest_service = keyring_service();
|
||||
if dest_service == "buzz-desktop" {
|
||||
return; // never run in a release build via this path
|
||||
}
|
||||
// Determine source: scoped dev → canonical dev; canonical dev → prod.
|
||||
let is_scoped = dest_service != "buzz-desktop-dev";
|
||||
let src_service = if is_scoped {
|
||||
"buzz-desktop-dev"
|
||||
} else {
|
||||
"buzz-desktop"
|
||||
};
|
||||
|
||||
let dest_store = crate::secret_store::SecretStore::shared(dest_service);
|
||||
|
||||
// Read destination blob. If the v2 migration marker is present, we ran
|
||||
// this migration already — skip entirely.
|
||||
let dest_map: std::collections::HashMap<String, String> = match dest_store.load_all_readonly() {
|
||||
Ok(Some(map)) if map.contains_key(DEV_SECRETS_MIGRATION_MARKER) => {
|
||||
return; // already done
|
||||
}
|
||||
Ok(Some(map)) => map,
|
||||
Ok(None) => std::collections::HashMap::new(),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"buzz-desktop: keyring-dev-secrets-migration: \
|
||||
cannot read dest keyring ({dest_service}): {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let src_store = crate::secret_store::SecretStore::keyring(src_service);
|
||||
let src_map: std::collections::HashMap<String, String> = match src_store.load_all_readonly() {
|
||||
Ok(Some(map)) => map,
|
||||
Ok(None) => std::collections::HashMap::new(),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"buzz-desktop: keyring-dev-secrets-migration: \
|
||||
cannot read src keyring ({src_service}): {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Determine which projection keys to copy: those present in src, absent
|
||||
// from dest, OR where src and dest agree (idempotent). Keys present in
|
||||
// both with DIFFERENT values are conflicts — fail closed for that key.
|
||||
//
|
||||
// Exclude `global:env:*` from the copy unless the destination JSON
|
||||
// references it (global-agent-config.json is not a shared file).
|
||||
let global_refs = collect_global_env_refs(app);
|
||||
|
||||
let mut to_write: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||
let mut conflict_count = 0usize;
|
||||
for (key, src_val) in &src_map {
|
||||
if !is_projection_key(key) {
|
||||
continue; // skip non-projection keys (nsec, identity markers, etc.)
|
||||
}
|
||||
// Skip global:env:* unless the destination JSON references this gen.
|
||||
if key.starts_with("global:env:") {
|
||||
let gen = key.trim_start_matches("global:env:");
|
||||
if !global_refs.contains(gen) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match dest_map.get(key) {
|
||||
None => {
|
||||
to_write.insert(key.clone(), src_val.clone());
|
||||
}
|
||||
Some(dest_val) if dest_val == src_val => {
|
||||
// Already identical — idempotent, no action needed.
|
||||
}
|
||||
Some(_dest_val) => {
|
||||
// Conflict: src and dest have different values. Fail closed.
|
||||
eprintln!(
|
||||
"buzz-desktop: keyring-dev-secrets-migration: \
|
||||
conflict on key {key} between {src_service} and {dest_service}; \
|
||||
refusing to overwrite (manual resolution required)"
|
||||
);
|
||||
conflict_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always write the marker (even if there was nothing to copy) so future
|
||||
// boots skip the source-keyring read entirely.
|
||||
to_write.insert(DEV_SECRETS_MIGRATION_MARKER.to_string(), "done".to_string());
|
||||
|
||||
if let Err(e) = dest_store.store_all(&to_write) {
|
||||
eprintln!(
|
||||
"buzz-desktop: keyring-dev-secrets-migration: \
|
||||
cannot write to dest keyring ({dest_service}): {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let copied = to_write.len().saturating_sub(1); // exclude the marker itself
|
||||
if copied > 0 {
|
||||
eprintln!(
|
||||
"buzz-desktop: keyring-dev-secrets-migration: \
|
||||
copied {copied} projection key(s) from {src_service} → {dest_service}"
|
||||
);
|
||||
}
|
||||
if conflict_count > 0 {
|
||||
eprintln!(
|
||||
"buzz-desktop: keyring-dev-secrets-migration: \
|
||||
{conflict_count} key(s) had conflicts and were NOT copied"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect the set of generation IDs referenced by `global-agent-config.json`
|
||||
/// for the purposes of the dev secrets migration (to decide whether to copy
|
||||
/// `global:env:*` keys). Returns an empty set when the file is absent or
|
||||
/// unparseable — in that case no global env refs exist in the destination.
|
||||
#[cfg(debug_assertions)]
|
||||
fn collect_global_env_refs(app: &tauri::AppHandle) -> std::collections::HashSet<String> {
|
||||
let path = match app.path().app_data_dir() {
|
||||
Ok(d) => d.join("agents/global-agent-config.json"),
|
||||
Err(_) => return std::collections::HashSet::new(),
|
||||
};
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return std::collections::HashSet::new(),
|
||||
};
|
||||
let v: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return std::collections::HashSet::new(),
|
||||
};
|
||||
let mut refs = std::collections::HashSet::new();
|
||||
if let Some(r) = v.get("env_vars_ref").and_then(|r| r.as_str()) {
|
||||
refs.insert(r.to_string());
|
||||
}
|
||||
refs
|
||||
}
|
||||
|
||||
/// Remove an agent's key from the keyring, returning an error on failure.
|
||||
/// Used by the snapshot-import rollback path, which must surface cleanup
|
||||
/// failures rather than swallowing them.
|
||||
|
||||
@@ -221,23 +221,18 @@ pub struct ManagedAgentRecord {
|
||||
/// Team this instance was deployed from. Resolves runtime team instructions.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub team_id: Option<String>,
|
||||
/// nsec private key. Stored in the OS keyring (keyed by `pubkey`); the
|
||||
/// storage layer blanks this before writing JSON once the key is in the
|
||||
/// keyring and re-hydrates it on load. Serialized inline only when the
|
||||
/// keyring is unreachable (`0o600` JSON fallback).
|
||||
/// nsec private key. Stored in the OS keyring (keyed by `pubkey`); blanked
|
||||
/// before JSON write, re-hydrated on load. Inline only when keyring is unreachable.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub private_key_nsec: String,
|
||||
/// NIP-OA auth tag JSON. Pre-existing agents (pre-NIP-OA) have `None`
|
||||
/// and continue to work without attestation.
|
||||
/// NIP-OA auth tag JSON. Pre-existing agents (pre-NIP-OA) have `None`.
|
||||
#[serde(default)]
|
||||
pub auth_tag: Option<String>,
|
||||
/// Keyring gen ref for `auth_tag`; absent = intentionally empty.
|
||||
/// Keyring gen refs; absent = intentionally empty (`provider_config_ref` is `None` for `Local`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_tag_ref: Option<String>,
|
||||
/// Keyring gen ref for `env_vars`; absent = intentionally empty.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub env_vars_ref: Option<String>,
|
||||
/// Keyring gen ref for `BackendKind::Provider.config`; `None` for Local.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_config_ref: Option<String>,
|
||||
pub relay_url: String,
|
||||
@@ -443,13 +438,8 @@ pub struct ManagedAgentRecord {
|
||||
/// deserialize as `None`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub relay_mesh: Option<RelayMeshConfig>,
|
||||
/// Set by the load path when one or more secret fields (env_vars, auth_tag,
|
||||
/// provider_config) have a keyring `*_ref` that points to an unavailable or
|
||||
/// missing entry. Never serialized — it is a transient load-time signal
|
||||
/// that the spawn path must consult before starting this agent.
|
||||
///
|
||||
/// `true` → at least one referenced secret is unreachable; spawn must refuse.
|
||||
/// `false` (default) → all refs resolved successfully (or there are no refs).
|
||||
/// Set by the load path when a secret field's keyring `*_ref` points to an
|
||||
/// unavailable entry. Never serialized; `true` means spawn must refuse.
|
||||
#[serde(skip)]
|
||||
pub secrets_unavailable: bool,
|
||||
}
|
||||
|
||||
@@ -191,12 +191,8 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) {
|
||||
reconcile_provider_mcp_commands(app);
|
||||
reconcile_databricks_v1_to_v2(app);
|
||||
materialize_agent_runtimes(app);
|
||||
migrate_inline_secrets_to_keyring(app); // keyring extraction — runs after all raw-JSON migrations
|
||||
// Dev-build-only: copy secret-projection keys (env vars, auth tags, provider
|
||||
// configs) from the source keyring service into the destination dev service.
|
||||
// Runs AFTER migrate_inline_secrets_to_keyring so projection keys are
|
||||
// present in the source before we copy them.
|
||||
#[cfg(debug_assertions)]
|
||||
migrate_inline_secrets_to_keyring(app); // keyring extraction — after all raw-JSON migrations
|
||||
#[cfg(debug_assertions)] // dev-build only; runs after extraction so keys exist in the source
|
||||
if is_dev {
|
||||
crate::managed_agents::migrate_agent_secrets_to_dev_service(app);
|
||||
}
|
||||
@@ -1389,9 +1385,6 @@ mod team_suffix;
|
||||
pub use team_suffix::strip_baked_team_instructions;
|
||||
mod migration_secrets;
|
||||
use migration_secrets::migrate_inline_secrets_to_keyring;
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use migration_secrets::run_secret_gc;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "migration_avatar_tests.rs"]
|
||||
mod avatar_tests;
|
||||
|
||||
Reference in New Issue
Block a user