fix(desktop): add ManagedAgentRecord.secrets_unavailable + spawn refusal + dev secrets migration

Commit the three items that were in working-tree state but not pushed:

- types.rs: add secrets_unavailable: bool field (transient, #[serde(skip)]) to
  ManagedAgentRecord with doc comment explaining load-time semantics

- secret_seam.rs: set record.secrets_unavailable = true when hydration errors
  are present, making the setter actually wire unavailability into the record

- storage.rs: extend spawn_key_refusal to check secrets_unavailable (in addition
  to empty nsec), refuse spawn with a named error message; update comments at
  both load_managed_agents callers to reflect that unavailability is now set
  directly on the record and consulted by spawn_key_refusal

- storage.rs: add migrate_agent_secrets_to_dev_service (v2 marker, separately
  versioned from _dev_migration_v1) covering canonical dev → prod and scoped
  dev → canonical dev, with conflict detection, global:env ref check, and
  collect_global_env_refs helper

- migration.rs: wire migrate_agent_secrets_to_dev_service into the boot path
  (cfg(debug_assertions), after migrate_inline_secrets_to_keyring)

- secret_projection.rs: add test_cancel_before_mark_ordering_protects_in_flight_gen
  covering the cancel-before-mark scenario Paul flagged (step 4 safe: a marked-
  then-referenced gen survives the next boot's delete phase)

- All struct literal sites updated with secrets_unavailable: false

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-10 13:51:05 -04:00
parent ebda26b2df
commit a787d83fc4
32 changed files with 490 additions and 8 deletions

View File

@@ -122,6 +122,7 @@ fn agent_record() -> ManagedAgentRecord {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -916,6 +916,7 @@ pub async fn create_managed_agent(
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
};
records.push(record);

View File

@@ -65,6 +65,7 @@ fn bare_agent_record(
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}
fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition {

View File

@@ -73,6 +73,7 @@ fn make_agent(
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -218,6 +218,7 @@ fn local_agent() -> ManagedAgentRecord {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -67,6 +67,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -657,6 +657,7 @@ pub async fn confirm_agent_snapshot_import(
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
};
records.push(record.clone());

View File

@@ -76,6 +76,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -61,6 +61,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -614,6 +614,7 @@ pub async fn confirm_team_snapshot_import(
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
};
minted.push(MintedMember {

View File

@@ -234,6 +234,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
};
let mut memory_map = std::collections::HashMap::new();

View File

@@ -219,6 +219,7 @@ mod tests {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -422,6 +422,7 @@ mod tests {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -75,6 +75,7 @@ fn minimal_record() -> ManagedAgentRecord {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -121,6 +121,7 @@ fn test_record() -> ManagedAgentRecord {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -283,6 +283,7 @@ fn record_with(
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -95,6 +95,7 @@ fn record(
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -356,6 +356,7 @@ fn bare_record() -> ManagedAgentRecord {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -505,6 +505,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -120,6 +120,7 @@ mod tests {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -61,6 +61,7 @@ pub(super) fn sample_record() -> ManagedAgentRecord {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -1530,6 +1530,7 @@ mod tests {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
};
let runtime = known_acp_runtime_exact("buzz-agent");

View File

@@ -92,5 +92,6 @@ pub(super) fn fixture(
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -927,4 +927,113 @@ mod tests {
"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)"
);
}
}

View File

@@ -264,6 +264,7 @@ pub(crate) fn hydrate_all_secrets_for_records<S: ProjectionStore>(
eprintln!("buzz-desktop: {e}");
}
if !errors.is_empty() {
record.secrets_unavailable = true;
unavailable.push(record.pubkey.clone());
}
}

View File

@@ -73,6 +73,7 @@ fn record() -> ManagedAgentRecord {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -225,14 +225,28 @@ fn migrate_inline_key(store: &impl KeyStore, record: &ManagedAgentRecord) -> Key
/// deliberately keyless agent. Spawning anyway would inject an empty
/// `BUZZ_PRIVATE_KEY`/`NOSTR_PRIVATE_KEY`, launching with no identity. Callers
/// (the spawn path) must fail closed (Wes storage.rs:158).
///
/// Also refuses when `record.secrets_unavailable` is set — at least one secret
/// field (env_vars, auth_tag, provider_config) has a keyring ref that points to
/// a missing or unreachable entry. Spawning with silently empty secrets is the
/// exact failure mode the gen-ref protocol exists to prevent.
pub(crate) fn spawn_key_refusal(record: &ManagedAgentRecord) -> Option<String> {
record.private_key_nsec.is_empty().then(|| {
format!(
if record.private_key_nsec.is_empty() {
return Some(format!(
"agent {} has no private key available — the OS keyring may be unreachable. \
Refusing to start without an identity; retry once the keyring is reachable.",
record.pubkey
)
})
));
}
if record.secrets_unavailable {
return Some(format!(
"agent {} has one or more secrets (env vars, auth tag, or provider config) \
that could not be loaded from the keyring. Refusing to start with missing \
secrets; retry once the keyring is reachable.",
record.pubkey
));
}
None
}
/// Read the raw unified store — keyed instances AND key-less definitions —
@@ -267,10 +281,10 @@ pub fn load_managed_agents(app: &AppHandle) -> Result<Vec<ManagedAgentRecord>, S
// Hydrate env/auth_tag/provider_config from keyring.
if let Some(store) = agent_secret_store() {
let _ = hydrate_all_secrets_for_records(store, &mut records);
// Note: unavailable env/auth/provider_config does NOT block load — only
// nsec unavailability (handled in hydrate_keys) causes spawn refusal.
// The returned unavailable list is informational here; readiness checks
// surface it to the user via the existing refusal path.
// Unavailability is set directly on each record (`secrets_unavailable`);
// the returned Vec is a secondary summary, not needed here. The spawn
// path consults `spawn_key_refusal`, which checks `secrets_unavailable`
// and refuses to start any agent whose secrets could not be loaded.
}
Ok(records)
}
@@ -598,6 +612,177 @@ 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.

View File

@@ -251,6 +251,29 @@ fn spawn_allowed_when_private_key_present() {
assert!(super::spawn_key_refusal(&record).is_none());
}
#[test]
fn spawn_refused_when_secrets_unavailable() {
// A record whose keyring ref exists but the entry is unavailable must be
// refused at spawn time — same semantics as a missing private key.
let mut record = record_with_key("nsec1realkey");
record.secrets_unavailable = true;
assert!(
super::spawn_key_refusal(&record).is_some(),
"an agent with unavailable secrets must be refused at spawn"
);
}
#[test]
fn spawn_allowed_when_key_present_and_no_unavailable_secrets() {
// A fully hydrated record must not be blocked.
let mut record = record_with_key("nsec1realkey");
record.secrets_unavailable = false;
assert!(
super::spawn_key_refusal(&record).is_none(),
"an agent with key and reachable secrets must be allowed"
);
}
#[test]
fn persist_agent_keys_issues_zero_writes_when_inline_keys_already_cleared() {
// This is the dominant prompt-storm scenario: after the first successful
@@ -830,3 +853,123 @@ fn install_log_filename_accepts_ordinary_runtime_ids() {
);
}
}
/// Regression test: after secret extraction, the serialized store must not
/// contain any secret-shaped values. This is the grep-empty criterion from
/// the v5 spec acceptance criteria, exercised at the type level.
///
/// The test constructs records with inline secrets, runs the strip path via
/// the secret seam, and verifies the resulting JSON is free of the known
/// secret values.
#[test]
fn serialized_store_is_empty_of_secret_values_after_strip() {
use crate::managed_agents::secret_seam::strip_and_persist_agent_secrets_with;
use std::cell::RefCell;
use std::collections::HashMap;
// ── FakeProjectionStore (minimal, for this test) ──────────────────
struct FakePS {
data: RefCell<HashMap<String, String>>,
}
impl crate::managed_agents::secret_projection::ProjectionStore for FakePS {
fn write_and_verify(&self, key: &str, value: &str) -> Result<(), String> {
self.data
.borrow_mut()
.insert(key.to_string(), value.to_string());
Ok(())
}
fn load_key(&self, key: &str) -> Result<Option<String>, String> {
Ok(self.data.borrow().get(key).cloned())
}
fn load_all(&self) -> Result<Option<HashMap<String, String>>, String> {
Ok(Some(self.data.borrow().clone()))
}
fn store_batch(&self, entries: &HashMap<String, String>) -> Result<(), String> {
for (k, v) in entries {
self.data.borrow_mut().insert(k.clone(), v.clone());
}
Ok(())
}
fn remove_batch(&self, keys: &[&str]) -> Result<(), String> {
let mut d = self.data.borrow_mut();
for k in keys {
d.remove(*k);
}
Ok(())
}
}
let store = FakePS {
data: RefCell::new(HashMap::new()),
};
let secret_env_value = "sk-ant-api03-very-secret-key";
let secret_auth_tag = "auth-tag-secret";
// Build a record via JSON deserialization (avoids Default dependency).
let mut record: ManagedAgentRecord = serde_json::from_str(&format!(
r#"{{
"pubkey": "testpubkey123",
"name": "test-agent",
"env_vars": {{"ANTHROPIC_API_KEY": "{secret_env_value}"}},
"auth_tag": "{secret_auth_tag}",
"backend": {{"type": "provider", "id": "anthropic", "config": {{"api_key": "provider-secret"}}}},
"relay_url": "wss://localhost:3000",
"acp_command": "buzz-acp",
"agent_command": "goose",
"agent_args": [],
"mcp_command": "",
"turn_timeout_seconds": 320,
"created_at": "2026-01-01",
"updated_at": "2026-01-01"
}}"#
))
.expect("sample record with inline secrets");
// Strip: moves secrets from inline fields into the fake keyring.
strip_and_persist_agent_secrets_with(&store, &mut record);
// After strip: inline fields must be empty/null.
assert!(
record.env_vars.is_empty(),
"env_vars must be cleared after strip"
);
assert!(
record.auth_tag.is_none(),
"auth_tag must be cleared after strip"
);
if let crate::managed_agents::types::BackendKind::Provider { config, .. } = &record.backend {
assert!(
config.is_null(),
"provider config must be cleared after strip"
);
}
// Serialize to JSON and verify no secret bytes appear.
let json = serde_json::to_string(&record).expect("serialize");
assert!(
!json.contains(secret_env_value),
"serialized JSON must not contain env_vars secret"
);
assert!(
!json.contains(secret_auth_tag),
"serialized JSON must not contain auth_tag secret"
);
assert!(
!json.contains("provider-secret"),
"serialized JSON must not contain provider config secret"
);
// Refs must be present (the keyring round-trip worked).
assert!(
record.env_vars_ref.is_some(),
"env_vars_ref must be set after successful strip"
);
assert!(
record.auth_tag_ref.is_some(),
"auth_tag_ref must be set after successful strip"
);
assert!(
record.provider_config_ref.is_some(),
"provider_config_ref must be set after successful strip"
);
}

View File

@@ -312,6 +312,7 @@ mod tests {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -219,6 +219,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord {
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

View File

@@ -156,6 +156,7 @@ impl AgentDefinition {
definition_respond_to_allowlist: self.respond_to_allowlist,
definition_parallelism: self.parallelism,
relay_mesh: None,
secrets_unavailable: false,
}
}
}
@@ -442,6 +443,15 @@ 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).
#[serde(skip)]
pub secrets_unavailable: bool,
}
/// Typed relay-mesh configuration carried on a [`ManagedAgentRecord`].

View File

@@ -192,6 +192,14 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) {
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)]
if is_dev {
crate::managed_agents::migrate_agent_secrets_to_dev_service(app);
}
}
/// Copy one-time app state from the legacy app identifier directory to