Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export default defineConfig({
"**/project-pr-review.spec.ts",
"**/persona-model-combobox-screenshots.spec.ts",
"**/drafts-screenshots.spec.ts",
"**/signout-screenshots.spec.ts",
"**/buzz-theme-screenshots.spec.ts",
"**/channel-sort.spec.ts",
"**/identity-lost.spec.ts",
Expand Down
20 changes: 17 additions & 3 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@ const overrides = new Map([
// mcp-readonly-view rebase: PR2 MCP config surface FE-type fields force +1 over the grandfathered ceiling.
// Git Bash prerequisite payload adds four fields to the shared Tauri API
// contract. This is the canonical type location; split remains queued.
["src/shared/api/types.ts", 1038],
// signout-wipe: resetFailed field added to Identity type (+6 lines).
["src/shared/api/types.ts", 1044],
// readiness-gate: PersonaDialog.tsx threads computeLocalModeGate +
// requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog
// shows required markers and credential amber rows (parity with
Expand Down Expand Up @@ -293,7 +294,7 @@ const overrides = new Map([
// am review fix: also clear stale V1 model field on provider rewrite +
// new model-clear test. Load-bearing chimera fix.
// keyring-dev-isolation: run_boot_migrations wires agent-key migration.
["src-tauri/src/migration.rs", 1415],
["src-tauri/src/migration.rs", 1428],
// 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.
Expand All @@ -317,7 +318,20 @@ const overrides = new Map([
// security fix for the lost-update race that stranded agent keys.
// identity-import-keyring: KeyringLockedScreen, RecoveryScreen,
// load_readonly + load_all_readonly + store_all for safe cross-service reads.
["src-tauri/src/secret_store.rs", 1140],
// sign-out wipe: delete_all() method removes the entire keychain blob under
// the interprocess advisory lock; +8 lines. Load-bearing; queued to split.
// signout-wipe phase 2: delete_all_with_legacy_cleanup replaces delete_all;
// reads blob keys + deletes per-key legacy entries to prevent resurrection.
// + regression test for per-key resurrection via real OS keychain.
// Net growth ~36+32 lines over prior cap. Load-bearing correctness fix.
// signout-wipe pass-2 (F2): delete_all_with_legacy_cleanup DPK deletes now
// observable (propagate real errors); verify_fully_wiped checks all three
// keychain shapes (main blob, DPK blob, per-key "identity"). +73 lines.
["src-tauri/src/secret_store.rs", 1307],
// sign-out wipe: Sign Out section (AlertDialog + controlled state) added
// at the bottom of the Profile settings page. Load-bearing UX feature;
// queued to split when ProfileSettingsCard is broken into sub-components.
["src/features/settings/ui/ProfileSettingsCard.tsx", 1005],
// 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],
Expand Down
2 changes: 1 addition & 1 deletion desktop/scripts/check-px-text.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const rules = [
// glyphs are fixed display sizes sized to their avatar box (not readable
// message text), so they are exempted from the readable-text px rule.
const overrides = new Set([
"src/features/settings/ui/ProfileSettingsCard.tsx:672",
"src/features/settings/ui/ProfileSettingsCard.tsx:684",
"src/features/onboarding/ui/AvatarStep.tsx:95",
"src/features/agents/ui/AgentCreationPreview.tsx:666",
"src/features/agents/ui/AgentCreationPreview.tsx:747",
Expand Down
9 changes: 9 additions & 0 deletions desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ pub struct AppState {
/// `keys` so readers (signing, get_identity, etc.) are not blocked during
/// keyring I/O.
pub identity_mutation: Mutex<()>,
/// Set when the boot-time Phase 2 reset attempted a wipe but verification
/// failed. The sentinel is preserved so the next relaunch retries. All
/// identity-dependent setup is skipped; the frontend shows a reset-failed
/// recovery screen via `get_identity`.
///
/// Ordering: written once in `setup()` with `Ordering::Release`; read in
/// `get_identity` with `Ordering::Acquire`.
pub reset_failed: AtomicBool,
/// Cached ACP session config from running agents, keyed by agent pubkey.
/// Populated when the harness emits `session_config_captured` observer events.
pub session_config_cache: Mutex<HashMap<String, SessionConfigCache>>,
Expand Down Expand Up @@ -142,6 +150,7 @@ pub fn build_app_state() -> AppState {
)),
keyring_locked: AtomicBool::new(false),
identity_lost: AtomicBool::new(false),
reset_failed: AtomicBool::new(false),
#[cfg(feature = "mesh-llm")]
mesh_llm_runtime: AsyncMutex::new(None),
#[cfg(feature = "mesh-llm")]
Expand Down
45 changes: 45 additions & 0 deletions desktop/src-tauri/src/commands/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,16 @@ pub fn get_identity(state: State<'_, AppState>) -> Result<IdentityInfo, String>
let locked = state
.keyring_locked
.load(std::sync::atomic::Ordering::Acquire);
let reset_failed = state
.reset_failed
.load(std::sync::atomic::Ordering::Acquire);

Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
lost,
locked,
reset_failed,
})
}

Expand Down Expand Up @@ -220,6 +224,7 @@ pub async fn import_identity(
display_name,
lost: false,
locked: false,
reset_failed: false,
})
})
.await
Expand Down Expand Up @@ -287,12 +292,52 @@ pub async fn persist_current_identity(
display_name,
lost: false,
locked: false,
reset_failed: false,
})
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}

/// Write a reset-intent sentinel and relaunch into Phase 2 (boot-time wipe).
///
/// The actual data destruction is deferred to the next boot: `setup()` in
/// `lib.rs` checks for the sentinel and performs the wipe before any migration
/// or identity resolution. This two-phase design means a crash between now and
/// the relaunch is safe — the sentinel persists and the wipe completes on the
/// next open.
///
/// Not available in shared-identity mode (`BUZZ_SHARE_IDENTITY=1`): the key
/// comes from an env var, not the keychain, so wiping would have no effect and
/// would be confusing.
#[tauri::command]
pub async fn sign_out(app: tauri::AppHandle) -> Result<(), String> {
if is_shared_identity() {
return Err("sign-out is not available in shared-identity mode".to_string());
}

// Stop all managed agents before restart so they don't race the wipe.
if let Err(e) = crate::shutdown::shutdown_managed_agents(&app) {
eprintln!("buzz-desktop sign-out: agent shutdown: {e}");
}

// Write the reset sentinel — destruction happens on next boot.
let data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("app data dir: {e}"))?;
crate::reset::write_sentinel(&data_dir)?;

// Attempt relaunch — if it fails, the sentinel is already written,
// so a manual reopen completes the reset.
let exe = std::env::current_exe().map_err(|e| format!("cannot find executable: {e}"))?;
std::process::Command::new(&exe)
.spawn()
.map_err(|e| format!("relaunch failed: {e}"))?;

std::process::exit(0);
}

fn nostr_bind_tag(name: &str, value: &str) -> Result<Tag, String> {
Tag::parse(vec![name, value]).map_err(|error| format!("{name} tag failed: {error}"))
}
Expand Down
45 changes: 42 additions & 3 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub mod nostr_convert;
mod prevent_sleep;
mod ptt_shortcut;
mod relay;
mod reset;
mod secret_store;
mod shutdown;
mod templates;
Expand Down Expand Up @@ -420,8 +421,41 @@ pub fn run() {
let app_handle = app.handle().clone();
let shutdown_started = Arc::clone(&restore_shutdown_started);

// ── Phase 2: boot-time sentinel wipe ──────────────────────────────
// Must run before migrations and identity resolution so the wipe
// completes atomically on crash recovery.
//
// init_nest_dir is called early here (normally it runs inside
// run_boot_migrations) so reset::run_boot_reset can call nest_dir().
let reset_outcome = if let Ok(data_dir) = app_handle.path().app_data_dir() {
let is_dev_for_reset = data_dir
.file_name()
.and_then(|n| n.to_str())
.map(crate::migration::is_dev_data_dir_name)
.unwrap_or(false);
crate::managed_agents::init_nest_dir(is_dev_for_reset);
crate::reset::run_boot_reset(&data_dir)
} else {
crate::reset::ResetOutcome::default()
};

if reset_outcome.failed {
// Surface reset-failed state — skip identity resolution and
// all side-effecting setup. The webview still loads so the
// frontend can show the recovery screen via get_identity.
let state = app_handle.state::<AppState>();
state
.reset_failed
.store(true, std::sync::atomic::Ordering::Release);
return Ok(());
}

// Run all pre-identity data migrations before state loads from disk.
migration::run_boot_migrations(&app_handle);
if reset_outcome.completed {
migration::run_boot_migrations_after_reset(&app_handle);
} else {
migration::run_boot_migrations(&app_handle);
}

// Resolve persisted identity key (env var → file → generate+save).
// This is fatal — the app should not start with an ephemeral identity
Expand Down Expand Up @@ -522,18 +556,22 @@ pub fn run() {
// destination is present. Non-fatal.
// On a real migration, emit a one-time hint so the user can delete
// the now-inert ~/.sprout; the frontend dedupes the toast.
if migration::migrate_legacy_nest() {
// Suppressed when a reset completed this boot: the nest was wiped and
// a fresh ~/.sprout-less state is exactly what we want.
if !reset_outcome.completed && migration::migrate_legacy_nest() {
let _ = app_handle.emit("legacy-nest-migrated", ());
}

// One-time migration for dev builds: copy accumulated knowledge
// from the shared ~/.buzz nest into the new dedicated ~/.buzz-dev
// nest so no work is lost when the nest is first namespaced.
// Runs only when nest_dir() resolved to ~/.buzz-dev (dev instance).
// Suppressed after a reset so re-importing ~/.buzz into ~/.buzz-dev
// doesn't re-populate what was just wiped.
let is_dev_nest = managed_agents::nest_dir()
.and_then(|p| p.file_name().map(|n| n.to_os_string()))
.is_some_and(|n| n == ".buzz-dev");
if is_dev_nest {
if !reset_outcome.completed && is_dev_nest {
migration::migrate_dev_nest();
}

Expand Down Expand Up @@ -702,6 +740,7 @@ pub fn run() {
discover_managed_agent_prereqs,
sign_event,
sign_nostr_identity_binding,
sign_out,
decrypt_observer_event,
build_observer_control_event,
create_auth_event,
Expand Down
24 changes: 20 additions & 4 deletions desktop/src-tauri/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const SHARED_AGENT_DIRS: &[&str] = &["agents/teams"];
/// `xyz.block.buzz.app.developer`. This is the authoritative dev/prod
/// discriminator shared by `run_boot_migrations`, `sync_shared_agent_data`,
/// and `reconcile_target_dir`.
fn is_dev_data_dir_name(name: &str) -> bool {
pub(crate) fn is_dev_data_dir_name(name: &str) -> bool {
name == CANONICAL_DEV_IDENTIFIER
|| name
.strip_prefix(CANONICAL_DEV_IDENTIFIER)
Expand All @@ -55,7 +55,7 @@ fn canonical_dev_data_dir(current: &Path) -> Option<PathBuf> {
current.parent().map(|p| p.join(CANONICAL_DEV_IDENTIFIER))
}

fn legacy_app_data_dir(current: &Path) -> Option<PathBuf> {
pub(crate) fn legacy_app_data_dir(current: &Path) -> Option<PathBuf> {
let name = current.file_name()?.to_str()?;
let legacy_name = if name.starts_with(CANONICAL_DEV_IDENTIFIER) {
name.replacen(CANONICAL_DEV_IDENTIFIER, LEGACY_CANONICAL_DEV_IDENTIFIER, 1)
Expand Down Expand Up @@ -119,6 +119,15 @@ fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
/// readers — reader-first loses a launch (stale harness/`mcp_command` until
/// the next boot).
pub fn run_boot_migrations(app: &tauri::AppHandle) {
run_boot_migrations_inner(app, false);
}

/// Entry point when a completed reset must suppress dev-nest re-import.
pub fn run_boot_migrations_after_reset(app: &tauri::AppHandle) {
run_boot_migrations_inner(app, true);
}

fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) {
// Initialize the process-lifetime nest directory before any filesystem
// operation that calls nest_dir(). The discriminator matches the existing
// pattern used by reconcile_target_dir: dev instances have an app-data-dir
Expand All @@ -139,7 +148,7 @@ pub fn run_boot_migrations(app: &tauri::AppHandle) {
// ensures the dev nest boots with the correct workspace on its first launch,
// matching what the prod nest had configured. Skip-if-dest-exists so it is
// idempotent and never clobbers a value the dev nest already set explicitly.
if is_dev {
if should_migrate_dev_repos_dir(is_dev, reset_completed) {
migrate_dev_repos_dir();
}

Expand Down Expand Up @@ -318,13 +327,20 @@ fn migrate_legacy_nest_at(legacy: &Path, current: &Path) -> bool {
/// also copies into `~/.buzz-dev` and could otherwise set the sentinel early.
const DEV_NEST_MIGRATED_SENTINEL: &str = ".dev-nest-migrated";

/// Returns true when `migrate_dev_repos_dir` should run: dev build AND no
/// completed reset this boot (a completed reset means the dev nest was just
/// wiped — re-importing from prod would undo the sign-out).
pub(crate) fn should_migrate_dev_repos_dir(is_dev: bool, reset_completed: bool) -> bool {
is_dev && !reset_completed
}

/// Copy the `.repos-dir` dotfile from `~/.buzz` → `~/.buzz-dev`, non-destructively.
///
/// Must be called on dev builds BEFORE `resolve_repos_at_boot()` reads the
/// dotfile, so the dev nest boots with the correct workspace configuration on
/// its first launch. Skip-if-dest-exists so it is idempotent and never
/// overwrites a value set directly by the dev nest.
fn migrate_dev_repos_dir() {
pub(crate) fn migrate_dev_repos_dir() {
let Some(home) = dirs::home_dir() else {
return;
};
Expand Down
5 changes: 5 additions & 0 deletions desktop/src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ pub struct IdentityInfo {
/// shows a "unlock the keyring and relaunch" screen. Mutually exclusive
/// with `lost`.
pub locked: bool,
/// True when the boot-time Phase 2 reset attempted a wipe but verification
/// failed. Identity resolution was skipped; the frontend shows a
/// reset-failed recovery screen. The sentinel is preserved so the next
/// relaunch retries the wipe automatically.
pub reset_failed: bool,
}

#[derive(Serialize, Deserialize)]
Expand Down
Loading
Loading