feat(desktop): add Sign Out to Settings to reset and relaunch (#1842)

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:
Will Pfleger
2026-07-14 16:52:57 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 2d7ed3267a
commit cf97237965
19 changed files with 1445 additions and 41 deletions
+1
View File
@@ -90,6 +90,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",
+19 -3
View File
@@ -207,7 +207,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
@@ -307,7 +308,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", 1436],
// 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.
@@ -331,7 +332,22 @@ 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.
// +20 lines: scroll-position save/restore across avatar editor open/close
// to prevent layout shift from the Sign Out section causing a viewport jump.
["src/features/settings/ui/ProfileSettingsCard.tsx", 1033],
// 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],
+1 -1
View File
@@ -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:712",
"src/features/onboarding/ui/AvatarStep.tsx:95",
"src/features/agents/ui/AgentCreationPreview.tsx:666",
"src/features/agents/ui/AgentCreationPreview.tsx:747",
+9
View File
@@ -77,6 +77,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>>,
@@ -169,6 +177,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")]
@@ -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,
})
}
@@ -220,6 +224,7 @@ pub async fn import_identity(
display_name,
lost: false,
locked: false,
reset_failed: false,
})
})
.await
@@ -287,12 +292,53 @@ 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 request a graceful restart 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 before the
/// restart 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 isn't available while BUZZ_SHARE_IDENTITY provides your identity. Unset BUZZ_SHARE_IDENTITY and BUZZ_PRIVATE_KEY, then relaunch to sign out."
.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)?;
// Tauri restarts only after normal shutdown, avoiding a single-instance
// race. If restarting does not complete, the sentinel makes a manual open
// finish the reset.
app.request_restart();
Ok(())
}
fn nostr_bind_tag(name: &str, value: &str) -> Result<Tag, String> {
Tag::parse(vec![name, value]).map_err(|error| format!("{name} tag failed: {error}"))
}
+42 -3
View File
@@ -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;
@@ -415,8 +416,41 @@ pub fn run() {
.setup(move |app| {
let app_handle = app.handle().clone();
// ── 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
@@ -517,7 +551,9 @@ 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", ());
}
@@ -525,10 +561,12 @@ pub fn run() {
// 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();
}
@@ -691,6 +729,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,
+42 -18
View File
@@ -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)
@@ -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)
@@ -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
@@ -139,8 +148,10 @@ 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 {
migrate_dev_repos_dir();
// Uses the composed helper so the gate + migration run through the same
// code path that the behavioral test exercises.
if let (Some(home), Some(dev_nest)) = (dirs::home_dir(), crate::managed_agents::nest_dir()) {
maybe_migrate_dev_repos_dir(is_dev, reset_completed, &home, &dev_nest);
}
migrate_legacy_app_data_dir(app);
@@ -318,23 +329,21 @@ 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";
/// 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() {
let Some(home) = dirs::home_dir() else {
return;
};
/// 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
}
/// Injectable core: copy `.repos-dir` from `<home>/.buzz/` into `dev_nest`,
/// non-destructively. Extracted so tests can inject temp paths without
/// touching `dirs::home_dir()` or the global `nest_dir()` OnceLock.
pub(crate) fn migrate_dev_repos_dir_at(home: &Path, dev_nest: &Path) {
let src = home.join(".buzz").join(".repos-dir");
if !src.exists() {
return;
}
let Some(dev_nest) = crate::managed_agents::nest_dir() else {
return;
};
let dst = dev_nest.join(".repos-dir");
// Skip if the dev nest already has its own .repos-dir.
if dst.exists() {
@@ -342,7 +351,7 @@ fn migrate_dev_repos_dir() {
}
// Ensure the dev nest directory itself exists — this migration runs before
// ensure_nest() in the boot sequence, so the directory may not yet exist.
if let Err(e) = std::fs::create_dir_all(&dev_nest) {
if let Err(e) = std::fs::create_dir_all(dev_nest) {
eprintln!(
"buzz-desktop: dev-nest-migration: failed to create dev nest {}: {e}",
dev_nest.display()
@@ -358,6 +367,21 @@ fn migrate_dev_repos_dir() {
}
}
/// Composed gate + migration: applies `should_migrate_dev_repos_dir` and, if
/// the gate passes, runs the injectable migration core. This is the seam that
/// `run_boot_migrations_inner` calls with real paths — a future mis-wired flag
/// or inverted gate breaks tests at this level, not just the predicate.
pub(crate) fn maybe_migrate_dev_repos_dir(
is_dev: bool,
reset_completed: bool,
home: &Path,
dev_nest: &Path,
) {
if should_migrate_dev_repos_dir(is_dev, reset_completed) {
migrate_dev_repos_dir_at(home, dev_nest);
}
}
/// One-time migration of dev-build nest contents from `~/.buzz` → `~/.buzz-dev`.
///
/// When a dev build first boots after this change ships, it switches from the
+5
View File
@@ -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)]
+847
View File
@@ -0,0 +1,847 @@
//! Two-phase boot-time sentinel wipe.
//!
//! **Phase 1** (`write_sentinel`) — called by `sign_out`: writes a durable
//! reset-intent file outside every path that will be wiped.
//!
//! **Phase 2** (`run_boot_reset`) — called at the very top of `setup()` in
//! `lib.rs`, before migrations and identity resolution: if the sentinel is
//! present the wipe runs atomically and the app falls through into clean
//! onboarding.
//!
//! The sentinel lives at `<app_data_dir's parent>/.<bundle_id>.reset-pending`
//! and survives the app-data wipe because the wipe targets the exact
//! app-data dir, not its parent.
//!
//! Idempotency: if the process crashes mid-wipe the sentinel is still present
//! on next boot and the wipe retries from the top.
use std::path::{Path, PathBuf};
// ── Sentinel helpers ──────────────────────────────────────────────────────────
/// Sentinel path: `<app_data_dir.parent>/.<bundle_id>.reset-pending`
/// where `bundle_id` is the file-name component of `app_data_dir`
/// (e.g. `xyz.block.buzz.app` or `xyz.block.buzz.app.dev`).
pub(crate) fn sentinel_path(app_data_dir: &Path) -> PathBuf {
let bundle_id = app_data_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("buzz");
let name = format!(".{bundle_id}.reset-pending");
match app_data_dir.parent() {
Some(parent) => parent.join(name),
None => PathBuf::from(&name),
}
}
/// Atomically write the sentinel file. Content is intentionally empty —
/// existence is the signal.
pub(crate) fn write_sentinel(app_data_dir: &Path) -> Result<(), String> {
let path = sentinel_path(app_data_dir);
std::fs::write(&path, b"").map_err(|e| format!("write sentinel {}: {e}", path.display()))
}
/// Return `true` when the sentinel file exists.
pub(crate) fn check_sentinel(app_data_dir: &Path) -> bool {
sentinel_path(app_data_dir).exists()
}
/// Remove the sentinel file. A missing file is not an error.
pub(crate) fn delete_sentinel(app_data_dir: &Path) -> Result<(), String> {
let path = sentinel_path(app_data_dir);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("delete sentinel {}: {e}", path.display())),
}
}
// ── Keychain abstraction (enables unit testing) ───────────────────────────────
/// Keychain operations needed by the boot-time reset.
/// Implemented for `SecretStore`; a fake is used in tests.
pub(crate) trait ResetKeychain {
/// Delete the blob + all per-key legacy entries.
fn delete_all_with_legacy(&self) -> Result<(), String>;
/// Return `true` when all keychain shapes (main blob, DPK blob, per-key
/// entries) that `migrate_legacy_key` can consume are absent.
fn verify_fully_wiped(&self) -> bool;
}
impl ResetKeychain for crate::secret_store::SecretStore {
fn delete_all_with_legacy(&self) -> Result<(), String> {
self.delete_all_with_legacy_cleanup()
}
fn verify_fully_wiped(&self) -> bool {
self.verify_fully_wiped()
}
}
// ── Result type ───────────────────────────────────────────────────────────────
/// Outcome of the boot-time reset check.
#[derive(Debug, Default)]
pub(crate) struct ResetOutcome {
/// Wipe completed successfully this boot — suppress nest migrations.
pub completed: bool,
/// Wipe was attempted but verification failed — surface error state.
pub failed: bool,
}
// ── Boot-time reset ───────────────────────────────────────────────────────────
/// Wipe parameters assembled by `lib.rs` and passed into `run_boot_reset_with_keychain`.
pub(crate) struct ResetContext<'a> {
pub app_data_dir: &'a Path,
/// Legacy App Support dir for this build (Sprout import source). When
/// present and non-empty, wiped alongside `app_data_dir` to prevent
/// `migrate_legacy_app_data_dir` from restoring the old identity.
pub legacy_app_data_dir: Option<PathBuf>,
/// Nest dir (`~/.buzz` or `~/.buzz-dev`) scoped to this build's variant,
/// injected so unit tests can override without touching the global OnceLock.
pub nest_dir: Option<PathBuf>,
pub keychain: &'a dyn ResetKeychain,
pub home_dir: Option<PathBuf>,
pub is_dev: bool,
}
/// Entry point called from `lib.rs` setup (before migrations).
///
/// Constructs a `SecretStore` for the running build's keyring service and
/// delegates to `run_boot_reset_with_keychain` for testable wipe logic.
pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome {
if !check_sentinel(app_data_dir) {
return ResetOutcome::default();
}
let is_dev = app_data_dir
.file_name()
.and_then(|n| n.to_str())
.map(crate::migration::is_dev_data_dir_name)
.unwrap_or(false);
let store = crate::secret_store::SecretStore::keyring(crate::app_state::keyring_service());
let home_dir = dirs::home_dir();
let legacy_dir = crate::migration::legacy_app_data_dir(app_data_dir);
let nest_dir = crate::managed_agents::nest_dir();
let ctx = ResetContext {
app_data_dir,
legacy_app_data_dir: legacy_dir,
nest_dir,
keychain: &store,
home_dir,
is_dev,
};
run_boot_reset_with_keychain(ctx)
}
/// Deterministic trash path: `<original>.reset-trash`. Unlike PID-based names,
/// any boot can discover and clean trash from a prior crashed attempt.
fn trash_path(original: &Path) -> PathBuf {
original.with_file_name(format!(
"{}.reset-trash",
original
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("buzz")
))
}
/// Remove an existing reset-trash directory if present (from a prior crashed
/// attempt), then rename `src` into the deterministic trash path. Returns
/// `Ok(trash_path)` on success.
fn rename_to_trash(src: &Path) -> Result<PathBuf, String> {
let dst = trash_path(src);
// Clear prior trash before renaming so a collision doesn't fail the rename.
if dst.exists() {
let _ = std::fs::remove_dir_all(&dst);
}
std::fs::rename(src, &dst)
.map_err(|e| format!("rename {}{}: {e}", src.display(), dst.display()))?;
Ok(dst)
}
/// Core wipe logic — separated for testing.
pub(crate) fn run_boot_reset_with_keychain(ctx: ResetContext<'_>) -> ResetOutcome {
let app_data_dir = ctx.app_data_dir;
// ── Step 1: rename app-data dir (atomic — sentinel survives the parent) ──
let trash_app = trash_path(app_data_dir);
if app_data_dir.exists() {
if let Err(e) = rename_to_trash(app_data_dir) {
eprintln!("buzz-desktop reset: {e}");
return ResetOutcome {
completed: false,
failed: true,
};
}
}
// ── Step 1b: rename legacy App Support dir (sprout import source) ────────
let trash_legacy: Option<PathBuf> = ctx.legacy_app_data_dir.as_ref().map(|l| trash_path(l));
if let Some(ref legacy) = ctx.legacy_app_data_dir {
if legacy.exists() {
if let Err(e) = rename_to_trash(legacy) {
eprintln!("buzz-desktop reset: {e}");
// Non-fatal for legacy dir — continue
}
}
}
// ── Step 2: rename WebKit dir for this build ──────────────────────────────
let trash_webkit: Option<PathBuf> = if let Some(ref home) = ctx.home_dir {
let bundle_id = app_data_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("buzz");
let webkit_dir = home.join("Library").join("WebKit").join(bundle_id);
let tw = trash_path(&webkit_dir);
if webkit_dir.exists() {
if let Err(e) = rename_to_trash(&webkit_dir) {
eprintln!("buzz-desktop reset: {e}");
// Non-fatal — continue
}
}
Some(tw)
} else {
None
};
// ── Step 3: remove nest, ~/.sprout, ~/.config/buzz-agent, CLI symlink ────
if let Some(ref nest) = ctx.nest_dir {
let _ = std::fs::remove_dir_all(nest);
}
if let Some(ref home) = ctx.home_dir {
let _ = std::fs::remove_dir_all(home.join(".sprout"));
let _ = std::fs::remove_dir_all(home.join(".config").join("buzz-agent"));
let link_name = crate::managed_agents::cli_link_name(ctx.is_dev);
let _ = std::fs::remove_file(home.join(".local").join("bin").join(link_name));
}
// ── Step 4: keychain — LAST so we can read keys before deleting ──────────
if let Err(e) = ctx.keychain.delete_all_with_legacy() {
eprintln!("buzz-desktop reset: keychain delete: {e}");
// Keychain failure is fatal: keep sentinel, signal failure.
// Restore all three dirs so the app returns to a coherent pre-reset state.
if trash_app.exists() {
let _ = std::fs::rename(&trash_app, app_data_dir);
}
if let Some(ref legacy) = ctx.legacy_app_data_dir {
if let Some(ref tl) = trash_legacy {
if tl.exists() {
let _ = std::fs::rename(tl, legacy);
}
}
}
if let Some(ref home) = ctx.home_dir {
let bundle_id = app_data_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("buzz");
let webkit_dir = home.join("Library").join("WebKit").join(bundle_id);
if let Some(ref tw) = trash_webkit {
if tw.exists() {
let _ = std::fs::rename(tw, &webkit_dir);
}
}
}
return ResetOutcome {
completed: false,
failed: true,
};
}
// ── Step 5: sweep ALL reset trash (including from prior crashed boots) ───
let _ = std::fs::remove_dir_all(&trash_app);
if let Some(ref tl) = trash_legacy {
let _ = std::fs::remove_dir_all(tl);
}
if let Some(ref tw) = trash_webkit {
let _ = std::fs::remove_dir_all(tw);
}
// ── Step 6: verify ────────────────────────────────────────────────────────
let keychain_ok = ctx.keychain.verify_fully_wiped();
let app_data_gone = !app_data_dir.exists();
let legacy_gone = ctx
.legacy_app_data_dir
.as_ref()
.map(|p| !p.exists())
.unwrap_or(true);
let nest_gone = ctx.nest_dir.as_ref().map(|n| !n.exists()).unwrap_or(true);
let trash_app_gone = !trash_app.exists();
let trash_legacy_gone = trash_legacy.as_ref().map(|p| !p.exists()).unwrap_or(true);
let trash_webkit_gone = trash_webkit.as_ref().map(|p| !p.exists()).unwrap_or(true);
if !keychain_ok
|| !app_data_gone
|| !legacy_gone
|| !nest_gone
|| !trash_app_gone
|| !trash_legacy_gone
|| !trash_webkit_gone
{
eprintln!(
"buzz-desktop reset: verification failed (keychain_wiped={keychain_ok}, \
app_data_gone={app_data_gone}, legacy_gone={legacy_gone}, nest_gone={nest_gone}, \
trash_app_gone={trash_app_gone}, trash_legacy_gone={trash_legacy_gone}, \
trash_webkit_gone={trash_webkit_gone})"
);
return ResetOutcome {
completed: false,
failed: true,
};
}
// ── Step 7: delete sentinel → success ────────────────────────────────────
if let Err(e) = delete_sentinel(app_data_dir) {
eprintln!("buzz-desktop reset: delete sentinel: {e}");
// Sentinel not deleted — keep failed=false so the app boots into
// onboarding, but on next boot the reset will retry (idempotent).
}
ResetOutcome {
completed: true,
failed: false,
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use tempfile::TempDir;
// ── Fake keychain ─────────────────────────────────────────────────────────
struct FakeKeychain {
delete_result: Result<(), String>,
/// Tracks number of times delete was called.
delete_calls: Cell<u32>,
/// Whether `verify_fully_wiped` returns true after a successful delete.
wiped_after_delete: bool,
/// When true, verify_fully_wiped always returns false regardless of
/// delete outcome — simulates a transient/unknown keychain error
/// during verification that cannot confirm absence.
verify_always_fails: bool,
}
impl FakeKeychain {
fn ok() -> Self {
FakeKeychain {
delete_result: Ok(()),
delete_calls: Cell::new(0),
wiped_after_delete: true,
verify_always_fails: false,
}
}
fn fail(msg: &str) -> Self {
FakeKeychain {
delete_result: Err(msg.to_string()),
delete_calls: Cell::new(0),
wiped_after_delete: false,
verify_always_fails: false,
}
}
fn ok_but_not_wiped() -> Self {
FakeKeychain {
delete_result: Ok(()),
delete_calls: Cell::new(0),
wiped_after_delete: false,
verify_always_fails: false,
}
}
/// Delete succeeds but verify returns false — simulates a transient
/// unknown error during verification (e.g. keyring constructor failure
/// or unclassified read error that cannot confirm absence).
fn ok_but_verify_fails() -> Self {
FakeKeychain {
delete_result: Ok(()),
delete_calls: Cell::new(0),
wiped_after_delete: true,
verify_always_fails: true,
}
}
}
impl ResetKeychain for FakeKeychain {
fn delete_all_with_legacy(&self) -> Result<(), String> {
self.delete_calls.set(self.delete_calls.get() + 1);
self.delete_result.clone()
}
fn verify_fully_wiped(&self) -> bool {
if self.verify_always_fails {
return false;
}
self.wiped_after_delete && self.delete_calls.get() > 0
}
}
fn make_app_data(tmp: &TempDir) -> PathBuf {
let dir = tmp
.path()
.join("Application Support")
.join("xyz.block.buzz.app");
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn make_ctx<'a>(
app_data_dir: &'a Path,
keychain: &'a dyn ResetKeychain,
is_dev: bool,
) -> ResetContext<'a> {
ResetContext {
app_data_dir,
legacy_app_data_dir: None,
nest_dir: None,
keychain,
home_dir: None, // skip nest/sprout/CLI ops in unit tests
is_dev,
}
}
// ── Test 1: no sentinel ───────────────────────────────────────────────────
#[test]
fn test_no_sentinel_returns_no_op() {
let tmp = TempDir::new().unwrap();
let app_data = make_app_data(&tmp);
let kc = FakeKeychain::ok();
let outcome = run_boot_reset(app_data.as_path());
assert!(!outcome.completed, "no sentinel → not completed");
assert!(!outcome.failed, "no sentinel → not failed");
assert_eq!(kc.delete_calls.get(), 0, "keychain not touched");
assert!(app_data.exists(), "app-data dir untouched");
}
// ── Test 2: full wipe succeeds ────────────────────────────────────────────
#[test]
fn test_sentinel_present_full_wipe_succeeds() {
let tmp = TempDir::new().unwrap();
let app_data = make_app_data(&tmp);
// Also create a legacy App Support dir (sprout import source).
let legacy_dir = tmp
.path()
.join("Application Support")
.join("xyz.block.sprout.app");
std::fs::create_dir_all(&legacy_dir).unwrap();
std::fs::write(legacy_dir.join("identity.key"), b"old-identity").unwrap();
write_sentinel(&app_data).unwrap();
let kc = FakeKeychain::ok();
let ctx = ResetContext {
app_data_dir: &app_data,
legacy_app_data_dir: Some(legacy_dir.clone()),
nest_dir: None,
keychain: &kc,
home_dir: None,
is_dev: false,
};
let outcome = run_boot_reset_with_keychain(ctx);
assert!(outcome.completed, "should complete");
assert!(!outcome.failed, "should not fail");
assert!(!app_data.exists(), "app-data must be gone");
assert!(!legacy_dir.exists(), "legacy app-data must be gone");
assert!(!sentinel_path(&app_data).exists(), "sentinel must be gone");
assert_eq!(kc.delete_calls.get(), 1, "keychain deleted once");
}
// ── Test 3: keychain failure keeps sentinel ────────────────────────────────
#[test]
fn test_sentinel_present_keychain_failure_keeps_sentinel() {
let tmp = TempDir::new().unwrap();
let app_data = make_app_data(&tmp);
write_sentinel(&app_data).unwrap();
let kc = FakeKeychain::fail("keychain unavailable");
let ctx = make_ctx(&app_data, &kc, false);
let outcome = run_boot_reset_with_keychain(ctx);
assert!(!outcome.completed);
assert!(outcome.failed);
assert!(
sentinel_path(&app_data).exists(),
"sentinel must be preserved on failure"
);
}
// ── Test 4: app-data rename works but verify fails ────────────────────────
#[test]
fn test_sentinel_present_verify_failure_keeps_sentinel() {
let tmp = TempDir::new().unwrap();
let app_data = make_app_data(&tmp);
write_sentinel(&app_data).unwrap();
// Keychain delete "succeeds" but verify_fully_wiped still returns false.
let kc = FakeKeychain::ok_but_not_wiped();
let ctx = make_ctx(&app_data, &kc, false);
let outcome = run_boot_reset_with_keychain(ctx);
assert!(!outcome.completed);
assert!(outcome.failed);
assert!(sentinel_path(&app_data).exists(), "sentinel preserved");
}
// ── Test 5: crash-then-retry completes ───────────────────────────────────
#[test]
fn test_crash_then_retry_completes() {
let tmp = TempDir::new().unwrap();
let app_data = make_app_data(&tmp);
write_sentinel(&app_data).unwrap();
// First run — keychain fails (simulates a crash mid-wipe).
let kc1 = FakeKeychain::fail("transient error");
let ctx1 = make_ctx(&app_data, &kc1, false);
let first = run_boot_reset_with_keychain(ctx1);
assert!(first.failed);
assert!(
sentinel_path(&app_data).exists(),
"sentinel must survive first attempt"
);
// Second run — keychain succeeds. App-data dir was renamed but we need
// to recreate it for the test (the wipe tried to rename it).
// In production the dir would already be gone; here it was renamed to
// .trash-<pid> and then not cleaned up (keychain failed before cleanup).
// Create a fresh app-data dir to simulate a reboot where app recreated it.
std::fs::create_dir_all(&app_data).unwrap();
let kc2 = FakeKeychain::ok();
let ctx2 = make_ctx(&app_data, &kc2, false);
let second = run_boot_reset_with_keychain(ctx2);
assert!(second.completed, "second attempt must complete");
assert!(!second.failed);
assert!(
!sentinel_path(&app_data).exists(),
"sentinel cleared on success"
);
}
// ── Test 6: dev build wipes dev nest, leaves prod nest intact ────────────
#[test]
fn test_dev_build_wipes_dev_nest_not_prod() {
let tmp = TempDir::new().unwrap();
// Create both nests.
let dev_nest = tmp.path().join(".buzz-dev");
let prod_nest = tmp.path().join(".buzz");
std::fs::create_dir_all(&dev_nest).unwrap();
std::fs::create_dir_all(&prod_nest).unwrap();
let app_data = tmp
.path()
.join("Application Support")
.join("xyz.block.buzz.app.dev");
std::fs::create_dir_all(&app_data).unwrap();
write_sentinel(&app_data).unwrap();
let kc = FakeKeychain::ok();
let ctx = ResetContext {
app_data_dir: &app_data,
legacy_app_data_dir: None,
nest_dir: Some(dev_nest.clone()),
keychain: &kc,
home_dir: None,
is_dev: true,
};
let outcome = run_boot_reset_with_keychain(ctx);
assert!(outcome.completed, "wipe must complete");
assert!(!dev_nest.exists(), "dev nest must be wiped");
assert!(prod_nest.exists(), "prod nest must survive");
}
// ── Test 7: prod build wipes prod nest, leaves dev nest intact ───────────
#[test]
fn test_prod_build_wipes_prod_nest_not_dev() {
let tmp = TempDir::new().unwrap();
// Create both nests.
let dev_nest = tmp.path().join(".buzz-dev");
let prod_nest = tmp.path().join(".buzz");
std::fs::create_dir_all(&dev_nest).unwrap();
std::fs::create_dir_all(&prod_nest).unwrap();
let app_data = tmp
.path()
.join("Application Support")
.join("xyz.block.buzz.app");
std::fs::create_dir_all(&app_data).unwrap();
write_sentinel(&app_data).unwrap();
let kc = FakeKeychain::ok();
let ctx = ResetContext {
app_data_dir: &app_data,
legacy_app_data_dir: None,
nest_dir: Some(prod_nest.clone()),
keychain: &kc,
home_dir: None,
is_dev: false,
};
let outcome = run_boot_reset_with_keychain(ctx);
assert!(outcome.completed, "wipe must complete");
assert!(!prod_nest.exists(), "prod nest must be wiped");
assert!(dev_nest.exists(), "dev nest must survive");
}
// ── Test 8: legacy app-data removed on reset ──────────────────────────────
#[test]
fn test_legacy_app_data_removed_on_reset() {
let tmp = TempDir::new().unwrap();
let app_data = make_app_data(&tmp);
// Seed a legacy sprout dir with data that would be re-imported.
let legacy_dir = tmp
.path()
.join("Application Support")
.join("xyz.block.sprout.app");
std::fs::create_dir_all(legacy_dir.join("agents")).unwrap();
std::fs::write(legacy_dir.join("identity.key"), b"sprout-nsec").unwrap();
write_sentinel(&app_data).unwrap();
let kc = FakeKeychain::ok();
let ctx = ResetContext {
app_data_dir: &app_data,
legacy_app_data_dir: Some(legacy_dir.clone()),
nest_dir: None,
keychain: &kc,
home_dir: None,
is_dev: false,
};
let outcome = run_boot_reset_with_keychain(ctx);
assert!(outcome.completed, "reset must complete");
assert!(!legacy_dir.exists(), "legacy app-data dir must be removed");
}
// ── Test 9: unknown error during delete → failed, sentinel kept ────────
#[test]
fn test_unknown_delete_error_keeps_sentinel() {
let tmp = TempDir::new().unwrap();
let app_data = make_app_data(&tmp);
write_sentinel(&app_data).unwrap();
// Simulates an unclassified/transient keychain error during delete.
let kc = FakeKeychain::fail("unknown transient keychain error");
let ctx = make_ctx(&app_data, &kc, false);
let outcome = run_boot_reset_with_keychain(ctx);
assert!(outcome.failed, "unknown delete error must fail");
assert!(!outcome.completed, "must not complete on delete error");
assert!(
sentinel_path(&app_data).exists(),
"sentinel must survive unknown delete error"
);
}
// ── Test 10: unknown error during verify → failed, sentinel kept ─────
#[test]
fn test_unknown_verify_error_keeps_sentinel() {
let tmp = TempDir::new().unwrap();
let app_data = make_app_data(&tmp);
write_sentinel(&app_data).unwrap();
// Delete succeeds but verification fails — simulates a transient
// keychain error (e.g. constructor failure, unclassified read error)
// that cannot confirm absence. The sentinel must survive.
let kc = FakeKeychain::ok_but_verify_fails();
let ctx = make_ctx(&app_data, &kc, false);
let outcome = run_boot_reset_with_keychain(ctx);
assert!(outcome.failed, "unknown verify error must fail");
assert!(!outcome.completed, "must not complete on verify error");
assert!(
sentinel_path(&app_data).exists(),
"sentinel must survive unknown verify error"
);
}
// ── Test 11: completed dev reset suppresses dev repos-dir migration ────
// Behavioral test at the composed seam: exercises real filesystem effects
// through maybe_migrate_dev_repos_dir, not just the boolean predicate.
#[test]
fn test_completed_dev_reset_suppresses_dev_repos_dir_migration() {
let tmp = TempDir::new().unwrap();
let app_data = tmp
.path()
.join("Application Support")
.join("xyz.block.buzz.app.dev");
std::fs::create_dir_all(&app_data).unwrap();
write_sentinel(&app_data).unwrap();
// Seed prod ~/.buzz/.repos-dir so the migration has something to copy.
let home = tmp.path().join("home");
let prod_nest = home.join(".buzz");
std::fs::create_dir_all(&prod_nest).unwrap();
std::fs::write(prod_nest.join(".repos-dir"), "/some/workspace").unwrap();
let dev_nest = tmp.path().join(".buzz-dev");
// Run a real reset and take the REAL outcome.completed.
let kc = FakeKeychain::ok();
let ctx = ResetContext {
app_data_dir: &app_data,
legacy_app_data_dir: None,
nest_dir: Some(dev_nest.clone()),
keychain: &kc,
home_dir: None,
is_dev: true,
};
let outcome = run_boot_reset_with_keychain(ctx);
assert!(outcome.completed, "reset must complete");
// Arm 1: completed dev reset → dev nest must NOT get .repos-dir.
crate::migration::maybe_migrate_dev_repos_dir(true, outcome.completed, &home, &dev_nest);
assert!(
!dev_nest.join(".repos-dir").exists(),
"completed dev reset must suppress .repos-dir import into dev nest"
);
// Arm 2 (positive control): non-reset dev boot → dev nest IS created
// with .repos-dir copied. This proves the test would have caught the
// pass-3 resurrection live.
let dev_nest_2 = tmp.path().join(".buzz-dev-control");
crate::migration::maybe_migrate_dev_repos_dir(true, false, &home, &dev_nest_2);
assert!(
dev_nest_2.join(".repos-dir").exists(),
"non-reset dev boot must import .repos-dir into dev nest"
);
assert_eq!(
std::fs::read_to_string(dev_nest_2.join(".repos-dir")).unwrap(),
"/some/workspace",
".repos-dir content must match prod source"
);
// Arm 3: prod build (is_dev=false) → nothing created regardless.
let dev_nest_3 = tmp.path().join(".buzz-dev-prod");
crate::migration::maybe_migrate_dev_repos_dir(false, false, &home, &dev_nest_3);
assert!(
!dev_nest_3.join(".repos-dir").exists(),
"prod builds must never run dev repos-dir migration"
);
}
// ── Test 12: crash-after-rename retry cleans prior trash ─────────────
#[test]
fn test_crash_retry_cleans_prior_deterministic_trash() {
let tmp = TempDir::new().unwrap();
let app_support = tmp.path().join("Application Support");
let app_data = app_support.join("xyz.block.buzz.app");
std::fs::create_dir_all(&app_data).unwrap();
write_sentinel(&app_data).unwrap();
// Simulate a prior crashed boot: originals absent, deterministic trash
// present from the crash (as if the process renamed then died).
let trash_app_dir = app_support.join("xyz.block.buzz.app.reset-trash");
std::fs::create_dir_all(&trash_app_dir).unwrap();
std::fs::write(trash_app_dir.join("identity.key"), b"old-key").unwrap();
// Original is gone (crashed mid-wipe), so remove it for the retry.
std::fs::remove_dir_all(&app_data).unwrap();
let kc = FakeKeychain::ok();
let ctx = make_ctx(&app_data, &kc, false);
let outcome = run_boot_reset_with_keychain(ctx);
assert!(outcome.completed, "retry must complete");
assert!(
!trash_app_dir.exists(),
"prior crash trash must be cleaned by retry"
);
assert!(
!sentinel_path(&app_data).exists(),
"sentinel must be cleared"
);
}
// ── Test 13: keychain-fail restores all dirs, retry cleans trash ──────
#[test]
fn test_keychain_fail_restores_all_then_retry_cleans() {
let tmp = TempDir::new().unwrap();
let app_support = tmp.path().join("Application Support");
let app_data = app_support.join("xyz.block.buzz.app");
std::fs::create_dir_all(&app_data).unwrap();
std::fs::write(app_data.join("config.json"), b"{}").unwrap();
let legacy = app_support.join("xyz.block.sprout.app");
std::fs::create_dir_all(&legacy).unwrap();
std::fs::write(legacy.join("identity.key"), b"sprout-key").unwrap();
write_sentinel(&app_data).unwrap();
// First attempt: keychain fails → dirs restored.
let kc1 = FakeKeychain::fail("keychain locked");
let ctx1 = ResetContext {
app_data_dir: &app_data,
legacy_app_data_dir: Some(legacy.clone()),
nest_dir: None,
keychain: &kc1,
home_dir: Some(tmp.path().to_path_buf()),
is_dev: false,
};
let first = run_boot_reset_with_keychain(ctx1);
assert!(first.failed, "first attempt must fail");
assert!(
app_data.exists(),
"app-data must be restored after keychain fail"
);
assert!(
legacy.exists(),
"legacy dir must be restored after keychain fail"
);
assert!(sentinel_path(&app_data).exists(), "sentinel survives");
// Second attempt: keychain succeeds → everything cleaned including
// any residual trash from prior attempts.
let kc2 = FakeKeychain::ok();
let ctx2 = ResetContext {
app_data_dir: &app_data,
legacy_app_data_dir: Some(legacy.clone()),
nest_dir: None,
keychain: &kc2,
home_dir: Some(tmp.path().to_path_buf()),
is_dev: false,
};
let second = run_boot_reset_with_keychain(ctx2);
assert!(second.completed, "second attempt must complete");
assert!(!app_data.exists(), "app-data must be gone");
assert!(!legacy.exists(), "legacy must be gone");
// No trash directories should remain.
let trash_app = app_support.join("xyz.block.buzz.app.reset-trash");
let trash_legacy = app_support.join("xyz.block.sprout.app.reset-trash");
assert!(!trash_app.exists(), "app trash must be cleaned");
assert!(!trash_legacy.exists(), "legacy trash must be cleaned");
}
}
+195
View File
@@ -740,6 +740,163 @@ impl SecretStore {
}
}
/// Delete the entire keychain blob for this service, plus all legacy per-key
/// entries that could resurrect an identity on next boot.
///
/// Order of operations:
/// 1. Read the blob to collect every key name (e.g. `identity`, agent keys).
/// 2. Delete legacy per-key DPK entries for every key + the DPK blob itself.
/// 3. Delete legacy per-key keyring entries for every key.
/// 4. Delete the blob entry.
/// 5. Clear the in-memory cache.
///
/// This is the correct wipe path for sign-out: the old `delete_all` skipped
/// step 13 so stale per-key entries could be re-imported on the next launch
/// via `migrate_legacy_key`. This method prevents that resurrection.
pub fn delete_all_with_legacy_cleanup(&self) -> Result<(), String> {
#[cfg(feature = "system-keyring")]
{
let _lock = acquire_blob_lock(&self.service)?;
// Step 1: read current blob keys (best-effort; no entry = empty set).
let blob_keys: Vec<String> = match self.read_blob_raw() {
Ok(Some(bytes)) => {
let json = String::from_utf8(bytes).unwrap_or_default();
serde_json::from_str::<std::collections::HashMap<String, String>>(&json)
.map(|m| m.into_keys().collect())
.unwrap_or_default()
}
_ => vec![],
};
// Always include "identity" even if the blob is empty or absent —
// it may exist only as a legacy per-key entry.
let mut all_keys = blob_keys;
if !all_keys.contains(&"identity".to_string()) {
all_keys.push("identity".to_string());
}
// Steps 2 & 3: delete legacy per-key entries for every key.
for key in &all_keys {
#[cfg(target_os = "macos")]
{
match delete_generic_password_options(dpk_opts(&self.service, key)) {
Ok(()) => {}
Err(ref e) if is_not_found(e) => {}
Err(ref e) if is_dpk_unavailable(e) => {}
Err(e) => return Err(format!("dpk per-key delete {key}: {e}")),
}
}
{
let entry = keyring_entry(&self.service, key)
.map_err(|e| format!("keyring entry constructor {key}: {e}"))?;
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => {}
Err(e) if is_keyring_availability_error(&e.to_string()) => {
return Err(format!("keyring unavailable deleting {key}: {e}"));
}
Err(e) => {
return Err(format!("keyring per-key delete {key}: {e}"));
}
}
}
}
// Step 2 (cont.): also delete the legacy DPK blob written by #1267.
#[cfg(target_os = "macos")]
{
match delete_generic_password_options(dpk_opts(&self.service, BLOB_KEY)) {
Ok(()) => {}
Err(ref e) if is_not_found(e) => {}
Err(ref e) if is_dpk_unavailable(e) => {}
Err(e) => return Err(format!("dpk blob delete: {e}")),
}
}
// Step 4: delete the main blob entry.
{
let entry = keyring_entry(&self.service, BLOB_KEY)
.map_err(|e| format!("keyring entry constructor blob: {e}"))?;
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => {}
Err(e) if is_keyring_availability_error(&e.to_string()) => {
return Err(format!("keyring unavailable: {e}"));
}
Err(e) => {
return Err(format!("keyring blob delete: {e}"));
}
}
}
// Step 5: clear the in-memory cache.
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
*guard = None;
Ok(())
}
#[cfg(not(feature = "system-keyring"))]
{
Ok(()) // No-op: no keyring, nothing to delete.
}
}
/// Verify no identity-bearing keychain entry survives in any shape
/// that `load("identity")` → `migrate_legacy_key` can consume:
/// main blob, DPK blob (`BLOB_KEY`), and per-key `"identity"`.
///
/// Returns `true` when all three shapes are absent (or inaccessible in an
/// expected way), `false` when any entry is found or the keychain is
/// unavailable (fail-closed).
pub fn verify_fully_wiped(&self) -> bool {
#[cfg(feature = "system-keyring")]
{
// 1. Main blob must be absent.
match self.read_blob_raw() {
Ok(None) => {}
Ok(Some(_)) => return false,
Err(_) => return false,
}
// 2. Per-key "identity" via legacy keyring must be absent.
match keyring_entry(&self.service, "identity") {
Ok(entry) => match entry.get_password() {
Err(keyring::Error::NoEntry) => {}
Ok(_) => return false,
// Any other error (availability, unknown, transient) → fail closed.
// Only explicit NoEntry is proof of absence.
Err(_) => return false,
},
// Constructor failure → cannot verify → fail closed.
Err(_) => return false,
}
// 3. DPK blob (macOS only).
#[cfg(target_os = "macos")]
{
match generic_password(dpk_opts(&self.service, BLOB_KEY)) {
Err(ref e) if is_not_found(e) => {}
// dpk-unavailable is symmetric with load(): if load() can't
// consume DPK in this state, a surviving entry is harmless.
Err(ref e) if is_dpk_unavailable(e) => {}
Ok(_) => return false,
// Any other error → fail closed (not proof of absence).
Err(_) => return false,
}
// 4. Per-key DPK "identity" (macOS only).
match generic_password(dpk_opts(&self.service, "identity")) {
Err(ref e) if is_not_found(e) => {}
// dpk-unavailable: symmetric with load() — if load() can't
// read DPK, a surviving entry can't resurrect identity.
Err(ref e) if is_dpk_unavailable(e) => {}
Ok(_) => return false,
// Any other error → fail closed.
Err(_) => return false,
}
}
true
}
#[cfg(not(feature = "system-keyring"))]
{
true // No keyring = nothing to verify.
}
}
/// Delete the secret for `key`. A missing entry is not an error.
pub fn delete(&self, key: &str) -> Result<(), String> {
#[cfg(feature = "system-keyring")]
@@ -1108,4 +1265,42 @@ mod tests {
// Cleanup.
let _ = store2.delete(key);
}
#[ignore = "requires real OS keychain (run locally)"]
#[test]
fn delete_all_with_legacy_cleanup_removes_per_key_identity() {
let svc = "buzz-test-delete-all-legacy";
let key = "identity";
let value = "nsec1legacytest";
// Seed a legacy per-key entry (old format, pre-blob migration).
let entry = keyring_entry(svc, key).unwrap();
entry.set_password(value).unwrap();
// Also seed a blob with a different key to exercise the full path.
let store = SecretStore::keyring(svc);
store.store("agent:abc123", "nsec1agent").unwrap();
// Legacy per-key identity should be discoverable via probe.
let store2 = SecretStore::keyring(svc);
assert_eq!(store2.probe(key), KeyringProbe::Present);
// Wipe everything via the sign-out path.
store2.delete_all_with_legacy_cleanup().unwrap();
// Fresh store — neither the blob nor the per-key entry should remain.
let store3 = SecretStore::keyring(svc);
assert_eq!(
store3.probe(key),
KeyringProbe::ReachableButEmpty,
"per-key identity must not survive delete_all_with_legacy_cleanup"
);
assert_eq!(
store3.load(key).unwrap(),
None,
"load must not resurrect the legacy per-key identity"
);
// Agent key should also be gone.
assert_eq!(store3.load("agent:abc123").unwrap(), None);
}
}
+5
View File
@@ -21,6 +21,7 @@ import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSl
import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen";
import { RelaunchRequiredScreen } from "@/features/onboarding/ui/RelaunchRequiredScreen";
import { ResetFailedScreen } from "@/features/onboarding/ui/ResetFailedScreen";
import type { Community } from "@/features/communities/types";
import { useCommunityInit } from "@/features/communities/useCommunityInit";
import { useNestNotifications } from "@/features/communities/useNestNotifications";
@@ -299,6 +300,10 @@ function AppReady({
onFirstRunCommunitySettled,
]);
if (onboarding.stage === "reset-failed") {
return <ResetFailedScreen />;
}
if (onboarding.stage === "keyring-locked") {
return <KeyringLockedScreen />;
}
@@ -47,7 +47,6 @@ export function useIndependentThreadPanel(args: {
args.personaLookup,
args.respondToLookup,
),
// biome-ignore lint/correctness/useExhaustiveDependencies: fields listed explicitly — see comment above
[
args.channelEvents,
args.threadReplyEvents,
+15 -9
View File
@@ -413,6 +413,9 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
// the session cannot access it. No in-app recovery is possible; the user
// must unlock the keyring externally and relaunch. Mutually exclusive with lost.
const identityLocked = identity?.locked === true;
// Boot-time Phase 2 reset failed — wipe was attempted but verification failed.
// The sentinel is preserved so the next relaunch retries automatically.
const identityResetFailed = identity?.resetFailed === true;
// Sticky boot fact: once identity was lost at boot, this remains true for the
// entire session. Per-component state in OnboardingFlow cannot carry this
@@ -516,15 +519,18 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
currentPubkey,
flow,
identityLost,
// keyring-locked is the highest-precedence stage: nothing in-session can
// clear a locked keyring, so this fully blocks the UI until relaunch.
// reset-failed is the highest-precedence stage: a failed boot-time reset
// means identity resolution was skipped entirely. Nothing can proceed until
// the user relaunches and the wipe retries.
stage:
identityLocked && identityQuery.status === "success"
? ("keyring-locked" as const)
: relaunchRequired
? ("relaunch-required" as const)
: isCompletingWelcomeSetup
? ("blocking" as const)
: onboardingGate.stage,
identityResetFailed && identityQuery.status === "success"
? ("reset-failed" as const)
: identityLocked && identityQuery.status === "success"
? ("keyring-locked" as const)
: relaunchRequired
? ("relaunch-required" as const)
: isCompletingWelcomeSetup
? ("blocking" as const)
: onboardingGate.stage,
};
}
@@ -0,0 +1,11 @@
import { RecoveryScreen } from "./RecoveryScreen";
export function ResetFailedScreen() {
return (
<RecoveryScreen
testId="reset-failed"
title="Sign out could not complete"
body="Buzz was unable to fully clear your local data. Try relaunching — the reset will resume automatically. If this persists, contact support."
/>
);
}
@@ -13,7 +13,17 @@ import {
useUpdateProfileMutation,
} from "@/features/profile/hooks";
import { NsecMaskedDisplay } from "@/features/onboarding/ui/NsecMaskedDisplay";
import { getNsec } from "@/shared/api/tauriIdentity";
import { getNsec, signOut } from "@/shared/api/tauriIdentity";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
@@ -250,11 +260,15 @@ export function ProfileSettingsCard({
const [shouldRenderAvatarEditor, setShouldRenderAvatarEditor] =
React.useState(false);
const [avatarSquishKey, setAvatarSquishKey] = React.useState(0);
const [isSignOutOpen, setIsSignOutOpen] = React.useState(false);
const [isSignOutPending, setIsSignOutPending] = React.useState(false);
const displayNameInputRef = React.useRef<HTMLInputElement>(null);
const aboutTextareaRef = React.useRef<HTMLTextAreaElement>(null);
const sectionRef = React.useRef<HTMLElement>(null);
const isEditingProfileMetadataRef = React.useRef(false);
const avatarEditorOpenFrameRef = React.useRef<number | null>(null);
const avatarEditorFinishTimeoutRef = React.useRef<number | null>(null);
const savedScrollTopRef = React.useRef<number | null>(null);
isEditingProfileMetadataRef.current = isEditingProfileMetadata;
React.useEffect(() => {
@@ -411,14 +425,31 @@ export function ProfileSettingsCard({
window.clearTimeout(avatarEditorFinishTimeoutRef.current);
avatarEditorFinishTimeoutRef.current = null;
}, []);
const saveScrollPosition = React.useCallback(() => {
const el = sectionRef.current;
if (!el) return;
const scroller = el.closest<HTMLElement>("[class*='overflow-y']");
if (scroller) savedScrollTopRef.current = scroller.scrollTop;
}, []);
const restoreScrollPosition = React.useCallback(() => {
const saved = savedScrollTopRef.current;
if (saved == null) return;
savedScrollTopRef.current = null;
const el = sectionRef.current;
if (!el) return;
const scroller = el.closest<HTMLElement>("[class*='overflow-y']");
if (scroller) scroller.scrollTop = saved;
}, []);
const closeAvatarEditor = React.useCallback(() => {
clearAvatarEditorFinishTimeout();
setIsAvatarEditorOpen(false);
setIsAvatarEditorFinishing(false);
}, [clearAvatarEditorFinishTimeout]);
restoreScrollPosition();
}, [clearAvatarEditorFinishTimeout, restoreScrollPosition]);
const completeAvatarEditorClose = React.useCallback(() => {
setIsAvatarEditorOpen(false);
clearAvatarEditorFinishTimeout();
restoreScrollPosition();
avatarEditorFinishTimeoutRef.current = window.setTimeout(
() => {
avatarEditorFinishTimeoutRef.current = null;
@@ -426,7 +457,11 @@ export function ProfileSettingsCard({
},
shouldReduceMotion ? 0 : AVATAR_EDITOR_TRANSITION_MS,
);
}, [clearAvatarEditorFinishTimeout, shouldReduceMotion]);
}, [
clearAvatarEditorFinishTimeout,
restoreScrollPosition,
shouldReduceMotion,
]);
const reopenAvatarEditorAfterClose = React.useCallback(() => {
clearAvatarEditorFinishTimeout();
setShouldRenderAvatarEditor(true);
@@ -435,6 +470,7 @@ export function ProfileSettingsCard({
}, [clearAvatarEditorFinishTimeout]);
const openAvatarEditor = React.useCallback(() => {
saveScrollPosition();
setShouldRenderAvatarEditor(true);
setIsAvatarEditorFinishing(false);
clearAvatarEditorFinishTimeout();
@@ -447,7 +483,7 @@ export function ProfileSettingsCard({
avatarEditorOpenFrameRef.current = null;
setIsAvatarEditorOpen(true);
});
}, [clearAvatarEditorFinishTimeout]);
}, [clearAvatarEditorFinishTimeout, saveScrollPosition]);
const saveProfile = React.useCallback(async () => {
if (!canSave) {
@@ -535,7 +571,11 @@ export function ProfileSettingsCard({
}, []);
return (
<section className="min-w-0" data-testid="settings-profile">
<section
className="min-w-0"
data-testid="settings-profile"
ref={sectionRef}
>
<div>
<SettingsSectionHeader
title="Profile"
@@ -913,6 +953,80 @@ export function ProfileSettingsCard({
</div>
</div>
</div>
<div className="mt-8" data-testid="settings-signout">
<SettingsSectionHeader
title="Sign out"
description="Removes your identity key and all local app data from this device. Back up your private key (nsec) before proceeding — this cannot be undone."
/>
<div className="overflow-hidden rounded-xl border border-destructive/20 bg-background/70 shadow-xs">
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">Clear data and sign out</p>
<p className="text-sm text-muted-foreground">
Wipes the keychain, agent settings, and app cache, then
relaunches Buzz into first-run setup.
</p>
</div>
<button
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-destructive/30 bg-destructive/5 px-3 py-1.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
data-testid="signout-open-dialog"
disabled={isSignOutPending}
onClick={() => setIsSignOutOpen(true)}
type="button"
>
{isSignOutPending ? (
<Spinner
aria-label="Signing out"
className="h-4 w-4 border-2"
/>
) : null}
{isSignOutPending ? "Signing out…" : "Sign Out"}
</button>
</div>
</div>
<AlertDialog
onOpenChange={(open) => {
if (!open && !isSignOutPending) setIsSignOutOpen(false);
}}
open={isSignOutOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Sign out and wipe all data?</AlertDialogTitle>
<AlertDialogDescription>
This will delete your identity key, all agent settings, and
cached data from this device, then relaunch Buzz into first-run
setup. Make sure you have your private key (nsec) backed up
before continuing this cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSignOutPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90"
data-testid="signout-confirm"
disabled={isSignOutPending}
onClick={() => {
setIsSignOutPending(true);
// Keep the pending state if signOut() resolves before restart.
signOut().catch((err: unknown) => {
setIsSignOutPending(false);
setIsSignOutOpen(false);
toast.error(
err instanceof Error ? err.message : "Sign out failed.",
);
});
}}
>
{isSignOutPending ? "Signing out…" : "Sign Out"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</section>
);
}
+13
View File
@@ -6,6 +6,7 @@ type RawIdentity = {
display_name: string;
lost?: boolean;
locked?: boolean;
reset_failed?: boolean;
};
function fromRawIdentity(raw: RawIdentity): Identity {
@@ -14,6 +15,7 @@ function fromRawIdentity(raw: RawIdentity): Identity {
displayName: raw.display_name,
lost: raw.lost === true,
locked: raw.locked === true,
resetFailed: raw.reset_failed === true,
};
}
@@ -36,3 +38,14 @@ export async function persistCurrentIdentity(): Promise<Identity> {
await invokeTauri<RawIdentity>("persist_current_identity"),
);
}
/**
* Wipe all local Buzz state (keychain, App Support, WebKit, nest, OAuth cache,
* CLI symlinks) and relaunch into first-run onboarding.
*
* The app restarts after this call completes. Callers should keep the pending
* state until the process exits and only handle errors (e.g. display a toast).
*/
export async function signOut(): Promise<void> {
await invokeTauri("sign_out");
}
+4
View File
@@ -117,6 +117,10 @@ export type Identity = {
* the user must unlock the keyring externally and relaunch.
* Mutually exclusive with `lost`. */
locked?: boolean;
/** True when the boot-time Phase 2 reset attempted a wipe but verification
* failed. Identity resolution was skipped; the sentinel is preserved so
* the next relaunch retries the wipe automatically. */
resetFailed?: boolean;
};
export type Profile = {
+1 -1
View File
@@ -9526,7 +9526,7 @@ export function maybeInstallE2eTauriMocks() {
case "get_relay_self":
if ((activeConfig?.mock?.relaySelfDelayMs ?? 0) > 0) {
await new Promise((resolve) =>
window.setTimeout(resolve, activeConfig!.mock!.relaySelfDelayMs),
window.setTimeout(resolve, activeConfig?.mock?.relaySelfDelayMs),
);
}
return activeConfig?.mock?.relaySelf ?? null;
@@ -0,0 +1,70 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
const SHOTS = "test-results/signout";
test.describe("signout screenshots", () => {
test.use({ viewport: { width: 1280, height: 900 } });
test.beforeEach(async ({ page }) => {
page.on("pageerror", (err) => {
console.error(
"PAGE ERROR:",
err.message,
err.stack?.split("\n").slice(0, 5).join("\n"),
);
});
page.on("console", (msg) => {
if (msg.type() === "error") {
console.error("CONSOLE ERROR:", msg.text().slice(0, 500));
}
});
});
test("signout-section — Sign Out card in Settings Profile", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page, "profile");
const section = page.getByTestId("settings-signout");
await section.scrollIntoViewIfNeeded();
// Settle animations before capture.
await page.evaluate(() =>
Promise.all(document.getAnimations().map((a) => a.finished)),
);
await page.waitForTimeout(200);
await section.screenshot({ path: `${SHOTS}/signout-section.png` });
});
test("signout-dialog — AlertDialog shown before the wipe runs", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page, "profile");
const section = page.getByTestId("settings-signout");
await section.scrollIntoViewIfNeeded();
// Open the confirmation dialog.
await page.getByTestId("signout-open-dialog").click();
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog.getByText("Sign out and wipe all data?")).toBeVisible();
// Settle animations before capture.
await page.evaluate(() =>
Promise.all(document.getAnimations().map((a) => a.finished)),
);
await page.waitForTimeout(200);
await page.screenshot({ path: `${SHOTS}/signout-dialog.png` });
});
});