From 81cd8d0dfc60523d26dfe69d82cf93ed0ed70a88 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 16 Jul 2026 14:20:27 +1000 Subject: [PATCH] feat(desktop): isolate agent repos dirs per workspace Make agents immune to the shared REPOS symlink re-pointing on workspace switches (Phase 4 of lazy multi-workspace agents, building on the Phase 1-3 relay pinning, lazy activation, and UI scoping): - Persist repos_dir per relay URL in a new .repos-dirs.json nest dotfile (keys normalized like agent relay pins), written by every apply_workspace alongside the single-value .repos-dir that keeps driving the boot-time symlink resolve. The REPOS symlink itself keeps following the active workspace as a human/tooling convention. - At spawn, resolve the agent's own workspace entry (keyed by its effective relay) to a canonical real path and hand it to the child as BUZZ_REPOS_DIR, so a mid-task switch that re-points REPOS can no longer land a workspace-A agent in workspace B's checkouts. A configured-but-unresolvable entry fails the spawn closed (same rationale as resolve_repos_at_boot); no entry falls back to the nest REPOS path, preserving pre-map behavior. - Adjust the nest AGENTS.md instructions (template v5) that referenced REPOS/ relatively: agents are told to address checkouts through $BUZZ_REPOS_DIR whenever it is set. - Hash the raw per-relay map value into the spawn-config hash so a repos-dir edit badges needsRestart while a pure workspace switch (and filesystem state alone) cannot. - Migrate the map entry in rebind_agent_relay when a community's relay URL is edited, so rebound agents keep their repos-dir isolation. - Split the grown repos.rs test module into repos/tests.rs (file-size guard), mirroring nest/tests.rs and spawn_hash/tests.rs. Tested: cargo test --manifest-path desktop/src-tauri/Cargo.toml (1433 passed, incl. 7 new repos-map/resolution tests and 2 new spawn-hash tests), clippy --all-targets -D warnings, rustfmt --check, cargo check --features mesh-llm. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/src-tauri/src/commands/workspace.rs | 27 +- desktop/src-tauri/src/managed_agents/mod.rs | 5 +- desktop/src-tauri/src/managed_agents/nest.rs | 2 +- .../src/managed_agents/nest_agents.md | 3 +- desktop/src-tauri/src/managed_agents/repos.rs | 667 ++++------------ .../src/managed_agents/repos/tests.rs | 714 ++++++++++++++++++ .../src-tauri/src/managed_agents/runtime.rs | 37 +- .../src/managed_agents/spawn_hash.rs | 13 + .../src/managed_agents/spawn_hash/tests.rs | 422 +++++++++-- .../src-tauri/src/migration/backfill_tests.rs | 4 + 10 files changed, 1334 insertions(+), 560 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/repos/tests.rs diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 3622338dc6..79a344f1c6 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -6,8 +6,9 @@ use tauri::{AppHandle, Emitter, Manager, State}; use crate::app_state::AppState; use crate::managed_agents::{ activate_workspace_agents, effective_repos_dir, ensure_repos_symlink, load_managed_agents, - nest_dir, rebind_agent_relay_urls, save_managed_agents, stamp_blank_agent_relay_urls, - try_regenerate_nest, write_persisted_repos_dir, + nest_dir, persist_workspace_repos_dir, rebind_agent_relay_urls, rebind_workspace_repos_dir, + save_managed_agents, stamp_blank_agent_relay_urls, try_regenerate_nest, + write_persisted_repos_dir, }; use crate::relay; @@ -180,6 +181,18 @@ pub async fn apply_workspace( if let Err(error) = write_persisted_repos_dir(nest, effective_repos_dir.as_deref()) { eprintln!("buzz-desktop: persist repos dir failed: {error}"); } + // Per-relay map entry for THIS workspace (Phase 4 — REPOS + // isolation): spawn resolves an agent's repos dir from this map + // by the agent's pinned relay, so agents stay immune to the REPOS + // symlink re-point below when a later switch moves it. Must land + // before the activation task spawned after this closure starts + // this workspace's agents. A bad candidate clears the entry, same + // as the dotfile above. + if let Err(error) = + persist_workspace_repos_dir(nest, &relay_url, effective_repos_dir.as_deref()) + { + eprintln!("buzz-desktop: persist per-relay 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); @@ -274,6 +287,16 @@ pub async fn rebind_agent_relay( if changed > 0 { save_managed_agents(&app, &records)?; } + // Carry the workspace's per-relay repos_dir along with its agents — + // spawn resolves BUZZ_REPOS_DIR from the map by pinned relay, so + // leaving the entry on the old URL would silently drop repos-dir + // isolation for every rebound agent. Non-fatal: the next + // apply_workspace of the edited community re-stamps the entry. + if let Some(nest) = nest_dir() { + if let Err(error) = rebind_workspace_repos_dir(&nest, &old_relay_url, &new_relay_url) { + eprintln!("buzz-desktop: rebind per-relay repos dir failed: {error}"); + } + } Ok(changed) }) .await diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 98f91df89a..2215c2003b 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -63,8 +63,9 @@ pub(crate) use readiness::{ pub use relay_mesh::*; pub(crate) use relay_pinning::{rebind_agent_relay_urls, stamp_blank_agent_relay_urls}; pub use repos::{ - effective_repos_dir, ensure_repos_symlink, resolve_repos_at_boot, validate_repos_dir, - write_persisted_repos_dir, + effective_repos_dir, ensure_repos_symlink, persist_workspace_repos_dir, + rebind_workspace_repos_dir, resolve_agent_repos_dir, resolve_repos_at_boot, validate_repos_dir, + workspace_repos_dir_for_relay, write_persisted_repos_dir, }; pub use restore::*; pub use runtime::*; diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index e13ab2baad..ef81a98c55 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -46,7 +46,7 @@ const BUZZ_CLI_SKILL_MD: &str = include_str!("nest_skill.md"); /// Template content version for AGENTS.md static content (above managed markers). /// Bump this when changing `nest_agents.md` to trigger refresh on existing installs. /// Version 1 is implicitly "before this mechanism existed" (no version file). -const NEST_AGENTS_VERSION: u32 = 4; +const NEST_AGENTS_VERSION: u32 = 5; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. diff --git a/desktop/src-tauri/src/managed_agents/nest_agents.md b/desktop/src-tauri/src/managed_agents/nest_agents.md index 7cb7489b85..1a11c98bc5 100644 --- a/desktop/src-tauri/src/managed_agents/nest_agents.md +++ b/desktop/src-tauri/src/managed_agents/nest_agents.md @@ -11,7 +11,7 @@ Your persistent workspace. Created once by the Buzz desktop app. The static cont | `RESEARCH/` | Findings, notes, and reference material | | `WORK_LOGS/` | Session logs — what was tried, learned, decided | | `OUTBOX/` | Shareable docs for external readers (no frontmatter) | -| `REPOS/` | Source checkouts. Work in an existing local checkout when one exists; clone here only when none does | +| `REPOS/` | Source checkouts. Work in an existing local checkout when one exists; clone here only when none does. If `BUZZ_REPOS_DIR` is set in your environment, use that absolute path instead of `REPOS/` — see Core Guidelines | | `.scratch/` | Temporary working files — treat as disposable between sessions | Filenames: `ALL_CAPS_WITH_UNDERSCORES.md` (e.g., `OAUTH_FLOW_NOTES.md`). @@ -37,6 +37,7 @@ created: 2026-01-15 ## Core Guidelines +- **Reach source checkouts via `$BUZZ_REPOS_DIR`** — when set, this env var is the absolute path to *your* workspace's repos directory. Always address checkouts through it (e.g. `"$BUZZ_REPOS_DIR"/project`), never the relative `REPOS/` link: the `REPOS` symlink follows whichever workspace is active in the desktop app and can re-point mid-task. Only fall back to `REPOS/` when the env var is unset - **Local first** — check `RESEARCH/`, `GUIDES/`, `PLANS/` before external searches - **Write findings down** — if you research something, save it to `RESEARCH/` - **Cite sources** — no claim without a path, link, or reference diff --git a/desktop/src-tauri/src/managed_agents/repos.rs b/desktop/src-tauri/src/managed_agents/repos.rs index 1c77c5722e..b7c6b8d536 100644 --- a/desktop/src-tauri/src/managed_agents/repos.rs +++ b/desktop/src-tauri/src/managed_agents/repos.rs @@ -4,7 +4,15 @@ //! symlink to a user-configured `repos_dir`, letting agents work in existing //! local checkouts instead of re-cloning. [`ensure_repos_symlink`] reconciles //! `REPOS` with the configured path; [`validate_repos_dir`] guards the input. +//! +//! The symlink follows the *active* workspace — a human/tooling convention. +//! Agents are made immune to it: every applied workspace's `repos_dir` is +//! also persisted per relay URL ([`persist_workspace_repos_dir`]), and spawn +//! resolves the agent's own workspace entry to a real path +//! ([`resolve_agent_repos_dir`]) so a later workspace switch re-pointing +//! `REPOS` cannot move a running agent into another workspace's checkouts. +use std::collections::BTreeMap; use std::fs; use std::io; use std::path::{Path, PathBuf}; @@ -229,6 +237,155 @@ pub fn write_persisted_repos_dir(nest_root: &Path, repos_dir: Option<&str>) -> R } } +// ── Per-relay repos_dir map ─────────────────────────────────────────────── + +/// Filename of the dotfile persisting every workspace's `repos_dir`, keyed by +/// normalized relay URL. Distinct from [`REPOS_DIR_FILE`], which holds only +/// the *active* workspace's value for the boot-time symlink resolve. +const REPOS_DIR_MAP_FILE: &str = ".repos-dirs.json"; + +/// Read the per-relay `repos_dir` map from `nest_root/.repos-dirs.json`. +/// +/// Keys are normalized relay URLs ([`crate::relay::normalize_relay_url`]); +/// values are raw trimmed `repos_dir` strings, exactly as persisted by +/// [`persist_workspace_repos_dir`]. An absent, unreadable, or malformed file +/// yields an empty map — the map is an isolation layer over spawn, not a boot +/// dependency, so it degrades to the pre-map default rather than failing. +/// Keys are re-normalized and empty entries dropped on read, as defense +/// against a hand-edited file. +fn read_repos_dir_map(nest_root: &Path) -> BTreeMap { + let Ok(raw) = fs::read_to_string(nest_root.join(REPOS_DIR_MAP_FILE)) else { + return BTreeMap::new(); + }; + let Ok(parsed) = serde_json::from_str::>(&raw) else { + eprintln!("buzz-desktop: {REPOS_DIR_MAP_FILE} is malformed; ignoring"); + return BTreeMap::new(); + }; + parsed + .into_iter() + .filter_map(|(relay, dir)| { + let key = crate::relay::normalize_relay_url(&relay); + let value = dir.trim().to_string(); + (!key.is_empty() && !value.is_empty()).then_some((key, value)) + }) + .collect() +} + +/// Persist the per-relay `repos_dir` map, removing the file when empty +/// (mirrors [`write_persisted_repos_dir`]'s clear-by-removal). +fn write_repos_dir_map(nest_root: &Path, map: &BTreeMap) -> Result<(), String> { + let path = nest_root.join(REPOS_DIR_MAP_FILE); + if map.is_empty() { + return match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("remove {}: {e}", path.display())), + }; + } + let json = serde_json::to_string_pretty(map) + .map_err(|e| format!("serialize {REPOS_DIR_MAP_FILE}: {e}"))?; + fs::write(&path, format!("{json}\n")).map_err(|e| format!("write {}: {e}", path.display())) +} + +/// Upsert (or clear, on `None`/empty) the applied workspace's `repos_dir` in +/// the per-relay map. +/// +/// Written on every `apply_workspace`, alongside the single-value +/// [`REPOS_DIR_FILE`]: that dotfile drives the active-workspace `REPOS` +/// symlink at boot, while this map is what makes agents immune to the symlink +/// — spawn resolves the agent's own workspace entry via +/// [`resolve_agent_repos_dir`], keyed by the agent's pinned relay. Values are +/// stored raw (trimmed, not canonicalized) for the same reason +/// [`effective_repos_dir`] persists raw. +pub fn persist_workspace_repos_dir( + nest_root: &Path, + relay_url: &str, + repos_dir: Option<&str>, +) -> Result<(), String> { + let key = crate::relay::normalize_relay_url(relay_url); + if key.is_empty() { + return Err("cannot persist a repos dir for an empty relay URL".to_string()); + } + let mut map = read_repos_dir_map(nest_root); + match repos_dir.map(str::trim).filter(|s| !s.is_empty()) { + Some(value) => { + if map.get(&key).map(String::as_str) == Some(value) { + return Ok(()); + } + map.insert(key, value.to_string()); + } + None => { + if map.remove(&key).is_none() { + return Ok(()); + } + } + } + write_repos_dir_map(nest_root, &map) +} + +/// Move a workspace's per-relay `repos_dir` entry when the community's relay +/// URL is edited — the map analogue of `rebind_agent_relay_urls` for agent +/// records. Without this, agents rebound to the new URL would resolve no map +/// entry and silently fall back to the shared `REPOS` default. Returns +/// whether an entry moved; moving onto an existing entry overwrites it (the +/// edit just happened, so the edited community's value is freshest). +pub fn rebind_workspace_repos_dir( + nest_root: &Path, + old_relay_url: &str, + new_relay_url: &str, +) -> Result { + let old_key = crate::relay::normalize_relay_url(old_relay_url); + let new_key = crate::relay::normalize_relay_url(new_relay_url); + if new_key.is_empty() || old_key == new_key { + return Ok(false); + } + let mut map = read_repos_dir_map(nest_root); + let Some(value) = map.remove(&old_key) else { + return Ok(false); + }; + map.insert(new_key, value); + write_repos_dir_map(nest_root, &map)?; + Ok(true) +} + +/// The raw persisted `repos_dir` for the workspace identified by `relay_url`, +/// or `None` when that workspace has no override (or has not been applied +/// since the per-relay map was introduced). Raw and un-canonicalized so it is +/// a stable spawn-config-hash input — filesystem state changes alone must not +/// flip the restart badge; [`resolve_agent_repos_dir`] does the real-path +/// resolution separately at spawn. +pub fn workspace_repos_dir_for_relay(nest_root: &Path, relay_url: &str) -> Option { + read_repos_dir_map(nest_root).remove(&crate::relay::normalize_relay_url(relay_url)) +} + +/// Resolve the repos directory a spawning agent should be handed +/// (`BUZZ_REPOS_DIR`), from the per-relay map entry for the agent's effective +/// relay. +/// +/// - **Entry present** → the validated, canonical **real path** of that +/// workspace's `repos_dir`. Real, so the agent stays in its own workspace's +/// checkouts even while the `REPOS` symlink follows a different active +/// workspace. An unresolvable entry (unmounted volume, deleted dir) is an +/// `Err` — the spawn must fail CLOSED, mirroring [`resolve_repos_at_boot`]: +/// a refused start is recoverable, work landing in another workspace's +/// checkout is not. +/// - **No entry** → the nest's `REPOS` path (the shared in-nest default). +/// Deliberately NOT canonicalized: workspaces without an override share the +/// in-nest `REPOS` by design, and baking in a symlink target that happens +/// to be active now would be worse than the convention path. +pub fn resolve_agent_repos_dir(nest_root: &Path, relay_url: &str) -> Result { + match workspace_repos_dir_for_relay(nest_root, relay_url) { + Some(dir) => validate_repos_dir(nest_root, &dir).map_err(|reason| { + format!( + "repos dir `{dir}` configured for this agent's workspace ({relay_url}) cannot be \ + resolved: {reason}; refusing to start the agent so it cannot work in another \ + workspace's repos directory" + ) + }), + None => Ok(nest_root.join("REPOS")), + } +} + /// 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 @@ -279,512 +436,4 @@ pub fn resolve_repos_at_boot(nest_root: &Path) -> bool { } #[cfg(test)] -mod tests { - use super::*; - - // ── ensure_repos_symlink ────────────────────────────────────────────── - - #[cfg(unix)] - #[test] - fn ensure_repos_symlink_none_creates_real_dir() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - fs::create_dir_all(&root).unwrap(); - - ensure_repos_symlink(&root, None).unwrap(); - - let repos = root.join("REPOS"); - assert!(repos.is_dir(), "REPOS should be a real directory"); - assert!( - !repos.symlink_metadata().unwrap().file_type().is_symlink(), - "REPOS should not be a symlink when repos_dir is None" - ); - } - - #[cfg(unix)] - #[test] - fn ensure_repos_symlink_none_reverts_existing_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(); - let payload = external.join("KEEP.md"); - fs::write(&payload, "data").unwrap(); - - // First point REPOS at the external dir, then clear the field. - ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); - ensure_repos_symlink(&root, None).unwrap(); - - let repos = root.join("REPOS"); - assert!( - repos.is_dir(), - "REPOS should be a real directory after clear" - ); - assert!( - !repos.symlink_metadata().unwrap().file_type().is_symlink(), - "REPOS should no longer be a symlink after clearing repos_dir" - ); - assert!( - payload.exists(), - "clearing repos_dir must not touch the external target's contents" - ); - } - - #[cfg(unix)] - #[test] - fn ensure_repos_symlink_absent_creates_symlink() { - 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(); - - ensure_repos_symlink(&root, Some(external.to_str().unwrap())).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 ensure_repos_symlink_repoints_existing_wrong_symlink() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - fs::create_dir_all(&root).unwrap(); - let old = tmp.path().join("old"); - let new = tmp.path().join("new"); - fs::create_dir_all(&old).unwrap(); - fs::create_dir_all(&new).unwrap(); - let payload = old.join("KEEP.md"); - fs::write(&payload, "data").unwrap(); - - ensure_repos_symlink(&root, Some(old.to_str().unwrap())).unwrap(); - ensure_repos_symlink(&root, Some(new.to_str().unwrap())).unwrap(); - - let repos = root.join("REPOS"); - assert_eq!(repos.read_link().unwrap(), new.canonicalize().unwrap()); - assert!( - payload.exists(), - "re-pointing a symlink must not touch the old target's contents" - ); - } - - #[cfg(unix)] - #[test] - fn ensure_repos_symlink_correct_symlink_is_noop() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - fs::create_dir_all(&root).unwrap(); - let external = tmp.path().join("Development").canonicalize_or_make(); - - ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); - ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); - - let repos = root.join("REPOS"); - assert_eq!(repos.read_link().unwrap(), external); - } - - #[cfg(unix)] - #[test] - fn ensure_repos_symlink_empty_real_dir_converts() { - 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(); - - ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); - - let repos = root.join("REPOS"); - assert!( - repos.symlink_metadata().unwrap().file_type().is_symlink(), - "an empty real REPOS should convert to a symlink" - ); - } - - #[cfg(unix)] - #[test] - fn ensure_repos_symlink_nonempty_real_dir_refuses_and_preserves() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - let repos = root.join("REPOS"); - fs::create_dir_all(&repos).unwrap(); - let checkout = repos.join("buzz"); - fs::create_dir_all(&checkout).unwrap(); - fs::write(checkout.join("code.rs"), "fn main() {}").unwrap(); - let external = tmp.path().join("Development"); - fs::create_dir_all(&external).unwrap(); - - let result = ensure_repos_symlink(&root, Some(external.to_str().unwrap())); - - assert!(result.is_err(), "non-empty real REPOS must refuse"); - assert!( - !repos.symlink_metadata().unwrap().file_type().is_symlink(), - "refused REPOS must stay a real directory" - ); - assert!( - checkout.join("code.rs").exists(), - "refusal must never delete existing repositories" - ); - } - - // ensure_nest_at must NOT clobber an existing REPOS symlink on startup. - // Regression guard for Finding 1: the startup `ensure_repos_symlink(_, None)` - // call used to remove a configured symlink and mint an empty real REPOS, - // which async-restored agents could write into — the FE re-point then - // refused the now-non-empty dir, silently breaking a configured repos_dir. - #[cfg(unix)] - #[test] - fn ensure_nest_startup_preserves_existing_repos_symlink() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - - // First launch creates the real nest with a real REPOS dir. - crate::managed_agents::ensure_nest_at(&root).unwrap(); - - // Simulate a configured repos_dir: REPOS points at an external dir - // holding agent checkouts. - let external = tmp.path().join("Development"); - fs::create_dir(&external).unwrap(); - fs::write(external.join("KEEP.md"), "data").unwrap(); - fs::remove_dir(root.join("REPOS")).unwrap(); - std::os::unix::fs::symlink(&external, root.join("REPOS")).unwrap(); - - // Next launch must leave the configured symlink intact. - crate::managed_agents::ensure_nest_at(&root).unwrap(); - - let repos = root.join("REPOS"); - assert!( - repos.symlink_metadata().unwrap().file_type().is_symlink(), - "an existing REPOS symlink must survive startup" - ); - assert_eq!(repos.read_link().unwrap(), external); - assert!( - external.join("KEEP.md").exists(), - "the symlink's target contents must be untouched" - ); - } - - #[cfg(unix)] - #[test] - fn validate_repos_dir_rejects_tilde_relative_and_missing() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join(".buzz"); - fs::create_dir_all(&root).unwrap(); - - assert!(validate_repos_dir(&root, "~/Development").is_err()); - assert!(validate_repos_dir(&root, "relative/path").is_err()); - assert!(validate_repos_dir(&root, "/no/such/dir/here").is_err()); - - let file = tmp.path().join("afile"); - fs::write(&file, "x").unwrap(); - assert!(validate_repos_dir(&root, file.to_str().unwrap()).is_err()); - } - - #[cfg(unix)] - #[test] - fn validate_repos_dir_rejects_nest_ancestor() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().join("home").join(".buzz"); - fs::create_dir_all(&root).unwrap(); - let parent = root.parent().unwrap(); - - assert!( - validate_repos_dir(&root, parent.to_str().unwrap()).is_err(), - "a parent of the nest would nest REPOS inside its own target" - ); - } - - // ── 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 { - fn canonicalize_or_make(&self) -> PathBuf; - } - #[cfg(unix)] - impl CanonicalizeOrMake for PathBuf { - fn canonicalize_or_make(&self) -> PathBuf { - fs::create_dir_all(self).unwrap(); - self.canonicalize().unwrap() - } - } -} +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/repos/tests.rs b/desktop/src-tauri/src/managed_agents/repos/tests.rs new file mode 100644 index 0000000000..67a93df548 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/repos/tests.rs @@ -0,0 +1,714 @@ +use super::*; + +// ── ensure_repos_symlink ────────────────────────────────────────────── + +#[cfg(unix)] +#[test] +fn ensure_repos_symlink_none_creates_real_dir() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + + ensure_repos_symlink(&root, None).unwrap(); + + let repos = root.join("REPOS"); + assert!(repos.is_dir(), "REPOS should be a real directory"); + assert!( + !repos.symlink_metadata().unwrap().file_type().is_symlink(), + "REPOS should not be a symlink when repos_dir is None" + ); +} + +#[cfg(unix)] +#[test] +fn ensure_repos_symlink_none_reverts_existing_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(); + let payload = external.join("KEEP.md"); + fs::write(&payload, "data").unwrap(); + + // First point REPOS at the external dir, then clear the field. + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); + ensure_repos_symlink(&root, None).unwrap(); + + let repos = root.join("REPOS"); + assert!( + repos.is_dir(), + "REPOS should be a real directory after clear" + ); + assert!( + !repos.symlink_metadata().unwrap().file_type().is_symlink(), + "REPOS should no longer be a symlink after clearing repos_dir" + ); + assert!( + payload.exists(), + "clearing repos_dir must not touch the external target's contents" + ); +} + +#[cfg(unix)] +#[test] +fn ensure_repos_symlink_absent_creates_symlink() { + 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(); + + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).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 ensure_repos_symlink_repoints_existing_wrong_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir_all(&old).unwrap(); + fs::create_dir_all(&new).unwrap(); + let payload = old.join("KEEP.md"); + fs::write(&payload, "data").unwrap(); + + ensure_repos_symlink(&root, Some(old.to_str().unwrap())).unwrap(); + ensure_repos_symlink(&root, Some(new.to_str().unwrap())).unwrap(); + + let repos = root.join("REPOS"); + assert_eq!(repos.read_link().unwrap(), new.canonicalize().unwrap()); + assert!( + payload.exists(), + "re-pointing a symlink must not touch the old target's contents" + ); +} + +#[cfg(unix)] +#[test] +fn ensure_repos_symlink_correct_symlink_is_noop() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let external = tmp.path().join("Development").canonicalize_or_make(); + + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); + + let repos = root.join("REPOS"); + assert_eq!(repos.read_link().unwrap(), external); +} + +#[cfg(unix)] +#[test] +fn ensure_repos_symlink_empty_real_dir_converts() { + 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(); + + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); + + let repos = root.join("REPOS"); + assert!( + repos.symlink_metadata().unwrap().file_type().is_symlink(), + "an empty real REPOS should convert to a symlink" + ); +} + +#[cfg(unix)] +#[test] +fn ensure_repos_symlink_nonempty_real_dir_refuses_and_preserves() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + let repos = root.join("REPOS"); + fs::create_dir_all(&repos).unwrap(); + let checkout = repos.join("buzz"); + fs::create_dir_all(&checkout).unwrap(); + fs::write(checkout.join("code.rs"), "fn main() {}").unwrap(); + let external = tmp.path().join("Development"); + fs::create_dir_all(&external).unwrap(); + + let result = ensure_repos_symlink(&root, Some(external.to_str().unwrap())); + + assert!(result.is_err(), "non-empty real REPOS must refuse"); + assert!( + !repos.symlink_metadata().unwrap().file_type().is_symlink(), + "refused REPOS must stay a real directory" + ); + assert!( + checkout.join("code.rs").exists(), + "refusal must never delete existing repositories" + ); +} + +// ensure_nest_at must NOT clobber an existing REPOS symlink on startup. +// Regression guard for Finding 1: the startup `ensure_repos_symlink(_, None)` +// call used to remove a configured symlink and mint an empty real REPOS, +// which async-restored agents could write into — the FE re-point then +// refused the now-non-empty dir, silently breaking a configured repos_dir. +#[cfg(unix)] +#[test] +fn ensure_nest_startup_preserves_existing_repos_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + + // First launch creates the real nest with a real REPOS dir. + crate::managed_agents::ensure_nest_at(&root).unwrap(); + + // Simulate a configured repos_dir: REPOS points at an external dir + // holding agent checkouts. + let external = tmp.path().join("Development"); + fs::create_dir(&external).unwrap(); + fs::write(external.join("KEEP.md"), "data").unwrap(); + fs::remove_dir(root.join("REPOS")).unwrap(); + std::os::unix::fs::symlink(&external, root.join("REPOS")).unwrap(); + + // Next launch must leave the configured symlink intact. + crate::managed_agents::ensure_nest_at(&root).unwrap(); + + let repos = root.join("REPOS"); + assert!( + repos.symlink_metadata().unwrap().file_type().is_symlink(), + "an existing REPOS symlink must survive startup" + ); + assert_eq!(repos.read_link().unwrap(), external); + assert!( + external.join("KEEP.md").exists(), + "the symlink's target contents must be untouched" + ); +} + +#[cfg(unix)] +#[test] +fn validate_repos_dir_rejects_tilde_relative_and_missing() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + + assert!(validate_repos_dir(&root, "~/Development").is_err()); + assert!(validate_repos_dir(&root, "relative/path").is_err()); + assert!(validate_repos_dir(&root, "/no/such/dir/here").is_err()); + + let file = tmp.path().join("afile"); + fs::write(&file, "x").unwrap(); + assert!(validate_repos_dir(&root, file.to_str().unwrap()).is_err()); +} + +#[cfg(unix)] +#[test] +fn validate_repos_dir_rejects_nest_ancestor() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("home").join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let parent = root.parent().unwrap(); + + assert!( + validate_repos_dir(&root, parent.to_str().unwrap()).is_err(), + "a parent of the nest would nest REPOS inside its own target" + ); +} + +// ── 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" + ); +} + +// ── per-relay repos_dir map ─────────────────────────────────────────── + +#[test] +fn repos_dir_map_roundtrips_upsert_and_clear() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + + assert_eq!( + workspace_repos_dir_for_relay(&root, "wss://relay-a.example"), + None, + "absent map → None" + ); + + persist_workspace_repos_dir(&root, "wss://relay-a.example", Some(" /Users/me/DevA ")) + .unwrap(); + persist_workspace_repos_dir(&root, "wss://relay-b.example", Some("/Users/me/DevB")).unwrap(); + + assert_eq!( + workspace_repos_dir_for_relay(&root, "wss://relay-a.example").as_deref(), + Some("/Users/me/DevA"), + "value is trimmed on write" + ); + assert_eq!( + workspace_repos_dir_for_relay(&root, "wss://relay-b.example").as_deref(), + Some("/Users/me/DevB"), + "entries are independent per relay" + ); + + // Clearing one workspace's override must not touch the other's. + persist_workspace_repos_dir(&root, "wss://relay-a.example", None).unwrap(); + assert_eq!( + workspace_repos_dir_for_relay(&root, "wss://relay-a.example"), + None + ); + assert_eq!( + workspace_repos_dir_for_relay(&root, "wss://relay-b.example").as_deref(), + Some("/Users/me/DevB") + ); + + // Clearing the last entry removes the file entirely. + persist_workspace_repos_dir(&root, "wss://relay-b.example", Some(" ")).unwrap(); + assert!( + !root.join(".repos-dirs.json").exists(), + "an empty map removes the dotfile" + ); +} + +#[test] +fn repos_dir_map_keys_are_normalized() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + + // Cosmetic URL differences (case, trailing slash) are the same relay: + // a lookup must find the entry, and a re-persist must overwrite it + // rather than minting a second entry. + persist_workspace_repos_dir(&root, "WSS://Relay-A.Example/", Some("/Users/me/DevA")).unwrap(); + assert_eq!( + workspace_repos_dir_for_relay(&root, "wss://relay-a.example").as_deref(), + Some("/Users/me/DevA") + ); + + persist_workspace_repos_dir(&root, "wss://relay-a.example", Some("/Users/me/Other")).unwrap(); + assert_eq!( + workspace_repos_dir_for_relay(&root, "WSS://Relay-A.Example/").as_deref(), + Some("/Users/me/Other"), + "cosmetic variants address the same entry" + ); + assert_eq!( + read_repos_dir_map(&root).len(), + 1, + "no duplicate entries for cosmetic variants" + ); +} + +#[test] +fn repos_dir_map_rejects_empty_relay_and_survives_malformed_file() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + + assert!( + persist_workspace_repos_dir(&root, " ", Some("/Users/me/DevA")).is_err(), + "an empty relay URL has no identity to key on" + ); + + fs::write(root.join(".repos-dirs.json"), "not-json{{{").unwrap(); + assert_eq!( + workspace_repos_dir_for_relay(&root, "wss://relay-a.example"), + None, + "a malformed map degrades to empty rather than failing" + ); + persist_workspace_repos_dir(&root, "wss://relay-a.example", Some("/Users/me/DevA")).unwrap(); + assert_eq!( + workspace_repos_dir_for_relay(&root, "wss://relay-a.example").as_deref(), + Some("/Users/me/DevA"), + "persisting over a malformed file recovers it" + ); +} + +#[test] +fn rebind_workspace_repos_dir_moves_entry_to_new_relay() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + persist_workspace_repos_dir(&root, "wss://old.example", Some("/Users/me/DevA")).unwrap(); + + let moved = + rebind_workspace_repos_dir(&root, "wss://old.example/", "WSS://New.Example").unwrap(); + + assert!(moved); + assert_eq!( + workspace_repos_dir_for_relay(&root, "wss://old.example"), + None, + "the old relay's entry is gone" + ); + assert_eq!( + workspace_repos_dir_for_relay(&root, "wss://new.example").as_deref(), + Some("/Users/me/DevA"), + "the entry follows the edited relay URL" + ); + + // Rebinding a relay with no entry is a no-op, not an error. + assert!(!rebind_workspace_repos_dir(&root, "wss://absent.example", "wss://x.example").unwrap()); + // A cosmetic-only edit (same normalized relay) moves nothing. + assert!(!rebind_workspace_repos_dir(&root, "wss://new.example", "WSS://New.Example/").unwrap()); +} + +// ── resolve_agent_repos_dir (spawn-time isolation) ──────────────────── + +// THE Phase-4 immunity property: each agent resolves its own workspace's +// repos dir as a real path, regardless of where the shared REPOS symlink +// currently points (it follows the ACTIVE workspace). +#[cfg(unix)] +#[test] +fn resolve_agent_repos_dir_is_immune_to_active_workspace_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let dir_a = tmp.path().join("DevA"); + let dir_b = tmp.path().join("DevB"); + fs::create_dir_all(&dir_a).unwrap(); + fs::create_dir_all(&dir_b).unwrap(); + + // Workspace A and B each applied with their own repos_dir; B is the + // active workspace, so REPOS symlinks to B's dir. + persist_workspace_repos_dir( + &root, + "wss://relay-a.example", + Some(dir_a.to_str().unwrap()), + ) + .unwrap(); + persist_workspace_repos_dir( + &root, + "wss://relay-b.example", + Some(dir_b.to_str().unwrap()), + ) + .unwrap(); + ensure_repos_symlink(&root, Some(dir_b.to_str().unwrap())).unwrap(); + + assert_eq!( + resolve_agent_repos_dir(&root, "wss://relay-a.example").unwrap(), + dir_a.canonicalize().unwrap(), + "an A-pinned agent resolves A's real dir even while REPOS points at B" + ); + assert_eq!( + resolve_agent_repos_dir(&root, "wss://relay-b.example").unwrap(), + dir_b.canonicalize().unwrap() + ); +} + +#[test] +fn resolve_agent_repos_dir_fails_closed_when_override_unresolvable() { + 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"); + persist_workspace_repos_dir( + &root, + "wss://relay-a.example", + Some(missing.to_str().unwrap()), + ) + .unwrap(); + + let result = resolve_agent_repos_dir(&root, "wss://relay-a.example"); + + let error = result.expect_err("a configured-but-unresolvable repos dir must refuse spawn"); + assert!( + error.contains("refusing to start"), + "the error explains the fail-closed refusal: {error}" + ); +} + +#[test] +fn resolve_agent_repos_dir_defaults_to_nest_repos_without_entry() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + + assert_eq!( + resolve_agent_repos_dir(&root, "wss://relay-a.example").unwrap(), + root.join("REPOS"), + "no override → the shared in-nest REPOS default (pre-map behavior)" + ); +} + +// ── 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 { + fn canonicalize_or_make(&self) -> PathBuf; +} +#[cfg(unix)] +impl CanonicalizeOrMake for PathBuf { + fn canonicalize_or_make(&self) -> PathBuf { + fs::create_dir_all(self).unwrap(); + self.canonicalize().unwrap() + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 31b31e00d0..a3951e11a4 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1330,16 +1330,28 @@ pub fn build_managed_agent_summary( let needs_restart = runtimes.get(&record.pubkey).is_some_and(|runtime| { use tauri::Manager; let state = app.state::(); + let workspace_relay = crate::relay::relay_ws_url_with_override(&state); let global_for_hash = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); let teams_for_hash = crate::managed_agents::load_teams(app).unwrap_or_default(); + // Repos-dir hash input mirrors spawn: the raw per-relay map value for + // the agent's EFFECTIVE relay. Keyed by the agent's own pin, so a + // workspace switch cannot move it; an edit to the agent's own + // community repos dir can — and should badge. + let repos_dir_for_hash = crate::managed_agents::nest_dir().and_then(|nest| { + crate::managed_agents::workspace_repos_dir_for_relay( + &nest, + &crate::relay::effective_agent_relay_url(&record.relay_url, &workspace_relay), + ) + }); let hash_drift = runtime.spawn_config_hash != crate::managed_agents::spawn_hash::spawn_config_hash( record, personas, &teams_for_hash, - &crate::relay::relay_ws_url_with_override(&state), + &workspace_relay, &global_for_hash, + repos_dir_for_hash.as_deref(), ); let availability_drift = super::availability_drift( runtime.adapter_availability.as_ref(), @@ -1542,6 +1554,25 @@ pub fn spawn_agent_child( ) }; + // The agent's workspace repos directory, resolved to a REAL path from the + // per-relay map keyed by the agent's effective relay. The nest's REPOS + // symlink follows the ACTIVE workspace (a human/tooling convention) and + // can re-point mid-task on a workspace switch; handing the resolved path + // as BUZZ_REPOS_DIR makes this agent immune (the nest AGENTS.md instructs + // agents to prefer it over the relative REPOS/ path). Fails CLOSED when + // this workspace has a configured repos dir that cannot be resolved right + // now (e.g. unmounted volume) — same rationale as resolve_repos_at_boot: + // a refused start is recoverable, work landing in another workspace's + // checkouts is not. No map entry falls back to the nest REPOS path, + // preserving pre-map behavior. + let resolved_repos_dir = super::nest_dir() + .map(|nest| super::resolve_agent_repos_dir(&nest, &effective_relay_url)) + .transpose()?; + // Hash input: the RAW map value (stable against filesystem state), read + // once here so the stamp below and this spawn's env agree by construction. + let repos_dir_for_hash = super::nest_dir() + .and_then(|nest| super::workspace_repos_dir_for_relay(&nest, &effective_relay_url)); + // Augment PATH for DMG launches so child processes can find: // - bundled CLI via ~/.local/bin symlink // - nvm-managed node/npm (nvm initializes only in interactive shells) @@ -1572,6 +1603,9 @@ pub fn spawn_agent_child( command.env("RUST_LOG", child_rust_log_filter()); command.env("BUZZ_PRIVATE_KEY", &record.private_key_nsec); command.env("BUZZ_RELAY_URL", &effective_relay_url); + if let Some(repos_dir) = &resolved_repos_dir { + command.env("BUZZ_REPOS_DIR", repos_dir); + } command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); match &resolved_mcp_command { @@ -1900,6 +1934,7 @@ pub fn spawn_agent_child( &teams, &effective_relay_url, &global, + repos_dir_for_hash.as_deref(), ); // Stamp the adapter availability for runtimes with a version gate (codex diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index 4efe853cbc..0c9f2f900b 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -22,6 +22,11 @@ //! URL difference (trailing slash, scheme/host case) does not. //! - Channel membership is not an input: agents pick up channel changes live //! (#1468), never via restart. +//! - The workspace repos dir is hashed as the raw per-relay map value for the +//! agent's effective relay (the source of the spawned `BUZZ_REPOS_DIR`): +//! keyed by the agent's own pin, a workspace switch cannot move it, but an +//! edit to the agent's community repos dir means a restart would change +//! where the agent works. //! //! The hash never crosses a process or persistence boundary, so //! `DefaultHasher` (not stable across Rust releases) is sufficient. @@ -64,6 +69,9 @@ pub(crate) fn effective_team_instructions( /// Digest the effective spawn configuration of `record` under the current /// `personas`, resolving a blank record relay against `workspace_relay`. +/// `workspace_repos_dir` is the raw per-relay repos-dir map value for the +/// agent's effective relay (callers look it up via +/// `workspace_repos_dir_for_relay`); it feeds the spawned `BUZZ_REPOS_DIR`. /// Pure — no `AppHandle`, no disk, no keyring. pub(crate) fn spawn_config_hash( record: &ManagedAgentRecord, @@ -71,6 +79,7 @@ pub(crate) fn spawn_config_hash( teams: &[TeamRecord], workspace_relay: &str, global: &GlobalAgentConfig, + workspace_repos_dir: Option<&str>, ) -> u64 { // Prospective re-snapshot: apply the same `apply_persona_snapshot` the // start/restore paths run right before spawning, so the hash covers what a @@ -116,6 +125,10 @@ pub(crate) fn spawn_config_hash( workspace_relay, )) .hash(&mut hasher); + // The source value of the spawned BUZZ_REPOS_DIR. Hashed raw (not + // canonicalized), so filesystem state changes alone cannot flip the badge + // — only an actual config edit (or a relay rebind moving the entry) can. + workspace_repos_dir.hash(&mut hasher); // Prompt and runtime-layered team instructions use the same resolver as spawn. effective_spawn_prompt(record).hash(&mut hasher); effective_team_instructions(record, teams).hash(&mut hasher); diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index 4a81980075..57ec7b103e 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -85,8 +85,22 @@ fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { fn hash_is_deterministic() { let rec = record(); assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()) + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ) ); } @@ -112,14 +126,16 @@ fn materializing_runtime_keeps_hash_stable() { &personas, &[], "wss://ws.example", - &Default::default() + &Default::default(), + None ), spawn_config_hash( &post, &personas, &[], "wss://ws.example", - &Default::default() + &Default::default(), + None ) ); } @@ -132,8 +148,22 @@ fn record_env_var_edit_changes_hash() { .env_vars .insert("SOME_KEY".into(), "some-value".into()); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &edited, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ) ); } @@ -143,8 +173,22 @@ fn record_prompt_edit_changes_hash() { let mut edited = record(); edited.system_prompt = Some("Edited prompt.".into()); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &edited, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ) ); } @@ -157,8 +201,22 @@ fn persona_runtime_edit_changes_hash() { let before = [persona("pers", Some("goose"), "prompt")]; let after = [persona("pers", Some("claude"), "prompt")]; assert_ne!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()) + spawn_config_hash( + &rec, + &before, + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &rec, + &after, + &[], + "wss://ws.example", + &Default::default(), + None + ) ); } @@ -172,8 +230,22 @@ fn persona_prompt_edit_changes_hash() { let before = [persona("pers", Some("goose"), "old prompt")]; let after = [persona("pers", Some("goose"), "new prompt")]; assert_ne!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()) + spawn_config_hash( + &rec, + &before, + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &rec, + &after, + &[], + "wss://ws.example", + &Default::default(), + None + ) ); } @@ -184,8 +256,22 @@ fn workspace_relay_change_trips_hash_for_blank_record_relay() { let mut rec = record(); rec.relay_url = String::new(); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()), - spawn_config_hash(&rec, &[], &[], "wss://relay-b.example", &Default::default()) + spawn_config_hash( + &rec, + &[], + &[], + "wss://relay-a.example", + &Default::default(), + None + ), + spawn_config_hash( + &rec, + &[], + &[], + "wss://relay-b.example", + &Default::default(), + None + ) ); } @@ -195,8 +281,22 @@ fn workspace_relay_change_ignored_for_pinned_record_relay() { // a workspace relay change must NOT badge a pinned agent. let rec = record(); assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()), - spawn_config_hash(&rec, &[], &[], "wss://relay-b.example", &Default::default()) + spawn_config_hash( + &rec, + &[], + &[], + "wss://relay-a.example", + &Default::default(), + None + ), + spawn_config_hash( + &rec, + &[], + &[], + "wss://relay-b.example", + &Default::default(), + None + ) ); } @@ -208,8 +308,22 @@ fn cosmetic_pin_difference_does_not_change_hash() { let mut slashed = record(); slashed.relay_url = "WS://Localhost:3000/".into(); assert_eq!( - spawn_config_hash(&record(), &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&slashed, &[], &[], "wss://ws.example", &Default::default()) + spawn_config_hash( + &record(), + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &slashed, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ) ); } @@ -221,13 +335,21 @@ fn cosmetic_workspace_relay_difference_ignored_for_blank_record_relay() { let mut rec = record(); rec.relay_url = String::new(); assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()), + spawn_config_hash( + &rec, + &[], + &[], + "wss://relay-a.example", + &Default::default(), + None + ), spawn_config_hash( &rec, &[], &[], "WSS://Relay-A.Example/", - &Default::default() + &Default::default(), + None ) ); } @@ -239,8 +361,22 @@ fn respond_to_allowlist_edit_changes_hash() { edited.respond_to = RespondTo::Allowlist; edited.respond_to_allowlist = vec!["a".repeat(64)]; assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &edited, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ) ); } @@ -252,8 +388,22 @@ fn allowlist_ignored_when_mode_is_not_allowlist() { let mut edited = record(); edited.respond_to_allowlist = vec!["a".repeat(64)]; assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &edited, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ) ); } @@ -270,8 +420,22 @@ fn allowlist_normalization_equivalent_edits_do_not_change_hash() { "a".repeat(64), // duplicate ]; assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &edited, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ) ); } @@ -283,8 +447,22 @@ fn allowlist_content_edit_still_changes_hash() { let mut edited = rec.clone(); edited.respond_to_allowlist = vec!["b".repeat(64)]; assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &edited, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ) ); } @@ -297,8 +475,22 @@ fn explicit_default_max_turn_duration_does_not_change_hash() { edited.max_turn_duration_seconds = Some(crate::managed_agents::types::DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS); assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &edited, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ) ); } @@ -308,8 +500,22 @@ fn non_default_max_turn_duration_changes_hash() { let mut edited = record(); edited.max_turn_duration_seconds = Some(42); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &edited, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ) ); } @@ -324,8 +530,22 @@ fn non_spawn_bookkeeping_fields_do_not_change_hash() { edited.last_started_at = Some("later".into()); edited.last_exit_code = Some(0); assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &edited, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ) ); } @@ -355,14 +575,16 @@ fn resnapshot_does_not_clobber_record_quad_with_definition_absent_quad() { &quadless_definition, &[], "wss://ws.example", - &Default::default() + &Default::default(), + None ), spawn_config_hash( &rec, &definition_with_quad, &[], "wss://ws.example", - &Default::default() + &Default::default(), + None ), "definition quad must not leak into the spawn hash of an existing instance" ); @@ -378,8 +600,22 @@ fn empty_prompt_hashes_like_absent_prompt() { let mut empty = record(); empty.system_prompt = Some(String::new()); assert_eq!( - spawn_config_hash(&absent, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&empty, &[], &[], "wss://ws.example", &Default::default()), + spawn_config_hash( + &absent, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &empty, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ), ); } @@ -395,8 +631,22 @@ fn definition_runtime_edit_changes_hash_for_materialized_record() { let before = [persona("pers", Some("goose"), "prompt")]; let after = [persona("pers", Some("claude"), "prompt")]; assert_ne!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + spawn_config_hash( + &rec, + &before, + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &rec, + &after, + &[], + "wss://ws.example", + &Default::default(), + None + ), "definition runtime edit must badge a materialized, override-free instance" ); } @@ -413,8 +663,22 @@ fn agent_command_override_beats_definition_runtime_change() { let before = [persona("pers", Some("goose"), "prompt")]; let after = [persona("pers", Some("claude"), "prompt")]; assert_eq!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + spawn_config_hash( + &rec, + &before, + &[], + "wss://ws.example", + &Default::default(), + None + ), + spawn_config_hash( + &rec, + &after, + &[], + "wss://ws.example", + &Default::default(), + None + ), "explicit override must win regardless of definition runtime change" ); } @@ -438,19 +702,89 @@ fn missing_definition_leaves_materialized_runtime_in_hash() { no_personas, &[], "wss://ws.example", - &Default::default() + &Default::default(), + None ), spawn_config_hash( &no_runtime, no_personas, &[], "wss://ws.example", - &Default::default() + &Default::default(), + None ), "materialized runtime must still affect hash when definition is absent" ); } +#[test] +fn workspace_repos_dir_change_trips_hash() { + // Editing the agent's community repos dir changes where a restart would + // put BUZZ_REPOS_DIR → the badge must trip; likewise configuring or + // clearing an override. + let rec = record(); + let base = spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + Some("/Users/me/DevA"), + ); + assert_ne!( + base, + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + Some("/Users/me/DevB"), + ), + "a repos dir edit must badge" + ); + assert_ne!( + base, + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + None + ), + "clearing the override must badge" + ); +} + +#[test] +fn workspace_repos_dir_is_stable_across_recomputes() { + // The map value is keyed by the agent's pinned relay, so a pure workspace + // switch supplies the SAME value on recompute — no spurious badge. This + // pins that equal inputs hash equal (with and without an override). + let rec = record(); + for repos_dir in [None, Some("/Users/me/DevA")] { + assert_eq!( + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + repos_dir, + ), + spawn_config_hash( + &rec, + &[], + &[], + "wss://ws.example", + &Default::default(), + repos_dir, + ), + ); + } +} + #[test] fn effective_spawn_prompt_matches_hash_semantics() { // The env write and the hash share effective_spawn_prompt — this row diff --git a/desktop/src-tauri/src/migration/backfill_tests.rs b/desktop/src-tauri/src/migration/backfill_tests.rs index 9d2731d241..7966f45b75 100644 --- a/desktop/src-tauri/src/migration/backfill_tests.rs +++ b/desktop/src-tauri/src/migration/backfill_tests.rs @@ -137,6 +137,7 @@ fn backfill_of_promptless_record_keeps_spawn_hash_stable() { &[], "wss://ws.example", &Default::default(), + None, ); backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); @@ -153,6 +154,7 @@ fn backfill_of_promptless_record_keeps_spawn_hash_stable() { &[], "wss://ws.example", &Default::default(), + None, ); assert_eq!( @@ -186,6 +188,7 @@ fn backfill_of_prompted_record_keeps_spawn_hash_stable() { &[], "wss://ws.example", &Default::default(), + None, ); backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); @@ -202,6 +205,7 @@ fn backfill_of_prompted_record_keeps_spawn_hash_stable() { &[], "wss://ws.example", &Default::default(), + None, ); assert_eq!(hash_before, hash_after);