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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-07-16 14:20:27 +10:00
committed by npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent ee40613898
commit 81cd8d0dfc
10 changed files with 1334 additions and 560 deletions

View File

@@ -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

View File

@@ -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::*;

View File

@@ -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.

View File

@@ -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

View File

@@ -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<String, String> {
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::<BTreeMap<String, String>>(&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<String, String>) -> 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<bool, String> {
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<String> {
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<PathBuf, String> {
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;

View File

@@ -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()
}
}

View File

@@ -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::<crate::app_state::AppState>();
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

View File

@@ -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);

View File

@@ -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

View File

@@ -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);