Files
buzz/desktop/src-tauri/src/util.rs
T
aee6314484 fix(desktop): strip legacy baked team instructions from stored prompts (#3035)
Agents in a team receive two `Team Instructions` blocks per turn, and
the observer feed renders two Team Instructions cards for them.

There are two producers. `with_team()` in `crates/buzz-acp/src/pool.rs`
appends the LIVE `[Team Instructions]` block from the runtime
`TeamRecord` — that one is correct. The second is baked into the stored
`system_prompt` itself: records written before the runtime framing
landed were composed by the now-removed `compose_prompt()` in
`buzz-persona`, which appended `"\n\n---\n# Team Instructions\n"` plus a
frozen copy of the team instructions. So an affected agent is fed a
stale roster ahead of the current one, and the transcript parser —
correctly — reports both.

Fixing this in `parseSystemPromptSections` would hide the symptom while
the agent kept receiving the stale bytes, so the suffix is removed at
rest by a boot migration.

`strip_baked_team_instructions` splits each stored `system_prompt` at
the LAST occurrence of the exact delimiter and keeps the text before it.
Last-occurrence matches the parser's own `lastIndexOf` guard: a persona
body may quote a delimiter-shaped passage, and only the final one is the
producer boundary. The match is byte-exact — a bare `---`, a `# Team
Instructions` heading at a different position, or a single preceding
newline are author content and are left alone. It applies to every
record regardless of `team_id` / `persona_id` / `pubkey`: the key-less
definition records carry the suffix exactly as the instances minted from
them do. A prompt that was nothing but the suffix becomes `None`, not
`Some("")`, matching the absent-prompt convention in
`AgentDefinition::into_agent_record`.

Stripping a definition's prompt changes its `persona_content_hash`,
which is the drift basis behind the Agents-menu "out of date" badge.
Left alone, every linked instance would light up stale for a change the
user never made. The migration therefore advances the pin of instances
whose `persona_source_version` still equals the definition's PRE-strip
hash — the same conditional `refresh_builtin_agent_avatars` already
uses. An instance that had genuinely drifted keeps its stale pin, and
its badge.

The migration runs after `fold_personas_into_agent_store` so definitions
lifted out of the legacy `personas.json` are cleaned in the same boot,
and before `backfill_standalone_agents` so a manufactured definition
never snapshots a suffix about to be removed. It writes only when at
least one record changed, so a second boot is a true no-op, and takes a
create-if-absent backup at
`managed-agents.json.pre-team-suffix-strip.bak` following the
`pre-backfill.bak` contract — a re-run after a partial failure cannot
replace the pristine backup with a half-migrated snapshot. An
unparseable store errors without writing and without taking a backup,
leaving the file for manual recovery.

Pass 5's legacy branch in `agentSessionTranscriptHelpers.ts` is
deliberately untouched: un-migrated installs and snapshot imports still
need it.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-07-26 19:33:43 -04:00

485 lines
18 KiB
Rust

use chrono::Utc;
pub fn now_iso() -> String {
Utc::now().to_rfc3339()
}
/// Deserialize an `Option<Option<T>>` field that distinguishes an absent key
/// from an explicit JSON `null`.
///
/// Plain serde collapses a present `null` into the outer `None`, making
/// "clear this field" indistinguishable from "leave it unchanged". Paired with
/// `#[serde(default)]`, this yields the tri-state needed for nullable patches:
/// absent → `None`, `null` → `Some(None)`, value → `Some(Some(value))`.
pub fn double_option<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
serde::Deserialize::deserialize(deserializer).map(Some)
}
/// Turn a human-readable name into a filesystem-safe slug.
///
/// Non-alphanumeric characters become hyphens, leading/trailing hyphens are
/// stripped, and the result is capped at `max_len` characters (on a hyphen
/// boundary when possible). Returns `fallback` when the input produces an
/// empty slug.
pub fn slugify(name: &str, fallback: &str, max_len: usize) -> String {
let raw: String = name
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
let raw = if raw.is_empty() { fallback } else { &raw };
let raw = if raw.len() > max_len {
&raw[..max_len]
} else {
raw
};
raw.trim_end_matches('-').to_string()
}
// ── Safe symlink utilities ────────────────────────────────────────────────────
/// Create a symlink at `link` pointing to `target` on Unix; no-op on Windows.
///
/// Worktree sync and nest setup are Unix-only features. This wrapper lets
/// call sites compile and run harmlessly on non-Unix platforms.
#[cfg(unix)]
pub(crate) fn create_symlink(
target: &std::path::Path,
link: &std::path::Path,
) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
/// No-op on non-Unix platforms.
#[cfg(not(unix))]
pub(crate) fn create_symlink(
_target: &std::path::Path,
_link: &std::path::Path,
) -> std::io::Result<()> {
Ok(())
}
/// Returns `true` when `link` is a symlink whose stored target equals `target`.
///
/// Compares the raw stored link value — no canonicalization — so relative
/// targets (e.g. `../../.agents/skills/buzz-cli`) compare correctly against
/// the literal string used to create them.
pub(crate) fn symlink_points_to(link: &std::path::Path, target: &std::path::Path) -> bool {
link.is_symlink()
&& std::fs::read_link(link)
.map(|t| t == target)
.unwrap_or(false)
}
/// Compute a collision-safe backup path for `dst`.
///
/// The candidate is `<parent>/<full-filename>.bak.<ms-timestamp>`. If that
/// path already exists (rare — same-millisecond collision or leftover backup),
/// appends `-2`, `-3`, … up to 100. Returns `None` when all 100 candidates
/// are occupied, indicating a backup failure.
pub(crate) fn backup_path(dst: &std::path::Path) -> Option<std::path::PathBuf> {
let name = dst.file_name()?.to_str()?;
let stamp = Utc::now().format("%Y%m%d-%H%M%S%.3f");
let base = format!("{name}.bak.{stamp}");
let parent = dst.parent()?;
let candidate = parent.join(&base);
if !candidate.exists() {
return Some(candidate);
}
// Collision — try suffixes -2 … -100.
for n in 2u32..=100 {
let candidate = parent.join(format!("{base}-{n}"));
if !candidate.exists() {
return Some(candidate);
}
}
None
}
/// Replace `dst` with a symlink pointing to `src`, backing up any real
/// file or directory at `dst` first.
///
/// Behaviour by what `dst` currently is:
///
/// - **Already a correct symlink** (stored target == `src`): no-op, returns 0.
/// - **Wrong or broken symlink**: remove and replace; no backup — a symlink
/// holds no user data. Returns 1 on success, 0 if removal fails (the
/// subsequent `create_symlink` will surface EEXIST).
/// - **Real file or real directory**: rename to
/// `<full-filename>.bak.<ms-timestamp>` (collision-safe, up to suffix -100).
/// If backup fails, `dst` is left untouched and 0 is returned. If the
/// backup succeeds but `create_symlink` fails, the backup is renamed back to
/// restore `dst`; if that rollback also fails, an actionable error is logged.
/// Returns 1 on success, 0 on any failure.
/// - **Absent**: creates the symlink and returns 1.
#[cfg(unix)]
pub(crate) fn replace_with_symlink(src: &std::path::Path, dst: &std::path::Path) -> u32 {
if dst.is_symlink() {
if symlink_points_to(dst, src) {
return 0;
}
// Wrong or broken symlink — remove and replace, no backup.
if let Err(e) = std::fs::remove_file(dst) {
eprintln!(
"buzz-desktop: symlink-util: failed to remove stale symlink {}: {e}",
dst.display()
);
// Fall through — create_symlink will surface EEXIST.
}
} else if dst.exists() {
// Real file or real directory — back up before replacing.
let label = if dst.is_dir() { "dir" } else { "file" };
let Some(bak) = backup_path(dst) else {
eprintln!(
"buzz-desktop: symlink-util: all backup paths occupied for {}; skipping",
dst.display()
);
return 0;
};
match std::fs::rename(dst, &bak) {
Ok(()) => eprintln!(
"buzz-desktop: symlink-util: backed up real {label} {} → {}",
dst.display(),
bak.display()
),
Err(e) => {
eprintln!(
"buzz-desktop: symlink-util: failed to back up {label} {}: {e}",
dst.display()
);
return 0;
}
}
// Backup succeeded — attempt symlink creation.
if let Err(e) = create_symlink(src, dst) {
eprintln!(
"buzz-desktop: symlink-util: failed to symlink {} → {}: {e}; attempting rollback",
dst.display(),
src.display()
);
if let Err(rb_err) = std::fs::rename(&bak, dst) {
eprintln!(
"buzz-desktop: symlink-util: ROLLBACK FAILED ({rb_err}) — \
{dst_disp} is still at {bak_disp}; \
restore it manually: `mv {bak_disp} {dst_disp}`",
dst_disp = dst.display(),
bak_disp = bak.display(),
);
}
return 0;
}
return 1;
}
// dst was absent or was a symlink (already removed above).
match create_symlink(src, dst) {
Ok(()) => 1,
Err(e) => {
eprintln!(
"buzz-desktop: symlink-util: failed to symlink {} → {}: {e}",
dst.display(),
src.display()
);
0
}
}
}
/// No-op on non-Unix platforms — always returns 0.
#[cfg(not(unix))]
pub(crate) fn replace_with_symlink(_src: &std::path::Path, _dst: &std::path::Path) -> u32 {
0
}
/// Suppress the console window that Windows otherwise allocates for every
/// console-subsystem child process spawned from a GUI (non-console) parent.
///
/// On Windows, a GUI application (no console window of its own) that spawns a
/// child console-subsystem binary gets a fresh, briefly-visible console window
/// per child unless `CREATE_NO_WINDOW` is set. Setting it is a pure no-op on
/// non-Windows platforms, so callers can call this unconditionally.
///
/// **Exclusions**: any command that explicitly wants a visible terminal (e.g.
/// `launch_visible_terminal` which uses `CREATE_NEW_CONSOLE`) must NOT call
/// this helper — it would conflict with the explicit console-creation flag.
pub(crate) fn configure_no_window(command: &mut std::process::Command) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = command;
}
/// Copy `source_bytes` to `path` owner-only, exactly once.
///
/// For the pre-migration backups taken by the migrations in `crate::migration` before they
/// rewrite `managed-agents.json`. That store carries plaintext agent nsecs
/// whenever the keyring is unreachable (`SECURITY.md`), so the backup must be
/// owner-only from the initial open — `fs::write` then `set_permissions` leaves
/// exactly the umask window that [`atomic_write_json_restricted`] sets the mode
/// on the handle to close.
///
/// `create_new` also subsumes the create-if-absent check: `AlreadyExists` is the
/// idempotent success case, so a re-run after a partial failure cannot replace a
/// pristine backup with a half-migrated snapshot, and there is no exists-then-
/// write window between the two. `Ok(false)` means a backup was already there
/// and was left untouched. Every other error propagates, so a caller that fails
/// to back up never proceeds to the live write.
///
/// Non-Unix platforms get `create_new` alone.
///
/// [`atomic_write_json_restricted`]: crate::managed_agents::atomic_write_json_restricted
pub(crate) fn create_restricted_backup_once(
path: &std::path::Path,
source_bytes: &[u8],
) -> Result<bool, String> {
use std::io::Write as _;
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = match options.open(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false),
Err(e) => return Err(format!("create {}: {e}", path.display())),
};
file.write_all(source_bytes)
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(true)
}
/// Derive the sibling backup path for a store, resolving symlinks first.
///
/// Dev worktrees symlink `agents/managed-agents.json` into the shared canonical
/// dev data dir (`sync_shared_agent_data`). [`atomic_write_json_restricted`]
/// canonicalizes before writing, so without the same resolution here the sole
/// recovery copy would land beside the symlink while the data it protects lives
/// elsewhere. Falls back to the given path when `canonicalize` fails — the store
/// may legitimately not exist yet.
///
/// [`atomic_write_json_restricted`]: crate::managed_agents::atomic_write_json_restricted
pub(crate) fn resolved_backup_path(
store_path: &std::path::Path,
backup_name: &str,
) -> std::path::PathBuf {
std::fs::canonicalize(store_path)
.unwrap_or_else(|_| store_path.to_path_buf())
.with_file_name(backup_name)
}
#[cfg(test)]
mod tests {
use super::slugify;
#[test]
fn double_option_tristate() {
#[derive(serde::Deserialize)]
struct P {
#[serde(default, deserialize_with = "super::double_option")]
ttl: Option<Option<i32>>,
}
let absent: P = serde_json::from_str("{}").unwrap();
let null: P = serde_json::from_str(r#"{"ttl": null}"#).unwrap();
let set: P = serde_json::from_str(r#"{"ttl": 3600}"#).unwrap();
assert_eq!(absent.ttl, None);
assert_eq!(null.ttl, Some(None));
assert_eq!(set.ttl, Some(Some(3600)));
}
#[test]
fn slugify_basic() {
assert_eq!(slugify("My Cool Team", "team", 50), "my-cool-team");
}
#[test]
fn slugify_special_chars() {
assert_eq!(slugify("héllo wörld!", "fallback", 50), "h-llo-w-rld");
}
#[test]
fn slugify_empty_uses_fallback() {
assert_eq!(slugify(" ", "persona", 50), "persona");
assert_eq!(slugify("", "team", 50), "team");
}
#[test]
fn slugify_truncates_at_max_len() {
let long_name = "a]".repeat(60);
let result = slugify(&long_name, "fallback", 10);
assert!(result.len() <= 10);
assert!(!result.ends_with('-'));
}
#[test]
fn slugify_trims_trailing_hyphens_after_truncation() {
// "abcde-----fghij" truncated at 10 → "abcde-----" → trimmed → "abcde"
assert_eq!(slugify("abcde fghij", "x", 10), "abcde");
}
// ── symlink_util ──────────────────────────────────────────────────────────
#[cfg(unix)]
#[test]
fn replace_with_symlink_backs_up_real_file_and_creates_symlink() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("source.json");
let dst = dir.path().join("dest.json");
std::fs::write(&src, r#"[{"id":"canonical"}]"#).unwrap();
std::fs::write(&dst, r#"[{"id":"real-local-data"}]"#).unwrap();
let created = super::replace_with_symlink(&src, &dst);
assert_eq!(created, 1);
assert!(dst.is_symlink());
assert_eq!(std::fs::read_link(&dst).unwrap(), src);
let bak_entry = std::fs::read_dir(dir.path()).unwrap().flatten().find(|e| {
e.file_name()
.to_str()
.map(|n| n.starts_with("dest.json.bak."))
.unwrap_or(false)
});
assert!(bak_entry.is_some(), "a .bak.* backup file must exist");
let bak_content = std::fs::read_to_string(bak_entry.unwrap().path()).unwrap();
assert_eq!(bak_content, r#"[{"id":"real-local-data"}]"#);
}
#[cfg(unix)]
#[test]
fn replace_with_symlink_backs_up_real_dir_and_creates_symlink() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("canonical-teams");
let dst = dir.path().join("local-teams");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&dst).unwrap();
std::fs::write(dst.join("stale.txt"), "old-content").unwrap();
let created = super::replace_with_symlink(&src, &dst);
assert_eq!(created, 1);
assert!(dst.is_symlink());
assert_eq!(std::fs::read_link(&dst).unwrap(), src);
let bak_entry = std::fs::read_dir(dir.path()).unwrap().flatten().find(|e| {
e.file_name()
.to_str()
.map(|n| n.starts_with("local-teams.bak."))
.unwrap_or(false)
});
assert!(bak_entry.is_some(), "a .bak.* backup directory must exist");
let bak_path = bak_entry.unwrap().path();
assert!(bak_path.is_dir(), "backup must be a directory");
assert_eq!(
std::fs::read_to_string(bak_path.join("stale.txt")).unwrap(),
"old-content"
);
}
#[cfg(unix)]
#[test]
fn replace_with_symlink_noop_when_already_correct_symlink() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("source.json");
let dst = dir.path().join("dest.json");
std::fs::write(&src, "data").unwrap();
std::os::unix::fs::symlink(&src, &dst).unwrap();
let created = super::replace_with_symlink(&src, &dst);
assert_eq!(created, 0);
assert!(dst.is_symlink());
assert_eq!(std::fs::read_link(&dst).unwrap(), src);
let bak_count = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|e| {
e.file_name()
.to_str()
.map(|n| n.contains(".bak."))
.unwrap_or(false)
})
.count();
assert_eq!(
bak_count, 0,
"no backup should be created for a correct symlink"
);
}
#[cfg(unix)]
#[test]
fn replace_with_symlink_replaces_wrong_symlink_without_backup() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("source.json");
let dst = dir.path().join("dest.json");
let wrong_target = dir.path().join("wrong.json");
std::fs::write(&src, "data").unwrap();
std::fs::write(&wrong_target, "wrong").unwrap();
std::os::unix::fs::symlink(&wrong_target, &dst).unwrap();
let created = super::replace_with_symlink(&src, &dst);
assert_eq!(created, 1);
assert!(dst.is_symlink());
assert_eq!(std::fs::read_link(&dst).unwrap(), src);
let bak_count = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|e| {
e.file_name()
.to_str()
.map(|n| n.contains(".bak."))
.unwrap_or(false)
})
.count();
assert_eq!(bak_count, 0, "symlinks must not produce backups");
}
#[cfg(unix)]
#[test]
fn replace_with_symlink_replaces_broken_symlink_without_backup() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("source.json");
let dst = dir.path().join("dest.json");
std::fs::write(&src, "data").unwrap();
std::os::unix::fs::symlink(dir.path().join("nonexistent.json"), &dst).unwrap();
let created = super::replace_with_symlink(&src, &dst);
assert_eq!(created, 1);
assert!(dst.is_symlink());
assert_eq!(std::fs::read_link(&dst).unwrap(), src);
let bak_count = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|e| {
e.file_name()
.to_str()
.map(|n| n.contains(".bak."))
.unwrap_or(false)
})
.count();
assert_eq!(bak_count, 0, "broken symlinks must not produce backups");
}
}