fix(desktop): hash spawn config as the env receives it, not raw record fields (#1621)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-08 07:08:08 -07:00
committed by GitHub
co-authored by Pinky
parent a609e796bd
commit f0eb4eccd9
4 changed files with 122 additions and 4 deletions
@@ -1752,7 +1752,7 @@ pub fn spawn_agent_child(
if let Some(toolsets) = &record.mcp_toolsets {
command.env("BUZZ_TOOLSETS", toolsets);
} else {
command.env("BUZZ_TOOLSETS", "default,canvas,forums,dms,media");
command.env("BUZZ_TOOLSETS", super::types::DEFAULT_MCP_TOOLSETS);
}
command.env_remove("BUZZ_ACP_PRIVATE_KEY");
command.env_remove("BUZZ_ACP_API_TOKEN");
@@ -92,11 +92,31 @@ pub(crate) fn spawn_config_hash(
record.provider.hash(&mut hasher);
record.auth_tag.hash(&mut hasher);
record.respond_to.as_str().hash(&mut hasher);
record.respond_to_allowlist.hash(&mut hasher);
// The allowlist is hashed as the env receives it: spawn sets
// BUZZ_ACP_RESPOND_TO_ALLOWLIST only in allowlist mode, and normalized
// (trim/lowercase/dedup via `validate_respond_to_allowlist`) — so edits
// that don't survive normalization, or edits while another mode is
// active, must not badge. A list spawn would reject hashes raw: the
// stamped hash comes from a successful spawn, so any invalid edit
// correctly compares unequal.
if record.respond_to == super::types::RespondTo::Allowlist {
super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)
.unwrap_or_else(|_| record.respond_to_allowlist.clone())
.hash(&mut hasher);
}
record.idle_timeout_seconds.hash(&mut hasher);
record.max_turn_duration_seconds.hash(&mut hasher);
// Spawn writes BUZZ_ACP_MAX_TURN_DURATION and BUZZ_TOOLSETS with defaults
// filled in, so None and an explicit default are the same spawned value.
record
.max_turn_duration_seconds
.unwrap_or(super::types::DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS)
.hash(&mut hasher);
record.parallelism.hash(&mut hasher);
record.mcp_toolsets.hash(&mut hasher);
record
.mcp_toolsets
.as_deref()
.unwrap_or(super::types::DEFAULT_MCP_TOOLSETS)
.hash(&mut hasher);
record.persona_team_dir.hash(&mut hasher);
record.persona_name_in_team.hash(&mut hasher);
@@ -1,4 +1,5 @@
use super::*;
use crate::managed_agents::types::RespondTo;
use std::collections::BTreeMap;
fn record() -> ManagedAgentRecord {
@@ -183,6 +184,7 @@ fn workspace_relay_change_ignored_for_pinned_record_relay() {
fn respond_to_allowlist_edit_changes_hash() {
let rec = record();
let mut edited = record();
edited.respond_to = RespondTo::Allowlist;
edited.respond_to_allowlist = vec!["a".repeat(64)];
assert_ne!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
@@ -190,6 +192,99 @@ fn respond_to_allowlist_edit_changes_hash() {
);
}
#[test]
fn allowlist_ignored_when_mode_is_not_allowlist() {
// Spawn only sets BUZZ_ACP_RESPOND_TO_ALLOWLIST in allowlist mode, so
// editing the (dormant) list under owner-only must not badge.
let rec = record();
let mut edited = record();
edited.respond_to_allowlist = vec!["a".repeat(64)];
assert_eq!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}
#[test]
fn allowlist_normalization_equivalent_edits_do_not_change_hash() {
// The env receives the normalized list (trim/lowercase/dedup), so edits
// that normalize to the same value must not badge.
let mut rec = record();
rec.respond_to = RespondTo::Allowlist;
rec.respond_to_allowlist = vec!["a".repeat(64)];
let mut edited = rec.clone();
edited.respond_to_allowlist = vec![
format!(" {} ", "A".repeat(64)), // whitespace + case
"a".repeat(64), // duplicate
];
assert_eq!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}
#[test]
fn allowlist_content_edit_still_changes_hash() {
let mut rec = record();
rec.respond_to = RespondTo::Allowlist;
rec.respond_to_allowlist = vec!["a".repeat(64)];
let mut edited = rec.clone();
edited.respond_to_allowlist = vec!["b".repeat(64)];
assert_ne!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}
#[test]
fn explicit_default_max_turn_duration_does_not_change_hash() {
// Spawn writes BUZZ_ACP_MAX_TURN_DURATION with the default filled in, so
// None → Some(default) is the same spawned value and must not badge.
let rec = record();
let mut edited = record();
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"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}
#[test]
fn non_default_max_turn_duration_changes_hash() {
let rec = record();
let mut edited = record();
edited.max_turn_duration_seconds = Some(42);
assert_ne!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}
#[test]
fn explicit_default_toolsets_do_not_change_hash() {
// Spawn falls BUZZ_TOOLSETS back to the default set, so None → an
// explicit copy of the default is the same spawned value.
let rec = record();
let mut edited = record();
edited.mcp_toolsets = Some(crate::managed_agents::types::DEFAULT_MCP_TOOLSETS.to_string());
assert_eq!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}
#[test]
fn non_default_toolsets_change_hash() {
let rec = record();
let mut edited = record();
edited.mcp_toolsets = Some("default,canvas".to_string());
assert_ne!(
spawn_config_hash(&rec, &[], "wss://ws.example"),
spawn_config_hash(&edited, &[], "wss://ws.example")
);
}
#[test]
fn non_spawn_bookkeeping_fields_do_not_change_hash() {
// updated_at / runtime_pid / last_* are lifecycle bookkeeping, not spawn
@@ -768,6 +768,9 @@ pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 320;
/// 1 hour — absolute wall-clock safety cap per turn.
pub const DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS: u64 = 3600;
pub const DEFAULT_AGENT_PARALLELISM: u32 = 24;
/// Toolsets injected as `BUZZ_TOOLSETS` when the record doesn't pin its own —
/// single source of truth for the spawn env and the spawn-config hash.
pub const DEFAULT_MCP_TOOLSETS: &str = "default,canvas,forums,dms,media";
fn default_agent_parallelism() -> u32 {
DEFAULT_AGENT_PARALLELISM