diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 0190b36ee9..4cdcf8b2a8 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1065,6 +1065,7 @@ mod tests { CancellationToken::new(), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), + 3, ); state.sub_registry.register( conn_id, diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index cc6050e878..556046b1ed 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -41,10 +41,11 @@ const overrides = new Map([ ["src-tauri/src/managed_agents/runtime.rs", 1953], ["src-tauri/src/managed_agents/personas.rs", 1080], ["src-tauri/src/managed_agents/persona_card.rs", 1050], - // applyWorkspace reposDir parameter threaded through the Tauri invoke for - // configurable repos_dir — a 3-line overage from load-bearing parameter - // plumbing, not generic debt growth. Approved override; still queued to split. - ["src/shared/api/tauri.ts", 1198], + // applyWorkspace reposDir parameter plus the validateReposDir binding, + // threaded through Tauri invokes for configurable repos_dir — a 4-line + // overage from load-bearing parameter plumbing, not generic debt growth. + // Approved override; still queued to split. + ["src/shared/api/tauri.ts", 1199], ["src-tauri/src/nostr_convert.rs", 1126], ["src/shared/api/relayClientSession.ts", 1022], ["src-tauri/src/migration.rs", 1295], diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 7c3529fc1e..7c0e4b8327 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -3,7 +3,10 @@ use serde::Serialize; use tauri::{AppHandle, Emitter, State}; use crate::app_state::AppState; -use crate::managed_agents::{ensure_repos_symlink, nest_dir, try_regenerate_nest}; +use crate::managed_agents::{ + effective_repos_dir, ensure_repos_symlink, nest_dir, try_regenerate_nest, + write_persisted_repos_dir, +}; use crate::relay; #[derive(Serialize)] @@ -23,18 +26,36 @@ pub fn get_active_workspace(state: State<'_, AppState>) -> Result Result<(), String> { + let trimmed = dir.trim(); + if trimmed.is_empty() { + return Ok(()); + } + let nest = nest_dir().ok_or("cannot resolve home directory for nest")?; + crate::managed_agents::validate_repos_dir(&nest, trimmed).map(|_| ()) +} + /// Apply a workspace's configuration to the backend session. /// /// Called by the frontend on app init (after reload) to configure the /// Tauri backend with the selected workspace's relay URL, keys, and repos /// directory. /// -/// Validation runs before any state mutation: an invalid `repos_dir` (bad -/// path) rejects cleanly with nothing applied. The `REPOS` symlink itself is -/// a filesystem *side-effect* — its failure (e.g. a non-empty real `REPOS` -/// refusing a downgrade, or a renamed external target on a later launch) is -/// non-fatal: relay/keys still apply, the command returns `Ok`, and a -/// `repos-dir-error` event surfaces the failure to the frontend. +/// A bad `repos_dir` is non-fatal: relay/keys always apply (the relay is the +/// active workspace's own choice — orthogonal to the filesystem repos dir), +/// the bad value is NOT persisted (so the next boot starts clean), the +/// `REPOS` symlink is skipped (REPOS stays a real dir), a `repos-dir-error` +/// event surfaces the reason, and the command returns `Ok`. The dialogs +/// already block a bad path at Save (`validate_repos_dir`); this fallback only +/// catches a value that went bad after save (deleted dir, unmounted volume). #[tauri::command] pub fn apply_workspace( relay_url: String, @@ -51,23 +72,25 @@ pub fn apply_workspace( None => None, }; - // Normalize repos_dir to a trimmed non-empty value. `None`/empty clears - // the override (REPOS falls back to a real dir). A bad path is rejected - // here — before any mutation — so the dialog sees a clean Err. - let repos_dir = repos_dir - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - if let Some(dir) = repos_dir.as_deref() { - let nest = nest_dir().ok_or("cannot resolve home directory for nest")?; - // Validate without mutating the filesystem. Keeps the command's - // "validate-first, nothing below can fail" contract honest. Also emit - // the error so it surfaces even at the init call site (which swallows - // the returned Err to console for the relay/keys path). - if let Err(error) = crate::managed_agents::validate_repos_dir(&nest, dir) { - let _ = app.emit("repos-dir-error", error.clone()); - return Err(error); - } - } + // Decide the effective repos_dir from the candidate. A bad path does NOT + // reject — it is treated as if no override were set: relay/keys still + // apply, the bad value is not persisted, and a `repos-dir-error` surfaces + // the reason. Persisting a bad path would make every later boot read it, + // fail to resolve the symlink, and silently skip agent restore. One + // validate (inside `effective_repos_dir`) drives both the emit and the + // persisted value. `nest` is resolved softly: when absent there is nothing + // to persist or symlink, and relay/keys must still apply unconditionally. + let nest = nest_dir(); + let effective_repos_dir = match nest.as_deref() { + Some(nest) => match effective_repos_dir(nest, repos_dir.as_deref()) { + Ok(value) => value, + Err(error) => { + let _ = app.emit("repos-dir-error", error); + None + } + }, + None => None, + }; // ── Apply all state changes (nothing below can fail) ────────────────── { @@ -81,11 +104,20 @@ pub fn apply_workspace( } // ── Filesystem side-effect (non-fatal) ──────────────────────────────── - // Re-point REPOS to match repos_dir. Failure here (downgrade refused, - // external target gone) must NOT fail the command — relay/keys are already - // applied. Surface it via a `repos-dir-error` event the frontend toasts. - if let Some(nest) = nest_dir() { - if let Err(error) = ensure_repos_symlink(&nest, repos_dir.as_deref()) { + // Persist the *effective* repos_dir (None when the candidate failed + // validation) for the backend to read at boot, then re-point REPOS to + // match. Persisting first makes the dotfile authoritative even if the + // symlink apply fails here (e.g. a non-empty real REPOS): the next boot + // reads the persisted value and resolves the symlink before any agent can + // clone into REPOS. A bad candidate persists `None`, so the next boot is + // clean and agent restore proceeds. Failure of either must NOT fail the + // command — relay/keys are already applied. Surface symlink errors via + // `repos-dir-error`. + if let Some(nest) = nest.as_deref() { + if let Err(error) = write_persisted_repos_dir(nest, effective_repos_dir.as_deref()) { + eprintln!("buzz-desktop: persist repos dir failed: {error}"); + } + if let Err(error) = ensure_repos_symlink(nest, effective_repos_dir.as_deref()) { eprintln!("buzz-desktop: repos dir setup failed: {error}"); let _ = app.emit("repos-dir-error", error); } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d9b26841ff..8cf55b13e3 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -586,6 +586,21 @@ pub fn run() { eprintln!("buzz-desktop: failed to create nest: {error}"); } + // Resolve the REPOS symlink from the persisted repos_dir BEFORE + // agents are restored below, and decide whether restore is safe. + // The frontend's apply_workspace runs only after React mounts — + // later than the async agent restore — so without this an agent + // could clone into the empty real REPOS dir, and once REPOS is + // non-empty ensure_repos_symlink refuses forever. resolve_repos_at_boot + // fails closed: if a repos_dir was configured but its symlink could + // not be resolved (transiently unavailable external volume), it + // returns false so we skip restore this launch rather than let an + // agent clone into the wrong REPOS. See managed_agents::repos. + let restore_agents = match managed_agents::nest_dir() { + Some(nest) => managed_agents::resolve_repos_at_boot(&nest), + None => true, + }; + // Carry the agent's knowledge from the legacy nest (~/.sprout) into // the live nest (~/.buzz) after it exists. Must run after // ensure_nest() so the destination is present. Non-fatal. @@ -637,14 +652,20 @@ pub fn run() { } // Keep launch-time agent restoration off the synchronous setup path - // so the frontend can mount and reveal the window promptly. - tauri::async_runtime::spawn(async move { - if let Err(error) = - restore_managed_agents_on_launch(&app_handle, shutdown_started.as_ref()).await - { - eprintln!("buzz-desktop: failed to restore managed agents: {error}"); - } - }); + // so the frontend can mount and reveal the window promptly. Gated on + // the boot-time repos symlink result (see restore_agents above): + // skip when a configured repos_dir could not be resolved, so no + // agent clones into a REPOS that isn't the user's target. + if restore_agents { + tauri::async_runtime::spawn(async move { + if let Err(error) = + restore_managed_agents_on_launch(&app_handle, shutdown_started.as_ref()) + .await + { + eprintln!("buzz-desktop: failed to restore managed agents: {error}"); + } + }); + } // Periodic sweep: reap orphaned agents from dead instances every 60s. // Catches agents that escaped both the Justfile trap and boot-time @@ -844,6 +865,7 @@ pub fn run() { confirm_pairing_sas, cancel_pairing, apply_workspace, + validate_repos_dir, get_active_workspace, set_prevent_sleep_active, get_agent_memory, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 18d6af2770..ca996c96a4 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -27,7 +27,10 @@ pub use personas::*; pub use process_lifecycle::*; #[cfg(feature = "mesh-llm")] pub use relay_mesh::*; -pub use repos::{ensure_repos_symlink, validate_repos_dir}; +pub use repos::{ + effective_repos_dir, ensure_repos_symlink, resolve_repos_at_boot, validate_repos_dir, + write_persisted_repos_dir, +}; pub use restore::*; pub use runtime::*; pub use storage::*; diff --git a/desktop/src-tauri/src/managed_agents/repos.rs b/desktop/src-tauri/src/managed_agents/repos.rs index dd81950c40..1228ad74cb 100644 --- a/desktop/src-tauri/src/managed_agents/repos.rs +++ b/desktop/src-tauri/src/managed_agents/repos.rs @@ -164,6 +164,117 @@ pub fn ensure_repos_setup_default(nest_root: &Path) -> Result<(), String> { ensure_repos_symlink(nest_root, None) } +/// Decide what `repos_dir` `apply_workspace` should persist and symlink for a +/// candidate value, running the single source-of-truth [`validate_repos_dir`]. +/// +/// - **`None`/empty candidate** → `Ok(None)`: no override; `REPOS` reverts to a +/// real in-nest directory. +/// - **valid candidate** → `Ok(Some(raw_trimmed))`: persist the *raw* trimmed +/// string (not the canonical path — persisting canonical would drift the +/// symlink target on `..`/symlinked-ancestor paths). +/// - **invalid candidate** → `Err(reason)`: the caller must persist `None` +/// (clearing the override so the next boot resolves clean and agent restore +/// proceeds) and surface `reason` to the user. Returning `Err` rather than +/// silently `Ok(None)` lets the caller emit the human-readable cause. +/// +/// One validate call drives both the persisted value and the error: a bad path +/// is never persisted, so it can never silently skip agent restore on a later +/// boot. Pure (no FS mutation, no emit) so the persist decision is unit-tested. +pub fn effective_repos_dir( + nest_root: &Path, + candidate: Option<&str>, +) -> Result, String> { + let Some(trimmed) = candidate.map(str::trim).filter(|s| !s.is_empty()) else { + return Ok(None); + }; + validate_repos_dir(nest_root, trimmed).map(|_| Some(trimmed.to_string())) +} + +/// Filename of the dotfile persisting the active workspace's `repos_dir`. +const REPOS_DIR_FILE: &str = ".repos-dir"; + +/// Read the persisted `repos_dir` from `nest_root/.repos-dir`. +/// +/// Returns the trimmed value, or `None` when the file is absent, unreadable, +/// or empty. This is the backend's sole knowledge of `repos_dir` at boot — +/// the frontend persists it via [`write_persisted_repos_dir`] on every +/// `apply_workspace`, so [`resolve_repos_at_boot`] can resolve the `REPOS` +/// symlink before any agent is restored. +fn read_persisted_repos_dir(nest_root: &Path) -> Option { + fs::read_to_string(nest_root.join(REPOS_DIR_FILE)) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Persist the active workspace's `repos_dir` to `nest_root/.repos-dir`. +/// +/// Writes the trimmed value (one line). A `None`/empty value clears the +/// override by removing the file, so a later boot reverts `REPOS` to a real +/// in-nest directory. Removing an absent file is not an error. Mirrors the +/// `.nest-agents-version` dotfile pattern. +pub fn write_persisted_repos_dir(nest_root: &Path, repos_dir: Option<&str>) -> Result<(), String> { + let path = nest_root.join(REPOS_DIR_FILE); + match repos_dir.map(str::trim).filter(|s| !s.is_empty()) { + Some(value) => fs::write(&path, format!("{value}\n")) + .map_err(|e| format!("write {}: {e}", path.display())), + None => match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("remove {}: {e}", path.display())), + }, + } +} + +/// Decide whether launch-time agent restore is safe given the boot symlink +/// outcome. Fails CLOSED: when a `repos_dir` was configured but its symlink +/// could not be resolved, restoring agents would let one clone into the empty +/// real `REPOS` that `ensure_nest` provisioned — the wrong location — and once +/// it is non-empty [`ensure_repos_symlink`] refuses forever, permanently +/// re-triggering the race. Skipping restore is recoverable on the next boot +/// once the external target is reachable; a misplaced clone is not. +/// +/// The no-`repos_dir` case (`persisted_present == false`) is always safe: the +/// real in-nest `REPOS` default is exactly where clones belong. +fn should_restore_agents(persisted_present: bool, symlink_result: &Result<(), String>) -> bool { + !(persisted_present && symlink_result.is_err()) +} + +/// Resolve the `REPOS` symlink at boot from the persisted `repos_dir` and +/// report whether agent restore is safe to proceed. +/// +/// Runs in the synchronous setup hook, after `ensure_nest` and before the +/// async agent restore is spawned, so `REPOS` is the user's configured symlink +/// before any agent can clone. Logs on failure (no toast path exists +/// pre-mount) and returns the fail-closed [`should_restore_agents`] decision. +pub fn resolve_repos_at_boot(nest_root: &Path) -> bool { + let persisted = read_persisted_repos_dir(nest_root); + let symlink_result = ensure_repos_symlink(nest_root, persisted.as_deref()); + if let Err(error) = &symlink_result { + eprintln!("buzz-desktop: repos dir setup failed at boot: {error}"); + } + let restore = should_restore_agents(persisted.is_some(), &symlink_result); + // Log the resolved outcome on success so a healthy boot is observable (the + // Err/skip branches already log; previously success was silent). + if symlink_result.is_ok() { + match persisted.as_deref() { + Some(dir) => eprintln!( + "buzz-desktop: repos dir resolved at boot — REPOS symlinked to configured `{dir}`" + ), + None => eprintln!( + "buzz-desktop: repos dir resolved at boot — no configured override, REPOS is the default real dir" + ), + } + } + if !restore { + eprintln!( + "buzz-desktop: skipping agent restore — configured repos_dir `{}` could not be resolved at boot; will retry on next launch", + persisted.as_deref().unwrap_or_default() + ); + } + restore +} + #[cfg(test)] mod tests { use super::*; @@ -383,6 +494,284 @@ mod tests { ); } + // ── persisted repos_dir dotfile ─────────────────────────────────────── + + #[test] + fn persisted_repos_dir_roundtrips_write_read_clear() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + + assert_eq!(read_persisted_repos_dir(&root), None, "absent → None"); + + write_persisted_repos_dir(&root, Some(" /Users/me/Development ")).unwrap(); + assert_eq!( + read_persisted_repos_dir(&root).as_deref(), + Some("/Users/me/Development"), + "value is trimmed on write/read" + ); + + write_persisted_repos_dir(&root, None).unwrap(); + assert_eq!(read_persisted_repos_dir(&root), None, "cleared → None"); + assert!( + !root.join(".repos-dir").exists(), + "clearing removes the dotfile" + ); + } + + #[test] + fn persisted_repos_dir_empty_value_clears() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + write_persisted_repos_dir(&root, Some("/Users/me/Development")).unwrap(); + + write_persisted_repos_dir(&root, Some(" ")).unwrap(); + assert_eq!( + read_persisted_repos_dir(&root), + None, + "a whitespace-only value clears the override" + ); + } + + #[test] + fn persisted_repos_dir_clear_when_absent_is_ok() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + + write_persisted_repos_dir(&root, None).expect("clearing an absent dotfile is not an error"); + } + + #[cfg(unix)] + #[test] + fn boot_resolves_symlink_from_persisted_value_into_empty_repos() { + // Mirrors the boot sequence: ensure_nest leaves REPOS an empty real + // dir, then the setup hook reads the persisted value and symlinks. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(root.join("REPOS")).unwrap(); + let external = tmp.path().join("Development"); + fs::create_dir_all(&external).unwrap(); + + write_persisted_repos_dir(&root, Some(external.to_str().unwrap())).unwrap(); + let persisted = read_persisted_repos_dir(&root); + ensure_repos_symlink(&root, persisted.as_deref()).unwrap(); + + let repos = root.join("REPOS"); + assert!( + repos.symlink_metadata().unwrap().file_type().is_symlink(), + "boot must convert the empty real REPOS into a symlink" + ); + assert_eq!(repos.read_link().unwrap(), external.canonicalize().unwrap()); + } + + #[cfg(unix)] + #[test] + fn boot_leaves_already_correct_symlink_untouched() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let external = tmp.path().join("Development"); + fs::create_dir_all(&external).unwrap(); + + write_persisted_repos_dir(&root, Some(external.to_str().unwrap())).unwrap(); + // First boot converts; second boot must be a noop. + let persisted = read_persisted_repos_dir(&root); + ensure_repos_symlink(&root, persisted.as_deref()).unwrap(); + ensure_repos_symlink(&root, persisted.as_deref()).unwrap(); + + let repos = root.join("REPOS"); + assert!(repos.symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!(repos.read_link().unwrap(), external.canonicalize().unwrap()); + } + + #[cfg(unix)] + #[test] + fn boot_with_cleared_value_reverts_symlink_to_real_dir() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let external = tmp.path().join("Development"); + fs::create_dir_all(&external).unwrap(); + fs::write(external.join("KEEP.md"), "data").unwrap(); + + // Configure, then clear the field. + write_persisted_repos_dir(&root, Some(external.to_str().unwrap())).unwrap(); + ensure_repos_symlink(&root, read_persisted_repos_dir(&root).as_deref()).unwrap(); + write_persisted_repos_dir(&root, None).unwrap(); + + // Next boot reads None and reverts REPOS to a real in-nest dir. + ensure_repos_symlink(&root, read_persisted_repos_dir(&root).as_deref()).unwrap(); + + let repos = root.join("REPOS"); + assert!( + !repos.symlink_metadata().unwrap().file_type().is_symlink(), + "clearing the persisted value reverts REPOS to a real dir" + ); + assert!( + external.join("KEEP.md").exists(), + "reverting must not touch the external target's contents" + ); + } + + #[cfg(unix)] + #[test] + fn effective_repos_dir_drives_persisted_dotfile_for_all_three_cases() { + // Pins the CRITICAL persist decision on `.repos-dir` CONTENTS, not just + // a return value: a bad path must clear the dotfile so the next boot's + // `should_restore_agents(false, _)` restores agents (the regression + // this hardening fixes). Drives each case through the same + // effective→persist path `apply_workspace` uses. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let good = tmp.path().join("Development"); + fs::create_dir_all(&good).unwrap(); + let good_str = good.to_str().unwrap(); + + // Pre-seed a value so each case proves it overwrites/clears, not just + // that an absent dotfile stays absent. + let seed = |root: &Path| write_persisted_repos_dir(root, Some(good_str)).unwrap(); + let persist = |root: &Path, candidate: Option<&str>| { + let effective = effective_repos_dir(root, candidate); + // Mirror apply_workspace: Err clears the override (persist None). + let to_persist = effective.unwrap_or(None); + write_persisted_repos_dir(root, to_persist.as_deref()).unwrap(); + }; + + // Bad path → Err → dotfile cleared (the CRITICAL). + seed(&root); + persist(&root, Some("/no/such/dir/here")); + assert_eq!( + read_persisted_repos_dir(&root), + None, + "a bad repos_dir must clear `.repos-dir` so the next boot restores agents" + ); + + // Good path → Ok(Some(raw)) → dotfile holds the raw trimmed value. + persist(&root, Some(&format!(" {good_str} "))); + assert_eq!( + read_persisted_repos_dir(&root).as_deref(), + Some(good_str), + "a valid repos_dir must persist the raw trimmed path (not the canonical path)" + ); + + // Empty/whitespace → Ok(None) → dotfile cleared. + seed(&root); + persist(&root, Some(" ")); + assert_eq!( + read_persisted_repos_dir(&root), + None, + "an empty repos_dir clears the override" + ); + + // None candidate → Ok(None) → dotfile cleared. + seed(&root); + persist(&root, None); + assert_eq!( + read_persisted_repos_dir(&root), + None, + "no repos_dir clears the override" + ); + } + + // ── resolve_repos_at_boot (boot sequence + fail-closed gate) ────────── + + #[cfg(unix)] + #[test] + fn resolve_repos_at_boot_converts_empty_real_repos_and_allows_restore() { + // Drives the real boot sequence: ensure_repos_setup_default (called by + // ensure_nest) provisions REPOS as an empty real dir, then the setup + // hook calls resolve_repos_at_boot. Asserts the convert happens at that + // position and restore is allowed. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let external = tmp.path().join("Development"); + fs::create_dir_all(&external).unwrap(); + write_persisted_repos_dir(&root, Some(external.to_str().unwrap())).unwrap(); + + ensure_repos_setup_default(&root).unwrap(); + let repos = root.join("REPOS"); + assert!( + repos.is_dir() && !repos.symlink_metadata().unwrap().file_type().is_symlink(), + "ensure_nest provisions REPOS as an empty real dir before the boot resolve" + ); + + let restore = resolve_repos_at_boot(&root); + + assert!(restore, "restore proceeds when the symlink resolves"); + assert!( + repos.symlink_metadata().unwrap().file_type().is_symlink(), + "boot resolve converts the empty real REPOS into the configured symlink" + ); + assert_eq!(repos.read_link().unwrap(), external.canonicalize().unwrap()); + } + + #[cfg(unix)] + #[test] + fn resolve_repos_at_boot_fails_closed_when_target_unresolvable() { + // Persisted repos_dir whose target does not exist at boot (transiently + // unavailable external volume) → ensure_repos_symlink Errs. Restore must + // be skipped and REPOS left as the empty real dir, never the symlink, so + // no agent clones into the wrong place. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let missing = tmp.path().join("not-mounted-yet"); + write_persisted_repos_dir(&root, Some(missing.to_str().unwrap())).unwrap(); + ensure_repos_setup_default(&root).unwrap(); + + let restore = resolve_repos_at_boot(&root); + + assert!( + !restore, + "restore must be skipped when a configured repos_dir cannot resolve at boot" + ); + let repos = root.join("REPOS"); + assert!( + !repos.symlink_metadata().unwrap().file_type().is_symlink(), + "REPOS must not become a symlink to an unresolved target" + ); + } + + #[cfg(unix)] + #[test] + fn resolve_repos_at_boot_allows_restore_with_no_repos_dir() { + // No configured repos_dir → the real in-nest REPOS default is correct, + // restore proceeds normally (the fail-closed gate must not fire here). + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + ensure_repos_setup_default(&root).unwrap(); + + assert!( + resolve_repos_at_boot(&root), + "restore proceeds when no repos_dir is configured" + ); + } + + #[test] + fn should_restore_agents_only_blocks_configured_unresolved_boot() { + assert!( + should_restore_agents(false, &Ok(())), + "no repos_dir, symlink ok → restore" + ); + assert!( + should_restore_agents(false, &Err("boom".into())), + "no repos_dir → real-dir default is correct even on Err → restore" + ); + assert!( + should_restore_agents(true, &Ok(())), + "configured repos_dir resolved → restore" + ); + assert!( + !should_restore_agents(true, &Err("boom".into())), + "configured repos_dir unresolved → fail closed, skip restore" + ); + } + /// Test helper: canonicalize a path, creating it as a directory first. #[cfg(unix)] trait CanonicalizeOrMake { diff --git a/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx b/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx index 2c402664de..421c4c900b 100644 --- a/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx +++ b/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx @@ -6,6 +6,7 @@ import { expandTilde, normalizeRelayUrl, } from "@/features/workspaces/workspaceStorage"; +import { validateReposDir } from "@/shared/api/tauri"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -31,6 +32,7 @@ export function AddWorkspaceDialog({ const [relayUrl, setRelayUrl] = React.useState(""); const [token, setToken] = React.useState(""); const [reposDir, setReposDir] = React.useState(""); + const [reposDirError, setReposDirError] = React.useState(null); const handleClose = React.useCallback(() => { onOpenChange(false); @@ -38,6 +40,7 @@ export function AddWorkspaceDialog({ setRelayUrl(""); setToken(""); setReposDir(""); + setReposDirError(null); }, [onOpenChange]); const handleSubmit = React.useCallback( @@ -48,8 +51,16 @@ export function AddWorkspaceDialog({ } // Expand `~` before save — the backend rejects tilde paths. Empty input - // resolves to `undefined` so REPOS keeps its default location. + // resolves to `undefined` so REPOS keeps its default location. Validate + // the expanded value (the bytes the backend canonicalizes) before save + // so a bad path is caught here instead of bricking a later boot. const expandedReposDir = await expandTilde(reposDir); + try { + await validateReposDir(expandedReposDir ?? ""); + } catch (error) { + setReposDirError(String(error)); + return; + } const workspace: Workspace = { id: crypto.randomUUID(), @@ -144,11 +155,17 @@ export function AddWorkspaceDialog({ setReposDir(e.target.value)} + onChange={(e) => { + setReposDir(e.target.value); + setReposDirError(null); + }} placeholder="~/Development" type="text" value={reposDir} /> + {reposDirError ? ( +

{reposDirError}

+ ) : null}

Point the agent's REPOS directory at an existing folder so agents work in your local checkouts. Leave blank to use diff --git a/desktop/src/features/workspaces/ui/EditWorkspaceDialog.tsx b/desktop/src/features/workspaces/ui/EditWorkspaceDialog.tsx index 8dacc030cd..88fe4017e5 100644 --- a/desktop/src/features/workspaces/ui/EditWorkspaceDialog.tsx +++ b/desktop/src/features/workspaces/ui/EditWorkspaceDialog.tsx @@ -5,6 +5,7 @@ import { expandTilde, normalizeRelayUrl, } from "@/features/workspaces/workspaceStorage"; +import { validateReposDir } from "@/shared/api/tauri"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -41,6 +42,7 @@ export function EditWorkspaceDialog({ const [relayUrl, setRelayUrl] = React.useState(""); const [token, setToken] = React.useState(""); const [reposDir, setReposDir] = React.useState(""); + const [reposDirError, setReposDirError] = React.useState(null); // Sync form state when the dialog opens with a workspace React.useEffect(() => { @@ -49,6 +51,7 @@ export function EditWorkspaceDialog({ setRelayUrl(workspace.relayUrl); setToken(workspace.token ?? ""); setReposDir(workspace.reposDir ?? ""); + setReposDirError(null); } }, [workspace, open]); @@ -84,10 +87,18 @@ export function EditWorkspaceDialog({ // Expand `~` to an absolute path before save — the backend rejects // tilde paths. An empty field clears the override (REPOS reverts to a - // real dir). Only emit when the resolved value actually changed so a - // no-op edit doesn't trigger a backend re-apply. + // real dir). Validate the expanded value (the bytes the backend + // canonicalizes) before save so a bad path is caught here instead of + // bricking a later boot. Only emit when the resolved value actually + // changed so a no-op edit doesn't trigger a backend re-apply. const expandedReposDir = await expandTilde(reposDir); if (expandedReposDir !== workspace.reposDir) { + try { + await validateReposDir(expandedReposDir ?? ""); + } catch (error) { + setReposDirError(String(error)); + return; + } updates.reposDir = expandedReposDir; } @@ -185,11 +196,17 @@ export function EditWorkspaceDialog({ setReposDir(e.target.value)} + onChange={(e) => { + setReposDir(e.target.value); + setReposDirError(null); + }} placeholder="~/Development" type="text" value={reposDir} /> + {reposDirError ? ( +

{reposDirError}

+ ) : null}

Point the agent's REPOS directory at an existing folder so agents work in your local checkouts. Leave blank to use diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index c0d20f108b..fe01c30ce4 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -139,12 +139,15 @@ export function useWorkspaceInit( activeWorkspace.reposDir, ); } catch (error) { - // apply_workspace validates repos_dir before touching relay/keys, so a - // hard reject (bad path) leaves the backend on the previous/default - // relay with nothing applied. Marking the workspace ready here would - // render workspace-scoped UI against the wrong relay — a silent - // split-brain. Park on the loading gate (isReady:false, no appliedKey) - // instead; the backend already toasts a `repos-dir-error`. + // A bad `repos_dir` no longer reaches here — `apply_workspace` treats + // it as non-fatal (relay/keys apply, bad value not persisted, REPOS + // falls back to a real dir, a `repos-dir-error` toast surfaces it) and + // returns Ok, so the app boots into a working state where the user can + // fix the value in workspace settings. This catch now only fires on a + // genuine relay/key apply failure (e.g. an invalid nsec or a poisoned + // lock). For those, marking the workspace ready would render + // workspace-scoped UI against a backend that never applied — park on + // the loading gate (isReady:false, no appliedKey) instead. console.error("Failed to apply workspace to backend:", error); if (!cancelled) { setResult({ isReady: false, needsSetup: false, appliedKey: null }); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 519f3fd859..1975e016eb 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1188,5 +1188,11 @@ export async function applyWorkspace( }); } +// Validate a candidate repos dir without mutating the filesystem. Rejects +// with a human-readable reason; resolves for a valid or empty path. +export async function validateReposDir(dir: string): Promise { + await invokeTauri("validate_repos_dir", { dir }); +} + export const setPreventSleepActive = (active: boolean) => invokeTauri("set_prevent_sleep_active", { active });