mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): isolate dev keyring from production and make BUZZ_SHARE_IDENTITY keyring-aware (#1680)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
1f2afce29d
commit
e5d215e3c2
@@ -95,6 +95,9 @@ const overrides = new Map([
|
||||
// unified-agent-model 1A.1: inline test module moved to nest/tests.rs,
|
||||
// ratcheting 1575 -> 679 (under the 1000 default; entry kept as a ratchet).
|
||||
["src-tauri/src/managed_agents/nest.rs", 679],
|
||||
// keyring-dev-isolation: agent key migration added copy_agent_keys_between_stores
|
||||
// and load_readonly support; file grew past 1000 default. Queued to split.
|
||||
["src-tauri/src/managed_agents/storage.rs", 1325],
|
||||
// harness-persona-sync: persona-runtime resolution threaded into the spawn
|
||||
// path here. Load-bearing feature growth; queued to split in the resolver
|
||||
// unify refactor followup. +26 for resolve_effective_prompt_model_provider
|
||||
@@ -192,7 +195,8 @@ const overrides = new Map([
|
||||
// + inner fn with baked-env gate + 26 tests. Load-bearing correctness fix.
|
||||
// am review fix: also clear stale V1 model field on provider rewrite +
|
||||
// new model-clear test. Load-bearing chimera fix.
|
||||
["src-tauri/src/migration.rs", 1402],
|
||||
// keyring-dev-isolation: run_boot_migrations wires agent-key migration.
|
||||
["src-tauri/src/migration.rs", 1415],
|
||||
// onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop
|
||||
// already here) for the single-toggle mark-read/unread menu item — a small
|
||||
// overage from load-bearing per-message plumbing, not generic debt growth.
|
||||
@@ -213,8 +217,10 @@ const overrides = new Map([
|
||||
// cross-process keychain race fix (D3): interprocess lock + BlobLockGuard +
|
||||
// uid-keyed lockfile path + behavioral tests add ~303 lines. Load-bearing
|
||||
// security fix for the lost-update race that stranded agent keys.
|
||||
["src-tauri/src/secret_store.rs", 1043],
|
||||
["src-tauri/src/app_state.rs", 1033],
|
||||
["src-tauri/src/secret_store.rs", 1110],
|
||||
// keyring-dev-isolation: keyring_service() fn (7 lines) replaces the const
|
||||
// to return "buzz-desktop-dev" in debug builds. Load-bearing isolation fix.
|
||||
["src-tauri/src/app_state.rs", 1042],
|
||||
// multi-slot splitting + no-op suppression (#1309): the ReadStateManager
|
||||
// class grew from ~700 lines to ~1019 with the addition of
|
||||
// splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots,
|
||||
|
||||
@@ -184,7 +184,16 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(
|
||||
|
||||
/// Service name for the desktop OS keyring. Shared by the human identity key
|
||||
/// and managed-agent keys (each addressed by a distinct key name within it).
|
||||
pub(crate) const KEYRING_SERVICE: &str = "buzz-desktop";
|
||||
///
|
||||
/// Debug builds use a distinct service name so dev and production keyring
|
||||
/// entries never collide on the same machine.
|
||||
pub(crate) fn keyring_service() -> &'static str {
|
||||
if cfg!(debug_assertions) {
|
||||
"buzz-desktop-dev"
|
||||
} else {
|
||||
"buzz-desktop"
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyring key name for the human identity nsec.
|
||||
const IDENTITY_KEY_NAME: &str = "identity";
|
||||
@@ -238,7 +247,7 @@ fn load_or_create_identity(data_dir: &std::path::Path) -> Result<Keys, String> {
|
||||
return load_file_or_generate(&legacy_path, data_dir);
|
||||
}
|
||||
|
||||
let store = crate::secret_store::SecretStore::shared(KEYRING_SERVICE);
|
||||
let store = crate::secret_store::SecretStore::shared(keyring_service());
|
||||
resolve_identity_with_store(store, &legacy_path, data_dir)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{Read as _, Seek, SeekFrom, Write},
|
||||
path::{Path, PathBuf},
|
||||
@@ -6,7 +7,7 @@ use std::{
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::app_state::KEYRING_SERVICE;
|
||||
use crate::app_state::keyring_service;
|
||||
use crate::managed_agents::ManagedAgentRecord;
|
||||
use crate::secret_store::{KeyringProbe, SecretStore};
|
||||
|
||||
@@ -23,7 +24,7 @@ fn agent_keyring_name(pubkey: &str) -> String {
|
||||
/// races on concurrent blob writes.
|
||||
fn agent_secret_store() -> Option<&'static SecretStore> {
|
||||
if cfg!(feature = "system-keyring") {
|
||||
Some(SecretStore::shared(KEYRING_SERVICE))
|
||||
Some(SecretStore::shared(keyring_service()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -61,9 +62,18 @@ trait KeyStore {
|
||||
/// 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>;
|
||||
/// Read a key without any side effects (no legacy-key migration, no writes).
|
||||
/// `Ok(None)` when the blob or the key is absent; `Err` only on backend failure.
|
||||
fn load_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.
|
||||
fn load_all_readonly(&self) -> Result<Option<HashMap<String, String>>, String>;
|
||||
/// Write `value` and read it back to confirm before the caller strips the
|
||||
/// inline copy.
|
||||
fn write_and_verify(&self, name: &str, value: &str) -> Result<(), String>;
|
||||
/// Insert all entries from `entries` in a single blob mutation.
|
||||
fn store_all(&self, entries: &HashMap<String, String>) -> Result<(), String>;
|
||||
}
|
||||
|
||||
impl KeyStore for SecretStore {
|
||||
@@ -73,6 +83,12 @@ impl KeyStore for SecretStore {
|
||||
fn load(&self, name: &str) -> Result<Option<String>, String> {
|
||||
SecretStore::load(self, name)
|
||||
}
|
||||
fn load_readonly(&self, name: &str) -> Result<Option<String>, String> {
|
||||
SecretStore::load_readonly(self, name)
|
||||
}
|
||||
fn load_all_readonly(&self) -> Result<Option<HashMap<String, String>>, String> {
|
||||
SecretStore::load_all_readonly(self)
|
||||
}
|
||||
fn write_and_verify(&self, name: &str, value: &str) -> Result<(), String> {
|
||||
self.store(name, value)?;
|
||||
match self.load(name)? {
|
||||
@@ -80,6 +96,9 @@ impl KeyStore for SecretStore {
|
||||
_ => Err("keyring read-back verify failed".to_string()),
|
||||
}
|
||||
}
|
||||
fn store_all(&self, entries: &HashMap<String, String>) -> Result<(), String> {
|
||||
SecretStore::store_all(self, entries)
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of attempting to lift a record's inline key into the keyring.
|
||||
@@ -355,6 +374,138 @@ fn persist_agent_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time migration of agent keys from the production keyring service
|
||||
/// (`"buzz-desktop"`) to the dev service (`"buzz-desktop-dev"`). Only runs
|
||||
/// in debug builds — release builds never touch `"buzz-desktop"` from this
|
||||
/// path.
|
||||
///
|
||||
/// Idempotent: skips any key that already exists in the dev service so
|
||||
/// repeated boots after migration are no-ops. Leaves the production keyring
|
||||
/// 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
|
||||
/// after the service-name change.
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn migrate_agent_keys_to_dev_service(app: &tauri::AppHandle) {
|
||||
if !cfg!(feature = "system-keyring") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the JSON store for pubkeys only — we want every instance
|
||||
// record without running hydrate_keys (which would try the dev
|
||||
// keyring that is empty, and log noisy "has no key" warnings).
|
||||
let records = match load_agent_store(app) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("buzz-desktop: keyring-dev-migration: cannot read agent store: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let pubkeys: Vec<String> = records
|
||||
.into_iter()
|
||||
.filter(|r| !r.pubkey.is_empty())
|
||||
.map(|r| r.pubkey)
|
||||
.collect();
|
||||
|
||||
if pubkeys.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// A fresh non-singleton store for the prod service — its own empty
|
||||
// cache so reads go to the OS keyring without polluting the dev
|
||||
// singleton's cache.
|
||||
let prod_store = crate::secret_store::SecretStore::keyring("buzz-desktop");
|
||||
let dev_store = crate::secret_store::SecretStore::shared(keyring_service());
|
||||
copy_agent_keys_between_stores(&pubkeys, &prod_store, dev_store);
|
||||
}
|
||||
|
||||
/// Marker key stored inside the dev blob after a successful agent-key migration.
|
||||
/// Its presence means all agent keys that existed in the prod service at
|
||||
/// migration time have been copied; subsequent dev boots skip the migration
|
||||
/// entirely (no prod keyring access).
|
||||
#[cfg(debug_assertions)]
|
||||
const DEV_MIGRATION_MARKER: &str = "_dev_migration_v1";
|
||||
|
||||
/// Testable core of [`migrate_agent_keys_to_dev_service`]: copy `agent:<pubkey>`
|
||||
/// entries from `src` to `dst` for each pubkey, then write a migration-complete
|
||||
/// marker so future boots skip the entire function with zero prod-keyring access.
|
||||
///
|
||||
/// On the first migration boot:
|
||||
/// 1. One `dst.load_all_readonly()` — dev blob read (1 keychain prompt)
|
||||
/// 2. One `src.load_all_readonly()` — prod blob read (1 keychain prompt)
|
||||
/// 3. One `dst.store_all()` — dev blob write (same service as #1; macOS may
|
||||
/// skip the ACL prompt if the initial grant was "Always Allow")
|
||||
///
|
||||
/// On subsequent boots (marker already present):
|
||||
/// 1. One `dst.load_all_readonly()` — dev blob read (1 keychain prompt)
|
||||
/// Returns immediately — prod keyring is NEVER accessed.
|
||||
///
|
||||
/// Idempotency: keys already present in `dst` are not overwritten (the agent
|
||||
/// may have rotated their key in the dev service after initial migration).
|
||||
/// New agents (pubkey not in `src`) are silently skipped — they will mint a
|
||||
/// fresh key on their next onboarding run.
|
||||
#[cfg(debug_assertions)]
|
||||
fn copy_agent_keys_between_stores(pubkeys: &[String], src: &impl KeyStore, dst: &impl KeyStore) {
|
||||
// One read of the dev blob. If the migration-complete marker is present,
|
||||
// all prior agent keys are already in the dev service — skip entirely.
|
||||
let dst_map: HashMap<String, String> = match dst.load_all_readonly() {
|
||||
Ok(Some(map)) if map.contains_key(DEV_MIGRATION_MARKER) => {
|
||||
return; // already migrated: 0 prod keyring accesses
|
||||
}
|
||||
Ok(Some(map)) => map,
|
||||
Ok(None) => HashMap::new(),
|
||||
Err(e) => {
|
||||
eprintln!("buzz-desktop: keyring-dev-migration: cannot read dev keyring: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// One read of the prod blob.
|
||||
let src_map: HashMap<String, String> = match src.load_all_readonly() {
|
||||
Ok(Some(map)) => map,
|
||||
Ok(None) => HashMap::new(), // prod has no blob yet — nothing to copy
|
||||
Err(e) => {
|
||||
eprintln!("buzz-desktop: keyring-dev-migration: cannot read prod keyring: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Compute the set of entries to write: agent keys absent from dst, plus
|
||||
// the migration-complete marker.
|
||||
let mut to_write: HashMap<String, String> = HashMap::new();
|
||||
let mut copied = 0usize;
|
||||
for pubkey in pubkeys {
|
||||
let name = agent_keyring_name(pubkey);
|
||||
if dst_map.contains_key(&name) {
|
||||
continue; // already in dev service — do not overwrite (idempotent)
|
||||
}
|
||||
if let Some(nsec) = src_map.get(&name) {
|
||||
to_write.insert(name, nsec.clone());
|
||||
copied += 1;
|
||||
}
|
||||
// absent from src → new agent, will mint a fresh key
|
||||
}
|
||||
|
||||
// Always write the marker so future boots skip the prod read entirely,
|
||||
// even when there were no keys to copy (empty dev environment).
|
||||
to_write.insert(DEV_MIGRATION_MARKER.to_string(), "done".to_string());
|
||||
|
||||
if let Err(e) = dst.store_all(&to_write) {
|
||||
eprintln!("buzz-desktop: keyring-dev-migration: cannot write to dev keyring: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
if copied > 0 {
|
||||
eprintln!(
|
||||
"buzz-desktop: keyring-dev-migration: copied {copied} agent key(s) from buzz-desktop"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an agent's key from the keyring (best-effort). Called when an agent
|
||||
/// is deleted so its secret does not linger in the OS store.
|
||||
pub fn delete_agent_key(pubkey: &str) {
|
||||
@@ -602,6 +753,7 @@ mod tests {
|
||||
fail_verify: bool,
|
||||
stored: RefCell<HashMap<String, String>>,
|
||||
write_count: RefCell<usize>,
|
||||
read_count: RefCell<usize>,
|
||||
}
|
||||
|
||||
impl FakeKeyStore {
|
||||
@@ -611,6 +763,7 @@ mod tests {
|
||||
fail_verify: false,
|
||||
stored: RefCell::new(HashMap::new()),
|
||||
write_count: RefCell::new(0),
|
||||
read_count: RefCell::new(0),
|
||||
}
|
||||
}
|
||||
fn unreachable() -> Self {
|
||||
@@ -619,6 +772,7 @@ mod tests {
|
||||
fail_verify: false,
|
||||
stored: RefCell::new(HashMap::new()),
|
||||
write_count: RefCell::new(0),
|
||||
read_count: RefCell::new(0),
|
||||
}
|
||||
}
|
||||
fn verify_fails() -> Self {
|
||||
@@ -627,6 +781,7 @@ mod tests {
|
||||
fail_verify: true,
|
||||
stored: RefCell::new(HashMap::new()),
|
||||
write_count: RefCell::new(0),
|
||||
read_count: RefCell::new(0),
|
||||
}
|
||||
}
|
||||
/// Seed a key as already present in the keyring.
|
||||
@@ -652,8 +807,26 @@ mod tests {
|
||||
if !self.reachable {
|
||||
return Err("keyring backend unreachable".to_string());
|
||||
}
|
||||
*self.read_count.borrow_mut() += 1;
|
||||
Ok(self.stored.borrow().get(name).cloned())
|
||||
}
|
||||
fn load_readonly(&self, name: &str) -> Result<Option<String>, String> {
|
||||
// The fake has no side effects, so load_readonly is identical to load.
|
||||
self.load(name)
|
||||
}
|
||||
fn load_all_readonly(&self) -> Result<Option<HashMap<String, String>>, String> {
|
||||
if !self.reachable {
|
||||
return Err("keyring backend unreachable".to_string());
|
||||
}
|
||||
*self.read_count.borrow_mut() += 1;
|
||||
let map = self.stored.borrow().clone();
|
||||
// Return None when completely empty (simulates no blob written yet).
|
||||
if map.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(map))
|
||||
}
|
||||
}
|
||||
fn write_and_verify(&self, name: &str, value: &str) -> Result<(), String> {
|
||||
if self.fail_verify {
|
||||
return Err("read-back verify failed".to_string());
|
||||
@@ -664,6 +837,20 @@ mod tests {
|
||||
.insert(name.to_string(), value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn store_all(&self, entries: &HashMap<String, String>) -> Result<(), String> {
|
||||
if !self.reachable {
|
||||
return Err("keyring backend unreachable".to_string());
|
||||
}
|
||||
if self.fail_verify {
|
||||
return Err("read-back verify failed".to_string());
|
||||
}
|
||||
*self.write_count.borrow_mut() += 1;
|
||||
let mut stored = self.stored.borrow_mut();
|
||||
for (k, v) in entries {
|
||||
stored.insert(k.clone(), v.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn record_with_key(nsec: &str) -> ManagedAgentRecord {
|
||||
@@ -942,4 +1129,192 @@ mod tests {
|
||||
"2026-05-27T15:16:32 INFO buzz_acp: starting"
|
||||
);
|
||||
}
|
||||
|
||||
// ── keyring-dev-migration tests ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn copy_agent_keys_copies_keys_present_in_src_to_dst() {
|
||||
// Keys in src but not in dst must be copied in a single bulk write,
|
||||
// and the migration-complete marker must be set.
|
||||
let src = FakeKeyStore::reachable()
|
||||
.with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha")
|
||||
.with_key(&agent_keyring_name("agent-beta"), "nsec1beta");
|
||||
let dst = FakeKeyStore::reachable();
|
||||
|
||||
super::copy_agent_keys_between_stores(
|
||||
&["agent-alpha".to_string(), "agent-beta".to_string()],
|
||||
&src,
|
||||
&dst,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
dst.stored
|
||||
.borrow()
|
||||
.get(&agent_keyring_name("agent-alpha"))
|
||||
.map(String::as_str),
|
||||
Some("nsec1alpha"),
|
||||
"agent-alpha must be copied from src to dst"
|
||||
);
|
||||
assert_eq!(
|
||||
dst.stored
|
||||
.borrow()
|
||||
.get(&agent_keyring_name("agent-beta"))
|
||||
.map(String::as_str),
|
||||
Some("nsec1beta"),
|
||||
"agent-beta must be copied from src to dst"
|
||||
);
|
||||
assert_eq!(
|
||||
dst.stored
|
||||
.borrow()
|
||||
.get(super::DEV_MIGRATION_MARKER)
|
||||
.map(String::as_str),
|
||||
Some("done"),
|
||||
"migration-complete marker must be set after first migration"
|
||||
);
|
||||
// Bulk write: exactly 1 store_all call.
|
||||
assert_eq!(
|
||||
*dst.write_count.borrow(),
|
||||
1,
|
||||
"must perform exactly one bulk write"
|
||||
);
|
||||
// Src accessed exactly once (bulk blob read).
|
||||
assert_eq!(
|
||||
*src.read_count.borrow(),
|
||||
1,
|
||||
"src must be read exactly once (bulk)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_agent_keys_skips_keys_already_in_dst() {
|
||||
// Idempotency: a key already present in dst must NOT be overwritten
|
||||
// — the agent may have rotated their key in the dev service.
|
||||
let src =
|
||||
FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1old");
|
||||
let dst =
|
||||
FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1new");
|
||||
|
||||
super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst);
|
||||
|
||||
// dst value must remain unchanged — src must not overwrite it.
|
||||
assert_eq!(
|
||||
dst.stored
|
||||
.borrow()
|
||||
.get(&agent_keyring_name("agent-alpha"))
|
||||
.map(String::as_str),
|
||||
Some("nsec1new"),
|
||||
"key already in dst must not be overwritten by migration"
|
||||
);
|
||||
// Marker must still be written even though no new keys were copied.
|
||||
assert_eq!(
|
||||
dst.stored
|
||||
.borrow()
|
||||
.get(super::DEV_MIGRATION_MARKER)
|
||||
.map(String::as_str),
|
||||
Some("done"),
|
||||
"marker must be set even when all keys are already present"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_agent_keys_skips_keys_absent_from_src() {
|
||||
// A pubkey with no entry in src (new agent that will mint a fresh key)
|
||||
// must be silently skipped — no agent key written to dst.
|
||||
let src = FakeKeyStore::reachable(); // empty
|
||||
let dst = FakeKeyStore::reachable();
|
||||
|
||||
super::copy_agent_keys_between_stores(&["new-agent".to_string()], &src, &dst);
|
||||
|
||||
assert!(
|
||||
dst.stored
|
||||
.borrow()
|
||||
.get(&agent_keyring_name("new-agent"))
|
||||
.is_none(),
|
||||
"absent src key must produce no agent key write to dst"
|
||||
);
|
||||
// Marker must still be written.
|
||||
assert_eq!(
|
||||
dst.stored
|
||||
.borrow()
|
||||
.get(super::DEV_MIGRATION_MARKER)
|
||||
.map(String::as_str),
|
||||
Some("done"),
|
||||
"marker must be set even when no keys were present in src"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_agent_keys_skips_all_when_dst_unreachable() {
|
||||
// When dst keyring is unreachable the migration must be a no-op — never
|
||||
// data-loss (failing to write is fine; the agent will re-mint on next
|
||||
// onboarding run).
|
||||
let src =
|
||||
FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha");
|
||||
let dst = FakeKeyStore::unreachable();
|
||||
|
||||
super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst);
|
||||
|
||||
// No writes attempted to an unreachable dst.
|
||||
assert_eq!(*dst.write_count.borrow(), 0);
|
||||
// Src must not have been accessed (failed on dst read, returned early).
|
||||
assert_eq!(
|
||||
*src.read_count.borrow(),
|
||||
0,
|
||||
"src must not be accessed when dst is unreachable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_agent_keys_skips_entirely_when_marker_present() {
|
||||
// After the first migration, the marker is in dst. Subsequent calls
|
||||
// must return immediately — the prod keyring (src) must never be read.
|
||||
let src =
|
||||
FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha");
|
||||
let dst = FakeKeyStore::reachable()
|
||||
.with_key(super::DEV_MIGRATION_MARKER, "done")
|
||||
.with_key(&agent_keyring_name("agent-alpha"), "nsec1dev");
|
||||
|
||||
super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst);
|
||||
|
||||
// Src must not have been accessed at all.
|
||||
assert_eq!(
|
||||
*src.read_count.borrow(),
|
||||
0,
|
||||
"src must not be read when migration-complete marker is present"
|
||||
);
|
||||
// Dst must not have been written.
|
||||
assert_eq!(
|
||||
*dst.write_count.borrow(),
|
||||
0,
|
||||
"dst must not be written when migration-complete marker is present"
|
||||
);
|
||||
// Dev key must remain unchanged.
|
||||
assert_eq!(
|
||||
dst.stored
|
||||
.borrow()
|
||||
.get(&agent_keyring_name("agent-alpha"))
|
||||
.map(String::as_str),
|
||||
Some("nsec1dev"),
|
||||
"dev key must not be overwritten on subsequent boots"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_agent_keys_writes_marker_even_with_empty_agent_list() {
|
||||
// An empty pubkey list (no agents yet) must still write the marker so
|
||||
// future boots skip the prod read.
|
||||
let src = FakeKeyStore::reachable();
|
||||
let dst = FakeKeyStore::reachable();
|
||||
|
||||
super::copy_agent_keys_between_stores(&[], &src, &dst);
|
||||
|
||||
assert_eq!(
|
||||
dst.stored
|
||||
.borrow()
|
||||
.get(super::DEV_MIGRATION_MARKER)
|
||||
.map(String::as_str),
|
||||
Some("done"),
|
||||
"marker must be set even when pubkey list is empty"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,16 @@ pub fn run_boot_migrations(app: &tauri::AppHandle) {
|
||||
|
||||
migrate_legacy_app_data_dir(app);
|
||||
sync_shared_agent_data(app);
|
||||
// Dev-build-only: copy any agent keys that exist in the production
|
||||
// keyring ("buzz-desktop") into the dev service ("buzz-desktop-dev")
|
||||
// so existing agents don't lose their keys after the service-name split.
|
||||
// Must run after sync_shared_agent_data (JSON symlinked) and before
|
||||
// any load_managed_agents call (which runs hydrate_keys against the
|
||||
// dev service and would log "has no key" for un-migrated entries).
|
||||
#[cfg(debug_assertions)]
|
||||
if is_dev {
|
||||
crate::managed_agents::migrate_agent_keys_to_dev_service(app);
|
||||
}
|
||||
migrate_packs_to_teams(app);
|
||||
reconcile_persona_team_dirs(app);
|
||||
migrate_persona_provider_to_runtime(app);
|
||||
|
||||
@@ -575,6 +575,69 @@ impl SecretStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the secret for `key` without any legacy-migration side effects.
|
||||
///
|
||||
/// Unlike [`load`](Self::load), this method never calls
|
||||
/// `migrate_legacy_key` and therefore never writes to or deletes from the
|
||||
/// keyring. Use this when the caller must guarantee the store is not
|
||||
/// mutated — for example, when reading from a foreign service (prod) to
|
||||
/// copy values into a dev service.
|
||||
///
|
||||
/// Returns `Ok(Some(value))` when the key is present in the blob,
|
||||
/// `Ok(None)` when the blob is absent or the key is not in it, and `Err`
|
||||
/// only when the backend is unavailable.
|
||||
pub fn load_readonly(&self, key: &str) -> Result<Option<String>, String> {
|
||||
#[cfg(feature = "system-keyring")]
|
||||
{
|
||||
match self.load_blob()? {
|
||||
Some(map) => Ok(map.get(key).cloned()),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "system-keyring"))]
|
||||
{
|
||||
let _ = key;
|
||||
Err("system-keyring feature disabled".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the entire blob without any legacy-migration side effects.
|
||||
///
|
||||
/// Returns the full key→value map when a blob exists, `Ok(None)` when no
|
||||
/// blob has been written yet, and `Err` only when the backend is
|
||||
/// unavailable. Never calls `migrate_legacy_key`.
|
||||
pub fn load_all_readonly(&self) -> Result<Option<HashMap<String, String>>, String> {
|
||||
#[cfg(feature = "system-keyring")]
|
||||
{
|
||||
self.load_blob()
|
||||
}
|
||||
#[cfg(not(feature = "system-keyring"))]
|
||||
{
|
||||
Err("system-keyring feature disabled".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert all entries from `entries` into the blob in a single mutation.
|
||||
///
|
||||
/// Entries that already exist in the blob are overwritten; entries not
|
||||
/// present in `entries` are left unchanged. If the resulting blob is
|
||||
/// identical to what is already stored, no keychain write occurs.
|
||||
pub fn store_all(&self, entries: &HashMap<String, String>) -> Result<(), String> {
|
||||
#[cfg(feature = "system-keyring")]
|
||||
{
|
||||
self.mutate_blob(|map| {
|
||||
for (k, v) in entries {
|
||||
map.insert(k.clone(), v.clone());
|
||||
}
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "system-keyring"))]
|
||||
{
|
||||
let _ = entries;
|
||||
Err("system-keyring feature disabled".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// On first launch after upgrading from the per-key DPK format, read the
|
||||
/// old DPK entry for `key`, write it into a new blob, and delete the old
|
||||
/// item. Returns `Ok(None)` when no old entry exists.
|
||||
|
||||
@@ -203,7 +203,7 @@ function OnboardingLoadingGate() {
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>
|
||||
Continue with Block Inc. workspace
|
||||
Continue with default workspace
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -14,6 +14,9 @@ type EphemeralChannelLike = Pick<Channel, "ttlSeconds" | "ttlDeadline">;
|
||||
|
||||
export const EPHEMERAL_CHANNEL_LABEL = "Ephemeral";
|
||||
|
||||
/** Default TTL for ephemeral channels: 7 days of inactivity. */
|
||||
export const DEFAULT_EPHEMERAL_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
export type EphemeralChannelDisplay = {
|
||||
detailLabel: string | null;
|
||||
tooltipLabel: string;
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "@/features/channels/hooks";
|
||||
import { compareMembersByRole } from "@/features/channels/lib/memberUtils";
|
||||
import {
|
||||
DEFAULT_EPHEMERAL_TTL_SECONDS,
|
||||
formatTtlDuration,
|
||||
parseTtlDuration,
|
||||
} from "@/features/channels/lib/ephemeralChannel";
|
||||
@@ -95,8 +96,6 @@ type ChannelManagementSheetProps = {
|
||||
transparentChrome?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_EPHEMERAL_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
export function ChannelManagementSheet({
|
||||
animateSplitEnter = false,
|
||||
channel,
|
||||
@@ -549,7 +548,7 @@ export function ChannelManagementSheet({
|
||||
>
|
||||
{ttlInvalid
|
||||
? "Enter a duration like 1d, 12h, or 30m."
|
||||
: "Defaults to 1d when left empty. Resets the deletion countdown from now whenever changed."}
|
||||
: "Defaults to 7d when left empty. Resets the deletion countdown from now whenever changed."}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ChevronDown, ClockFading, Hash, type LucideIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
|
||||
import { DEFAULT_EPHEMERAL_TTL_SECONDS } from "@/features/channels/lib/ephemeralChannel";
|
||||
import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -12,8 +13,6 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
/** Default TTL for ephemeral channels: 7 days of inactivity. */
|
||||
const EPHEMERAL_TTL_SECONDS = 604800;
|
||||
const CREATE_FIELD_SHELL_CLASS =
|
||||
"rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50";
|
||||
const CREATE_FIELD_CONTROL_CLASS =
|
||||
@@ -125,7 +124,7 @@ export function CreateChannelDialog({
|
||||
name: trimmedName,
|
||||
description: description.trim() || undefined,
|
||||
visibility,
|
||||
ttlSeconds: ephemeral ? EPHEMERAL_TTL_SECONDS : undefined,
|
||||
ttlSeconds: ephemeral ? DEFAULT_EPHEMERAL_TTL_SECONDS : undefined,
|
||||
templateId: selectedTemplateId ?? undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ export function WelcomeSetup({
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Continue with Block Inc. workspace
|
||||
Continue with default workspace
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -595,7 +595,7 @@ test("first-run default workspace handoff gives immediate stepper feedback", asy
|
||||
|
||||
await expect(page.getByText("Welcome to Buzz")).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", { name: "Continue with Block Inc. workspace" })
|
||||
.getByRole("button", { name: "Continue with default workspace" })
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(80);
|
||||
@@ -603,7 +603,7 @@ test("first-run default workspace handoff gives immediate stepper feedback", asy
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Continue with Block Inc. workspace" }),
|
||||
page.getByRole("button", { name: "Continue with default workspace" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("progressbar")).toHaveAttribute(
|
||||
"aria-valuenow",
|
||||
@@ -671,7 +671,7 @@ test("welcome presents custom workspace setup as joining a workspace", async ({
|
||||
await page.goto("/");
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Continue with Block Inc. workspace" }),
|
||||
page.getByRole("button", { name: "Continue with default workspace" }),
|
||||
).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Join a workspace" }).click();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user