mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
1317 lines
48 KiB
Rust
1317 lines
48 KiB
Rust
use crate::managed_agents::known_acp_runtime;
|
|
|
|
// ── desktop binary name tests ───────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn appimage_binary_matches_truncated_linux_comm_name() {
|
|
assert!(super::is_desktop_binary("buzz-desktop.bi"));
|
|
}
|
|
|
|
// ── buffer_contains_identifier tests ────────────────────────────────────
|
|
|
|
#[test]
|
|
fn identifier_prefix_does_not_match_longer_id() {
|
|
// DMG identifier should NOT match inside a dev desktop's config JSON.
|
|
let buf = br#""identifier":"xyz.block.buzz.app.dev""#;
|
|
let id = b"xyz.block.buzz.app";
|
|
assert!(!super::buffer_contains_identifier(buf, id));
|
|
}
|
|
|
|
#[test]
|
|
fn identifier_prefix_does_not_match_worktree_slug() {
|
|
// Main dev identifier should NOT match inside a worktree desktop's buffer.
|
|
let buf = br#""identifier":"xyz.block.buzz.app.dev.my-branch""#;
|
|
let id = b"xyz.block.buzz.app.dev";
|
|
assert!(!super::buffer_contains_identifier(buf, id));
|
|
}
|
|
|
|
#[test]
|
|
fn identifier_exact_match_with_quote_boundary() {
|
|
// Exact match followed by closing quote — should match.
|
|
let buf = br#""identifier":"xyz.block.buzz.app.dev""#;
|
|
let id = b"xyz.block.buzz.app.dev";
|
|
assert!(super::buffer_contains_identifier(buf, id));
|
|
}
|
|
|
|
#[test]
|
|
fn identifier_match_with_null_boundary() {
|
|
// In KERN_PROCARGS2, entries are null-delimited.
|
|
let mut buf = b"BUZZ_MANAGED_AGENT=xyz.block.buzz.app.dev".to_vec();
|
|
buf.push(0);
|
|
buf.extend_from_slice(b"OTHER_VAR=value");
|
|
let id = b"xyz.block.buzz.app.dev";
|
|
assert!(super::buffer_contains_identifier(&buf, id));
|
|
}
|
|
|
|
#[test]
|
|
fn identifier_exact_match_at_end_of_buffer() {
|
|
// Exact match with end-of-buffer as the boundary — Thufir's case 1.
|
|
let buf = b"xyz.block.buzz.app.dev";
|
|
let id = b"xyz.block.buzz.app.dev";
|
|
assert!(super::buffer_contains_identifier(buf, id));
|
|
}
|
|
|
|
#[test]
|
|
fn longer_id_matches_when_short_prefix_also_present() {
|
|
// The longer ID still matches when a shorter prefix token appears earlier.
|
|
let mut buf = b"xyz.block.buzz.app".to_vec();
|
|
buf.push(0);
|
|
buf.extend_from_slice(br#""identifier":"xyz.block.buzz.app.dev""#);
|
|
let id = b"xyz.block.buzz.app.dev";
|
|
assert!(super::buffer_contains_identifier(&buf, id));
|
|
}
|
|
|
|
#[test]
|
|
fn identifier_empty_returns_false() {
|
|
let buf = b"anything";
|
|
assert!(!super::buffer_contains_identifier(buf, b""));
|
|
}
|
|
|
|
// ── marker_entry tests ──────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn marker_entry_is_namespaced_by_instance_id() {
|
|
// The spawn stamp and sweep matcher both go through buzz_marker_entry, pinning the on-the-wire
|
|
// format and guards against a dev build (`...app.dev`) matching a
|
|
// release build's (`...app`) agents.
|
|
assert_eq!(
|
|
super::buzz_marker_entry("xyz.block.buzz.app"),
|
|
b"BUZZ_MANAGED_AGENT=xyz.block.buzz.app".to_vec()
|
|
);
|
|
assert_ne!(
|
|
super::buzz_marker_entry("xyz.block.buzz.app"),
|
|
super::buzz_marker_entry("xyz.block.buzz.app.dev")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn buzz_agent_has_mcp_hooks() {
|
|
let p = known_acp_runtime("buzz-agent").expect("should resolve");
|
|
assert!(p.mcp_hooks);
|
|
assert_eq!(p.mcp_command, Some("buzz-dev-mcp"));
|
|
}
|
|
|
|
#[test]
|
|
fn buzz_agent_resolved_via_path() {
|
|
assert!(known_acp_runtime("/usr/local/bin/buzz-agent").is_some_and(|p| p.mcp_hooks));
|
|
}
|
|
|
|
#[test]
|
|
fn codex_has_mcp_command() {
|
|
let p = known_acp_runtime("codex-acp").expect("should resolve");
|
|
assert!(!p.mcp_hooks, "codex-acp does not handle MCP_HOOK_SERVERS");
|
|
assert_eq!(p.mcp_command, Some("buzz-dev-mcp"));
|
|
}
|
|
|
|
#[test]
|
|
fn goose_has_no_mcp_hooks() {
|
|
let p = known_acp_runtime("goose").expect("should resolve");
|
|
assert!(!p.mcp_hooks);
|
|
assert_eq!(p.mcp_command, None);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_command_returns_none() {
|
|
assert!(known_acp_runtime("custom-agent").is_none());
|
|
}
|
|
|
|
// ── build_respond_to_env tests ───────────────────────────────────────
|
|
|
|
use super::build_respond_to_env;
|
|
use crate::managed_agents::types::{ManagedAgentRecord, RespondTo};
|
|
|
|
/// Construct a minimal record fixture for env-building tests. Only the
|
|
/// fields read by `build_respond_to_env` matter here.
|
|
fn fixture(
|
|
respond_to: RespondTo,
|
|
allowlist: Vec<String>,
|
|
auth_tag: Option<String>,
|
|
) -> ManagedAgentRecord {
|
|
ManagedAgentRecord {
|
|
pubkey: "p".into(),
|
|
name: "n".into(),
|
|
persona_id: None,
|
|
private_key_nsec: "nsec1fake".into(),
|
|
auth_tag,
|
|
relay_url: "ws://localhost:3000".into(),
|
|
avatar_url: None,
|
|
acp_command: "buzz-acp".into(),
|
|
agent_command: "goose".into(),
|
|
agent_command_override: None,
|
|
agent_args: vec![],
|
|
mcp_command: String::new(),
|
|
turn_timeout_seconds: 320,
|
|
idle_timeout_seconds: None,
|
|
max_turn_duration_seconds: None,
|
|
parallelism: 1,
|
|
system_prompt: None,
|
|
model: None,
|
|
provider: None,
|
|
persona_source_version: None,
|
|
env_vars: std::collections::BTreeMap::new(),
|
|
start_on_app_launch: false,
|
|
auto_restart_on_config_change: true,
|
|
runtime_pid: None,
|
|
backend: Default::default(),
|
|
backend_agent_id: None,
|
|
provider_binary_path: None,
|
|
team_id: None,
|
|
persona_team_dir: None,
|
|
persona_name_in_team: None,
|
|
created_at: "now".into(),
|
|
updated_at: "now".into(),
|
|
last_started_at: None,
|
|
last_stopped_at: None,
|
|
last_exit_code: None,
|
|
last_error: None,
|
|
last_error_code: None,
|
|
respond_to,
|
|
respond_to_allowlist: allowlist,
|
|
display_name: None,
|
|
slug: None,
|
|
runtime: None,
|
|
name_pool: Vec::new(),
|
|
is_builtin: false,
|
|
is_active: true,
|
|
shared: false,
|
|
source_team: None,
|
|
source_team_persona_slug: None,
|
|
catalog_source: None,
|
|
definition_respond_to: None,
|
|
definition_respond_to_allowlist: Vec::new(),
|
|
definition_parallelism: None,
|
|
relay_mesh: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_owner_only_sets_mode_and_removes_others() {
|
|
let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into()));
|
|
let (set, remove) = build_respond_to_env(&rec, Some("owner")).unwrap();
|
|
let set_map: std::collections::HashMap<_, _> = set.into_iter().collect();
|
|
assert_eq!(
|
|
set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str),
|
|
Some("owner-only")
|
|
);
|
|
assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
|
|
assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
|
|
// auth_tag is present → no AGENT_OWNER fallback fires.
|
|
assert!(remove.contains(&"BUZZ_ACP_AGENT_OWNER"));
|
|
}
|
|
|
|
// select_untracked_bundle_harnesses tests live in runtime/sweep.rs (mod tests).
|
|
|
|
#[test]
|
|
fn build_env_allowlist_sets_both_envs_and_joins() {
|
|
let a = "a".repeat(64);
|
|
let b = "b".repeat(64);
|
|
let rec = fixture(
|
|
RespondTo::Allowlist,
|
|
vec![a.clone(), b.clone()],
|
|
Some("tag".into()),
|
|
);
|
|
let (set, _remove) = build_respond_to_env(&rec, Some("owner")).unwrap();
|
|
let set_map: std::collections::HashMap<_, _> = set.into_iter().collect();
|
|
assert_eq!(
|
|
set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str),
|
|
Some("allowlist")
|
|
);
|
|
assert_eq!(
|
|
set_map
|
|
.get("BUZZ_ACP_RESPOND_TO_ALLOWLIST")
|
|
.map(String::as_str),
|
|
Some(format!("{a},{b}").as_str()),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_anyone_omits_allowlist_var() {
|
|
let rec = fixture(RespondTo::Anyone, vec![], Some("tag".into()));
|
|
let (set, remove) = build_respond_to_env(&rec, Some("owner")).unwrap();
|
|
let set_map: std::collections::HashMap<_, _> = set.into_iter().collect();
|
|
assert_eq!(
|
|
set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str),
|
|
Some("anyone")
|
|
);
|
|
assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
|
|
assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_legacy_record_without_auth_tag_emits_agent_owner() {
|
|
let rec = fixture(RespondTo::OwnerOnly, vec![], None);
|
|
let (set, remove) = build_respond_to_env(&rec, Some("ownerhex")).unwrap();
|
|
let set_map: std::collections::HashMap<_, _> = set.into_iter().collect();
|
|
assert_eq!(
|
|
set_map.get("BUZZ_ACP_AGENT_OWNER").map(String::as_str),
|
|
Some("ownerhex")
|
|
);
|
|
assert!(!remove.contains(&"BUZZ_ACP_AGENT_OWNER"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_legacy_record_without_owner_hex_removes_agent_owner() {
|
|
// No owner available to forward → make sure we don't inherit a leaked
|
|
// env var from the parent.
|
|
let rec = fixture(RespondTo::OwnerOnly, vec![], None);
|
|
let (_set, remove) = build_respond_to_env(&rec, None).unwrap();
|
|
assert!(remove.contains(&"BUZZ_ACP_AGENT_OWNER"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_rejects_corrupted_allowlist() {
|
|
let rec = fixture(
|
|
RespondTo::Allowlist,
|
|
vec!["not-hex".into()],
|
|
Some("tag".into()),
|
|
);
|
|
assert!(build_respond_to_env(&rec, Some("owner")).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn build_env_rejects_empty_allowlist_in_allowlist_mode() {
|
|
let rec = fixture(RespondTo::Allowlist, vec![], Some("tag".into()));
|
|
let err = build_respond_to_env(&rec, Some("owner")).unwrap_err();
|
|
assert!(err.contains("at least one pubkey"));
|
|
}
|
|
|
|
// ── persona fixture helpers ─────────────────────────────────────────
|
|
|
|
fn persona_with_provider(
|
|
id: &str,
|
|
prompt: &str,
|
|
model: Option<&str>,
|
|
provider: Option<&str>,
|
|
) -> crate::managed_agents::AgentDefinition {
|
|
crate::managed_agents::AgentDefinition {
|
|
id: id.to_string(),
|
|
display_name: id.to_string(),
|
|
avatar_url: None,
|
|
system_prompt: prompt.to_string(),
|
|
runtime: None,
|
|
model: model.map(str::to_string),
|
|
provider: provider.map(str::to_string),
|
|
name_pool: Vec::new(),
|
|
is_builtin: false,
|
|
is_active: true,
|
|
shared: false,
|
|
source_team: None,
|
|
source_team_persona_slug: None,
|
|
catalog_source: None,
|
|
env_vars: std::collections::BTreeMap::new(),
|
|
respond_to: None,
|
|
respond_to_allowlist: Vec::new(),
|
|
parallelism: None,
|
|
created_at: "2026-06-09T00:00:00Z".to_string(),
|
|
updated_at: "2026-06-09T00:00:00Z".to_string(),
|
|
}
|
|
}
|
|
|
|
// ── persona env refresh acceptance ──────────────────────────────────────
|
|
//
|
|
// The refresh lifecycle Wes decided: `record.env_vars` holds agent-level
|
|
// overrides only, the live persona env is merged underneath at read time
|
|
// (spawn / readiness / deploy), so persona env edits — like prompt/model/
|
|
// provider — reach the agent on the next spawn without delete+recreate.
|
|
// The merge assertions are load-bearing: they witness the credential refresh
|
|
// that the old create-time env baking silently blocked.
|
|
|
|
use crate::managed_agents::env_vars::{live_persona_env, merged_user_env};
|
|
use std::collections::BTreeMap;
|
|
|
|
/// Apply a persona snapshot onto a record, mirroring `create_managed_agent`:
|
|
/// links the record to `persona.id`, then delegates the actual snapshot
|
|
/// (prompt/model/provider/runtime/source_version) to the real production
|
|
/// `apply_persona_snapshot` — so a change to that function's behavior is
|
|
/// exercised by these tests instead of silently diverging from it.
|
|
fn pin_persona(record: &mut ManagedAgentRecord, persona: &crate::managed_agents::AgentDefinition) {
|
|
record.persona_id = Some(persona.id.clone());
|
|
crate::managed_agents::persona_events::apply_persona_snapshot(record, persona);
|
|
}
|
|
|
|
fn persona_v(
|
|
id: &str,
|
|
prompt: &str,
|
|
env: &[(&str, &str)],
|
|
) -> crate::managed_agents::AgentDefinition {
|
|
let mut p = persona_with_provider(id, prompt, Some("model-v"), Some("anthropic"));
|
|
p.env_vars = env
|
|
.iter()
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
.collect();
|
|
p
|
|
}
|
|
|
|
/// The spawn-time user env for `record` under `personas` — the same
|
|
/// live-persona-under-overrides merge `spawn_agent_child` and
|
|
/// `resolve_effective_agent_env` perform.
|
|
fn spawn_user_env(
|
|
record: &ManagedAgentRecord,
|
|
personas: &[crate::managed_agents::AgentDefinition],
|
|
) -> BTreeMap<String, String> {
|
|
merged_user_env(
|
|
&live_persona_env(personas, record.persona_id.as_deref()),
|
|
&record.env_vars,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn create_keeps_env_vars_as_overrides_only() {
|
|
let p0 = persona_v("p", "prompt-v0", &[("ANTHROPIC_API_KEY", "key-v0")]);
|
|
let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into()));
|
|
pin_persona(&mut record, &p0);
|
|
|
|
assert_eq!(record.system_prompt.as_deref(), Some("prompt-v0"));
|
|
assert_eq!(record.provider.as_deref(), Some("anthropic"));
|
|
assert!(
|
|
record.env_vars.is_empty(),
|
|
"create must NOT bake persona env into the record — overrides only"
|
|
);
|
|
// The credential still reaches the spawned process via the live merge.
|
|
assert_eq!(
|
|
spawn_user_env(&record, std::slice::from_ref(&p0))
|
|
.get("ANTHROPIC_API_KEY")
|
|
.map(String::as_str),
|
|
Some("key-v0"),
|
|
"spawn env must carry the persona credential via the live merge"
|
|
);
|
|
assert!(record.persona_source_version.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn restart_after_persona_edit_refreshes_credential() {
|
|
// Create from P0.
|
|
let p0 = persona_v("p", "prompt-v0", &[("ANTHROPIC_API_KEY", "key-v0")]);
|
|
let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into()));
|
|
pin_persona(&mut record, &p0);
|
|
|
|
// Edit the persona to P1 (prompt + credential change). Restart reuses the
|
|
// SAME record, but spawn merges the live persona env — so the edited
|
|
// credential reaches the next spawn without delete+recreate.
|
|
let p1 = persona_v("p", "prompt-v1", &[("ANTHROPIC_API_KEY", "key-v1")]);
|
|
assert_eq!(
|
|
spawn_user_env(&record, std::slice::from_ref(&p1))
|
|
.get("ANTHROPIC_API_KEY")
|
|
.map(String::as_str),
|
|
Some("key-v1"),
|
|
"restart must pick up the edited credential — the refresh Wes asked for"
|
|
);
|
|
|
|
// The badge flips: the record's snapshot lags the edited persona until the
|
|
// spawn-path re-snapshot runs.
|
|
let (out_of_date, orphaned) = super::persona_drift_state(&record, std::slice::from_ref(&p1));
|
|
assert!(
|
|
out_of_date,
|
|
"edited persona must mark the instance out of date"
|
|
);
|
|
assert!(!orphaned);
|
|
}
|
|
|
|
#[test]
|
|
fn agent_env_overrides_win_over_persona_env_at_spawn() {
|
|
// Agent-level env_vars layer over persona env on collision at read time
|
|
// (persona env < agent env).
|
|
let persona = persona_v("p", "prompt", &[("ANTHROPIC_API_KEY", "persona-key")]);
|
|
let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into()));
|
|
record.env_vars = BTreeMap::from([("ANTHROPIC_API_KEY".to_string(), "agent-key".to_string())]);
|
|
pin_persona(&mut record, &persona);
|
|
|
|
assert_eq!(
|
|
spawn_user_env(&record, std::slice::from_ref(&persona))
|
|
.get("ANTHROPIC_API_KEY")
|
|
.map(String::as_str),
|
|
Some("agent-key"),
|
|
"agent override must win over persona env"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn orphaned_agent_refused_at_spawn_boundary() {
|
|
// Persona deleted: `spawn_agent_child` must refuse before any process
|
|
// side effect, not silently degrade to the record's stale overrides.
|
|
// `require_resolved` on the shared resolver is the pure predicate
|
|
// `spawn_agent_child` checks first — this pins the contract without
|
|
// needing a real `AppHandle`.
|
|
let persona = persona_v("p", "prompt", &[("ANTHROPIC_API_KEY", "persona-key")]);
|
|
let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into()));
|
|
record.env_vars = BTreeMap::from([("EXTRA".to_string(), "agent-value".to_string())]);
|
|
pin_persona(&mut record, &persona);
|
|
|
|
// The persona is absent from the live catalog — same shape restore/start
|
|
// see when a persona was deleted on another device.
|
|
let no_personas: &[crate::managed_agents::AgentDefinition] = &[];
|
|
let error = crate::managed_agents::effective_config::resolve_effective_config(
|
|
&record,
|
|
no_personas,
|
|
&Default::default(),
|
|
)
|
|
.require_resolved()
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
error,
|
|
crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(),
|
|
"an orphaned linked instance must be refused, not spawned from its own overrides"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn self_heal_drops_overrides_equal_to_persona_value() {
|
|
// Pre-refresh records baked persona env in as pseudo-overrides. The
|
|
// spawn-path retain() treats an override equal to the persona's current
|
|
// value as inherited, so later persona edits refresh it.
|
|
let p0 = persona_v("p", "prompt", &[("ANTHROPIC_API_KEY", "key-v0")]);
|
|
let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into()));
|
|
record.env_vars = BTreeMap::from([
|
|
("ANTHROPIC_API_KEY".to_string(), "key-v0".to_string()), // baked-in persona value
|
|
("GENUINE".to_string(), "override".to_string()), // real override
|
|
]);
|
|
pin_persona(&mut record, &p0);
|
|
|
|
// Mirror the start/restore self-heal.
|
|
record.env_vars.retain(|k, v| p0.env_vars.get(k) != Some(v));
|
|
|
|
assert_eq!(
|
|
record.env_vars.get("ANTHROPIC_API_KEY"),
|
|
None,
|
|
"baked-in persona value must be reclassified as inherited"
|
|
);
|
|
assert_eq!(
|
|
record.env_vars.get("GENUINE").map(String::as_str),
|
|
Some("override"),
|
|
"genuine overrides must survive the self-heal"
|
|
);
|
|
|
|
// After the persona edits the key, the healed record refreshes.
|
|
let p1 = persona_v("p", "prompt", &[("ANTHROPIC_API_KEY", "key-v1")]);
|
|
assert_eq!(
|
|
spawn_user_env(&record, std::slice::from_ref(&p1))
|
|
.get("ANTHROPIC_API_KEY")
|
|
.map(String::as_str),
|
|
Some("key-v1"),
|
|
"healed record must inherit the edited persona credential"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deleted_persona_is_orphaned_not_out_of_date() {
|
|
let p0 = persona_v("p", "prompt-v0", &[("KEY", "v0")]);
|
|
let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into()));
|
|
pin_persona(&mut record, &p0);
|
|
|
|
// Persona no longer in the catalog → orphaned, never out of date (no
|
|
// current persona to respawn into).
|
|
let (out_of_date, orphaned) = super::persona_drift_state(&record, &[]);
|
|
assert!(!out_of_date);
|
|
assert!(orphaned);
|
|
}
|
|
|
|
#[test]
|
|
fn non_persona_agent_never_drifts() {
|
|
// A hand-built agent (no persona_id) has nothing to drift from.
|
|
let record = fixture(RespondTo::Anyone, vec![], Some("tag".into()));
|
|
assert_eq!(record.persona_id, None);
|
|
let (out_of_date, orphaned) = super::persona_drift_state(&record, &[]);
|
|
assert!(!out_of_date);
|
|
assert!(!orphaned);
|
|
}
|
|
|
|
use super::runtime_metadata_env_vars;
|
|
|
|
#[test]
|
|
fn runtime_metadata_env_vars_injects_model_and_provider() {
|
|
let vars = runtime_metadata_env_vars(
|
|
Some("GOOSE_MODEL"),
|
|
Some("GOOSE_PROVIDER"),
|
|
false,
|
|
Some("gpt-4o"),
|
|
Some("openai"),
|
|
);
|
|
assert_eq!(
|
|
vars,
|
|
vec![("GOOSE_MODEL", "gpt-4o"), ("GOOSE_PROVIDER", "openai")]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_metadata_env_vars_skips_provider_when_locked() {
|
|
let vars = runtime_metadata_env_vars(
|
|
None, // claude has no model_env_var
|
|
None, // claude has no provider_env_var
|
|
true, // provider_locked = true
|
|
Some("claude-opus-4-7"),
|
|
Some("anthropic"),
|
|
);
|
|
assert!(vars.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_metadata_env_vars_injects_model_even_with_acp_model_switching() {
|
|
// buzz-agent has supports_acp_model_switching=true but we still inject
|
|
// the model env var because ACP model switching is post-bootstrap
|
|
let vars = runtime_metadata_env_vars(
|
|
Some("BUZZ_AGENT_MODEL"),
|
|
Some("BUZZ_AGENT_PROVIDER"),
|
|
false,
|
|
Some("goose-claude-4-6-opus"),
|
|
Some("databricks"),
|
|
);
|
|
assert_eq!(
|
|
vars,
|
|
vec![
|
|
("BUZZ_AGENT_MODEL", "goose-claude-4-6-opus"),
|
|
("BUZZ_AGENT_PROVIDER", "databricks"),
|
|
]
|
|
);
|
|
}
|
|
|
|
// ── name_matches_known_binary / name_matches_interpreter tests ───────────
|
|
|
|
#[test]
|
|
fn name_matches_known_binary_rejects_node() {
|
|
// `node` must NOT be in KNOWN_AGENT_BINARIES — adding it there would
|
|
// sweep all node processes on the machine regardless of ownership.
|
|
assert!(!super::name_matches_known_binary("node"));
|
|
}
|
|
|
|
#[test]
|
|
fn name_matches_interpreter_accepts_node() {
|
|
// `node` IS a known script interpreter and must be recognized.
|
|
assert!(super::name_matches_interpreter("node"));
|
|
}
|
|
|
|
#[test]
|
|
fn name_matches_interpreter_rejects_unknown() {
|
|
// Interpreters not in KNOWN_SCRIPT_INTERPRETERS must not match.
|
|
assert!(!super::name_matches_interpreter("python3"));
|
|
assert!(!super::name_matches_interpreter("deno"));
|
|
assert!(!super::name_matches_interpreter("bun"));
|
|
}
|
|
|
|
#[test]
|
|
fn name_matches_interpreter_rejects_node_prefix() {
|
|
// A name that starts with "node" but is longer must not match —
|
|
// exact equality is required to avoid false positives.
|
|
assert!(!super::name_matches_interpreter("node_modules"));
|
|
assert!(!super::name_matches_interpreter("nodejs"));
|
|
assert!(!super::name_matches_interpreter("node-gyp"));
|
|
}
|
|
|
|
#[test]
|
|
fn claude_spawn_uses_the_probed_cli_executable() {
|
|
let _guard = crate::managed_agents::lock_path_mutex();
|
|
let temp = tempfile::tempdir().expect("temp dir");
|
|
let cli = temp
|
|
.path()
|
|
.join(format!("claude{}", std::env::consts::EXE_SUFFIX));
|
|
std::fs::write(&cli, "").expect("write fake cli");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755))
|
|
.expect("make fake cli executable");
|
|
}
|
|
let original_path = std::env::var_os("PATH");
|
|
std::env::set_var("PATH", temp.path());
|
|
|
|
let mut command = std::process::Command::new("buzz-acp");
|
|
super::configure_runtime_cli(&mut command, super::known_acp_runtime("claude-agent-acp"));
|
|
|
|
if let Some(path) = original_path {
|
|
std::env::set_var("PATH", path);
|
|
} else {
|
|
std::env::remove_var("PATH");
|
|
}
|
|
assert!(command
|
|
.get_envs()
|
|
.any(|(key, value)| { key == "CLAUDE_CODE_EXECUTABLE" && value == Some(cli.as_os_str()) }));
|
|
}
|
|
|
|
#[test]
|
|
fn codex_spawn_does_not_set_a_claude_executable() {
|
|
let mut command = std::process::Command::new("buzz-acp");
|
|
super::configure_runtime_cli(&mut command, super::known_acp_runtime("codex-acp"));
|
|
assert!(!command
|
|
.get_envs()
|
|
.any(|(key, _)| key == "CLAUDE_CODE_EXECUTABLE"));
|
|
}
|
|
|
|
/// On Windows, `.cmd` and `.bat` batch shims must NOT be assigned to
|
|
/// `CLAUDE_CODE_EXECUTABLE` — `CreateProcess` cannot exec them directly and
|
|
/// returns EINVAL (issue #2397). The adapter must fall back to its own PATH
|
|
/// lookup instead.
|
|
///
|
|
/// These tests exercise `is_batch_shim` directly — a pure path predicate with
|
|
/// no global PATH or resolve_command cache involvement — so they run on every
|
|
/// host and cannot be poisoned by the `claude_spawn_uses_the_probed_cli_executable`
|
|
/// test that runs before them.
|
|
#[test]
|
|
fn batch_shim_cmd_extension_is_rejected() {
|
|
assert!(
|
|
super::path::is_batch_shim(std::path::Path::new("claude.cmd")),
|
|
"claude.cmd must be identified as a batch shim"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_shim_cmd_extension_uppercase_is_rejected() {
|
|
assert!(
|
|
super::path::is_batch_shim(std::path::Path::new("claude.CMD")),
|
|
"claude.CMD must be identified as a batch shim (case-insensitive)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_shim_bat_extension_is_rejected() {
|
|
assert!(
|
|
super::path::is_batch_shim(std::path::Path::new("claude.bat")),
|
|
"claude.bat must be identified as a batch shim"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_shim_bat_extension_uppercase_is_rejected() {
|
|
assert!(
|
|
super::path::is_batch_shim(std::path::Path::new("claude.BAT")),
|
|
"claude.BAT must be identified as a batch shim (case-insensitive)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_shim_exe_extension_is_not_rejected() {
|
|
assert!(
|
|
!super::path::is_batch_shim(std::path::Path::new("claude.exe")),
|
|
"claude.exe must not be identified as a batch shim"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn batch_shim_no_extension_is_not_rejected() {
|
|
assert!(
|
|
!super::path::is_batch_shim(std::path::Path::new("claude")),
|
|
"claude (no extension) must not be identified as a batch shim"
|
|
);
|
|
}
|
|
|
|
// ── PGID-based orphan sweep tests ───────────────────────────────────────
|
|
|
|
/// Validates the kernel invariant that the orphan sweep PGID fix relies on:
|
|
/// a grandchild process inherits the PGID of the process group leader (the
|
|
/// harness), so checking PGID membership in `skip_pids` correctly identifies
|
|
/// live descendants even when their ppid is an intermediate process (e.g.
|
|
/// goose) rather than the harness itself.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn grandchild_inherits_pgid_of_process_group_leader() {
|
|
use std::os::unix::process::CommandExt;
|
|
use std::process::Command;
|
|
|
|
// Spawn a "harness" process in its own process group (mirrors
|
|
// `command.process_group(0)` in the real spawn path). The harness
|
|
// spawns an intermediate child which in turn spawns a grandchild.
|
|
// This mirrors the real tree: buzz-acp → goose → buzz-dev-mcp.
|
|
//
|
|
// The intermediate `sh` backgrounds the grandchild and echoes its PID,
|
|
// so the grandchild's ppid is the intermediate (not the harness).
|
|
//
|
|
// The trailing `sleep 10` keeps the harness (the process group leader)
|
|
// alive through the assertions below: without it the harness exits as
|
|
// soon as the intermediate echoes, and under parallel test load it can
|
|
// be reaped before `getpgid(harness_pid)` runs (observed flake —
|
|
// getpgid returned -1). The group is killed in cleanup, so the sleep
|
|
// never runs to term.
|
|
//
|
|
// Absolute `/bin/sh` and `/bin/sleep` rather than bare names: parallel
|
|
// tests holding `lock_path_mutex` legitimately swap PATH to a tempdir,
|
|
// and this test doesn't need the lock — but a PATH lookup during the
|
|
// swap window fails with NotFound, and a child spawned during it
|
|
// inherits the poisoned PATH for its lifetime, so the script's inner
|
|
// lookups must be absolute too (observed flake).
|
|
let mut harness = {
|
|
let mut cmd = Command::new("/bin/sh");
|
|
cmd.args([
|
|
"-c",
|
|
"/bin/sh -c '/bin/sleep 10 & echo $!' & wait $!; /bin/sleep 10",
|
|
])
|
|
.stdout(std::process::Stdio::piped())
|
|
.process_group(0);
|
|
cmd.spawn().expect("spawn harness")
|
|
};
|
|
|
|
// Read the grandchild PID from stdout.
|
|
use std::io::BufRead;
|
|
let stdout = harness.stdout.take().unwrap();
|
|
let reader = std::io::BufReader::new(stdout);
|
|
let grandchild_pid: i32 = reader
|
|
.lines()
|
|
.next()
|
|
.expect("should get a line")
|
|
.expect("should read line")
|
|
.trim()
|
|
.parse()
|
|
.expect("should parse grandchild PID");
|
|
|
|
let harness_pid = harness.id() as i32;
|
|
|
|
// The harness is the process group leader (PGID == its own PID).
|
|
let harness_pgid = unsafe { libc::getpgid(harness_pid) };
|
|
assert_eq!(
|
|
harness_pgid, harness_pid,
|
|
"harness should be its own process group leader"
|
|
);
|
|
|
|
// The grandchild's PGID should equal the harness PID — this is the
|
|
// invariant our orphan sweep fix relies on.
|
|
let grandchild_pgid = unsafe { libc::getpgid(grandchild_pid) };
|
|
assert_eq!(
|
|
grandchild_pgid, harness_pid,
|
|
"grandchild PGID should match harness PID (process group leader)"
|
|
);
|
|
|
|
// The grandchild's ppid is NOT the harness — it's the intermediate sh.
|
|
// This proves the ppid-only check would miss it (the regression path).
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let mut info = std::mem::MaybeUninit::<super::BSDInfo>::zeroed();
|
|
let ret = unsafe {
|
|
super::proc_pidinfo(
|
|
grandchild_pid,
|
|
super::PROC_PIDTBSDINFO,
|
|
0,
|
|
info.as_mut_ptr() as *mut libc::c_void,
|
|
std::mem::size_of::<super::BSDInfo>() as libc::c_int,
|
|
)
|
|
};
|
|
assert!(ret > 0, "proc_pidinfo should succeed for grandchild");
|
|
let info = unsafe { info.assume_init() };
|
|
assert_ne!(
|
|
info.pbi_ppid as i32, harness_pid,
|
|
"grandchild ppid must NOT be the harness (it's the intermediate sh)"
|
|
);
|
|
}
|
|
|
|
// With skip_pids containing the harness PID, the grandchild's PGID
|
|
// is in skip_pids — so it would NOT be flagged as an orphan.
|
|
let skip_pids: Vec<u32> = vec![harness_pid as u32];
|
|
assert!(
|
|
skip_pids.contains(&(grandchild_pgid as u32)),
|
|
"grandchild's PGID should be found in skip_pids"
|
|
);
|
|
|
|
// Cleanup: kill the process group.
|
|
unsafe { libc::kill(-harness_pid, libc::SIGTERM) };
|
|
let _ = harness.wait();
|
|
}
|
|
|
|
/// Validates that `walk_has_tracked_ancestor` catches the production case the
|
|
/// old PGID check missed: the intermediate process is in its OWN process group
|
|
/// (mirroring the node npm-shim wrapper that starts `codex-acp`). The
|
|
/// grandchild's PGID matches the intermediate's PID, not the harness's — so
|
|
/// `skip_pids.contains(&grandchild_pgid)` returns false. The ancestor walk
|
|
/// must still find the harness as an ancestor and return true.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn own_group_grandchild_detected_by_ancestor_walk() {
|
|
use std::os::unix::process::CommandExt;
|
|
use std::process::Command;
|
|
|
|
// The test process is the "harness". Spawn an intermediate with its own
|
|
// process group (mirrors the node shim). It backgrounds a grandchild
|
|
// (sleep 30) and prints the grandchild PID so we can inspect it.
|
|
//
|
|
// Absolute `/bin/sh` and `/bin/sleep` — no PATH lookups anywhere in this
|
|
// tree. Parallel tests holding `lock_path_mutex` legitimately swap PATH to
|
|
// a tempdir; the outer spawn during that window fails with NotFound, and a
|
|
// child spawned during it inherits the poisoned PATH for its lifetime, so
|
|
// inner lookups must be absolute too (observed flake).
|
|
let mut intermediate = {
|
|
let mut cmd = Command::new("/bin/sh");
|
|
cmd.args(["-c", "/bin/sleep 30 & echo $!; wait"])
|
|
.stdout(std::process::Stdio::piped())
|
|
.process_group(0);
|
|
cmd.spawn().expect("spawn intermediate")
|
|
};
|
|
|
|
use std::io::BufRead;
|
|
let stdout = intermediate.stdout.take().unwrap();
|
|
let reader = std::io::BufReader::new(stdout);
|
|
let grandchild_pid: u32 = reader
|
|
.lines()
|
|
.next()
|
|
.expect("should get a line")
|
|
.expect("should read line")
|
|
.trim()
|
|
.parse()
|
|
.expect("should parse grandchild PID");
|
|
|
|
let intermediate_pid = intermediate.id();
|
|
let harness_pid = std::process::id();
|
|
|
|
// The intermediate is its own process group leader.
|
|
let intermediate_pgid = unsafe { libc::getpgid(intermediate_pid as i32) };
|
|
assert_eq!(
|
|
intermediate_pgid, intermediate_pid as i32,
|
|
"intermediate should be its own process group leader"
|
|
);
|
|
|
|
// The grandchild inherits the intermediate's group — NOT the harness's.
|
|
let grandchild_pgid = unsafe { libc::getpgid(grandchild_pid as i32) };
|
|
assert_eq!(
|
|
grandchild_pgid, intermediate_pid as i32,
|
|
"grandchild PGID should be the intermediate, not the harness"
|
|
);
|
|
assert_ne!(
|
|
grandchild_pgid, harness_pid as i32,
|
|
"grandchild PGID must not equal harness PID — this is the false-positive shape"
|
|
);
|
|
|
|
// The ancestor walk finds the harness even though PGID doesn't match it.
|
|
let skip_pids = vec![harness_pid];
|
|
let found =
|
|
super::sweep::walk_has_tracked_ancestor(grandchild_pid, &skip_pids, super::sweep::ppid_of);
|
|
assert!(
|
|
found,
|
|
"walk must detect grandchild as a live descendant of the tracked harness"
|
|
);
|
|
|
|
// Contrast: empty skip_pids → not a descendant of any tracked harness.
|
|
let not_found =
|
|
super::sweep::walk_has_tracked_ancestor(grandchild_pid, &[], super::sweep::ppid_of);
|
|
assert!(
|
|
!not_found,
|
|
"walk with empty skip_pids must return false for a real orphan"
|
|
);
|
|
|
|
// Guard against PID reuse: verify the intermediate is still alive before
|
|
// cleanup so a recycled PID can't corrupt the kill target.
|
|
assert!(
|
|
intermediate
|
|
.try_wait()
|
|
.expect("try_wait on intermediate")
|
|
.is_none(),
|
|
"intermediate exited before cleanup — its PID may have been recycled"
|
|
);
|
|
|
|
// Cleanup: SIGKILL the intermediate's process group (takes sleep 30 with it).
|
|
unsafe { libc::kill(-(intermediate_pid as i32), libc::SIGKILL) };
|
|
let _ = intermediate.wait();
|
|
}
|
|
|
|
// ── pair receipt validation tests ───────────────────────────────────────
|
|
|
|
fn receipt_fixture(
|
|
key: crate::managed_agents::ManagedAgentRuntimeKey,
|
|
) -> crate::managed_agents::ManagedAgentRuntimeReceipt {
|
|
crate::managed_agents::ManagedAgentRuntimeReceipt {
|
|
key,
|
|
pid: std::process::id(),
|
|
desktop_instance_id: "test-instance".into(),
|
|
started_at: "now".into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn receipt_validation_rejects_noncanonical_identity() {
|
|
let mut receipt = receipt_fixture(
|
|
crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example")
|
|
.unwrap(),
|
|
);
|
|
receipt.key.relay_url = "WSS://RELAY.EXAMPLE/".into();
|
|
let path = std::path::PathBuf::from(format!("{}.json", receipt.key.runtime_id()));
|
|
assert!(!super::valid_agent_runtime_receipt(
|
|
&path,
|
|
&receipt,
|
|
"test-instance"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn receipt_validation_rejects_wrong_pair_filename() {
|
|
let receipt = receipt_fixture(
|
|
crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example")
|
|
.unwrap(),
|
|
);
|
|
assert!(!super::valid_agent_runtime_receipt(
|
|
std::path::Path::new("corrupted.json"),
|
|
&receipt,
|
|
"test-instance"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn replacement_removes_receipt_only_after_confirmed_exit() {
|
|
use std::cell::{Cell, RefCell};
|
|
|
|
let receipt = receipt_fixture(
|
|
crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example")
|
|
.unwrap(),
|
|
);
|
|
let path = std::path::Path::new("pair.json");
|
|
let terminated = Cell::new(None);
|
|
let polls = Cell::new(0);
|
|
let removed = RefCell::new(None);
|
|
|
|
super::terminate_runtime_receipt_with(
|
|
path,
|
|
&receipt,
|
|
|pid| {
|
|
terminated.set(Some(pid));
|
|
Ok(())
|
|
},
|
|
|_| {
|
|
let poll = polls.get() + 1;
|
|
polls.set(poll);
|
|
poll < 2
|
|
},
|
|
|path| *removed.borrow_mut() = Some(path.to_path_buf()),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(terminated.get(), Some(receipt.pid));
|
|
assert_eq!(polls.get(), 2);
|
|
assert_eq!(removed.into_inner().as_deref(), Some(path));
|
|
}
|
|
|
|
#[test]
|
|
fn replacement_failure_keeps_receipt() {
|
|
use std::cell::Cell;
|
|
|
|
let receipt = receipt_fixture(
|
|
crate::managed_agents::ManagedAgentRuntimeKey::new("aa".repeat(32), "wss://relay.example")
|
|
.unwrap(),
|
|
);
|
|
let removed = Cell::new(false);
|
|
let error = super::terminate_runtime_receipt_with(
|
|
std::path::Path::new("pair.json"),
|
|
&receipt,
|
|
|_| Err("signal failed".into()),
|
|
|_| false,
|
|
|_| removed.set(true),
|
|
)
|
|
.unwrap_err();
|
|
|
|
assert_eq!(error, "signal failed");
|
|
assert!(!removed.get());
|
|
}
|
|
|
|
// ── workspace pair-key resolution (summary/stop scoping) ────────────────
|
|
|
|
#[test]
|
|
fn unpinned_record_resolves_pair_key_per_workspace() {
|
|
// Community-scoped truth: an unpinned agent running only on relay A must
|
|
// read as running in workspace A and stopped in workspace B — the pair
|
|
// key the summary looks up differs per workspace.
|
|
let pubkey = "aa".repeat(32);
|
|
let key_a = super::resolve_workspace_pair_key(&pubkey, "", "wss://one.example").unwrap();
|
|
let key_b = super::resolve_workspace_pair_key(&pubkey, "", "wss://two.example").unwrap();
|
|
|
|
let runtimes = std::collections::HashMap::from([(key_a.clone(), ())]);
|
|
assert!(runtimes.contains_key(&key_a));
|
|
assert!(!runtimes.contains_key(&key_b));
|
|
}
|
|
|
|
#[test]
|
|
fn stored_relay_pin_is_ignored_in_pair_key_resolution() {
|
|
// Legacy pins are ignored (#2122): a record carrying a creation-era
|
|
// `relay_url` resolves the same per-workspace pair key an unpinned record
|
|
// does, so summaries/stop act on the community being viewed.
|
|
let pubkey = "aa".repeat(32);
|
|
let from_a =
|
|
super::resolve_workspace_pair_key(&pubkey, "wss://pinned.example", "wss://one.example")
|
|
.unwrap();
|
|
let from_b =
|
|
super::resolve_workspace_pair_key(&pubkey, "wss://pinned.example", "wss://two.example")
|
|
.unwrap();
|
|
assert_ne!(from_a, from_b);
|
|
assert_eq!(from_a.relay_url, "wss://one.example");
|
|
assert_eq!(from_b.relay_url, "wss://two.example");
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_pair_key_is_canonical() {
|
|
// Spawn stamps the canonical key; lookup must hit the same entry even
|
|
// when the workspace relay is written in a non-canonical form.
|
|
let pubkey = "aa".repeat(32);
|
|
let stamped = super::resolve_workspace_pair_key(&pubkey, "", "wss://one.example").unwrap();
|
|
let viewed = super::resolve_workspace_pair_key(&pubkey, "", "WSS://One.Example:443/").unwrap();
|
|
assert_eq!(stamped, viewed);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_pubkey_resolves_no_pair_key() {
|
|
// Key-less records (keys minted on first start) cannot form a pair key;
|
|
// the summary must fall back to the stopped/legacy-pid path, not panic.
|
|
assert!(super::resolve_workspace_pair_key("not-a-key", "", "wss://one.example").is_none());
|
|
}
|
|
|
|
// ── Custom-harness orphan sweep coverage ─────────────────────────────────────
|
|
//
|
|
// The sweep/receipt ownership gate must include any process carrying the
|
|
// `BUZZ_MANAGED_AGENT` env marker, regardless of whether the binary name
|
|
// matches `KNOWN_AGENT_BINARIES`. Custom harnesses use arbitrary binary names
|
|
// so name-match alone would silently leak their orphans on crash.
|
|
//
|
|
// Previously: macOS used a two-check OR+AND pattern (equivalent to just marker),
|
|
// Linux used an AND-gate (name + marker) — wrong for custom harnesses.
|
|
// Fix: all platforms gate on `process_has_buzz_marker` alone; the receipt path
|
|
// is verified below via `valid_agent_runtime_receipt_with` (injectable),
|
|
// which no longer takes a name-check predicate at all — reinstating an
|
|
// AND-gate would be a signature change these tests would catch.
|
|
|
|
// ── Collector-discriminating sweep tests (C-9 / Thufir F6) ──────────────────
|
|
//
|
|
// `kill_stale_tracked_processes_with` and `valid_agent_runtime_receipt_with`
|
|
// accept injectable predicates so the sweep logic can be verified without
|
|
// spawning real processes. These tests drive the injection path directly,
|
|
// discriminating on custom-harness vs known-binary vs marker presence.
|
|
|
|
#[test]
|
|
fn kill_stale_custom_harness_with_marker_is_terminated() {
|
|
// A record with a PID not in the live runtime map and with the Buzz marker
|
|
// should be terminated even though the binary name is not in KNOWN_AGENT_BINARIES.
|
|
let mut record = minimal_record("pubkey-custom");
|
|
record.runtime_pid = Some(9001);
|
|
let mut records = vec![record];
|
|
let runtimes = std::collections::HashMap::new();
|
|
|
|
let mut killed = vec![];
|
|
let changed = super::kill_stale_tracked_processes_with(
|
|
&mut records,
|
|
&runtimes,
|
|
|_pid| true, // simulate: marker present (custom harness we own)
|
|
|pid| {
|
|
killed.push(pid);
|
|
Ok(())
|
|
},
|
|
);
|
|
|
|
assert!(changed, "stale record with marker should mark changed");
|
|
assert_eq!(killed, vec![9001u32], "marked process must be killed");
|
|
assert!(
|
|
records[0].runtime_pid.is_none(),
|
|
"runtime_pid must be cleared"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn kill_stale_process_without_marker_is_skipped() {
|
|
// A record PID without the marker (not our process — e.g. custom binary
|
|
// from another tool) should be skipped for termination but still cleared.
|
|
let mut record = minimal_record("pubkey-foreign");
|
|
record.runtime_pid = Some(9002);
|
|
let mut records = vec![record];
|
|
let runtimes = std::collections::HashMap::new();
|
|
|
|
let mut killed = vec![];
|
|
let changed = super::kill_stale_tracked_processes_with(
|
|
&mut records,
|
|
&runtimes,
|
|
|_pid| false, // simulate: no marker (not our process)
|
|
|pid| {
|
|
killed.push(pid);
|
|
Ok(())
|
|
},
|
|
);
|
|
|
|
assert!(
|
|
changed,
|
|
"stale record without marker should still mark changed"
|
|
);
|
|
assert!(
|
|
killed.is_empty(),
|
|
"process without marker must not be killed"
|
|
);
|
|
assert!(
|
|
records[0].runtime_pid.is_none(),
|
|
"runtime_pid must be cleared regardless"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn kill_stale_live_pair_is_not_touched() {
|
|
// A record whose PID is in the live runtime map is NOT stale — skip it entirely.
|
|
use crate::managed_agents::ManagedAgentRuntimeKey;
|
|
let pubkey = "aa".repeat(32); // 64 hex chars — satisfies ManagedAgentRuntimeKey validation
|
|
let mut record = minimal_record(&pubkey);
|
|
record.runtime_pid = Some(9003);
|
|
let key = ManagedAgentRuntimeKey::new(pubkey, "wss://relay.example").unwrap();
|
|
let mut runtimes = std::collections::HashMap::new();
|
|
// Insert a placeholder runtime — value shape doesn't matter for the key lookup.
|
|
runtimes.insert(key, make_pair_runtime_placeholder());
|
|
let original_pid = record.runtime_pid;
|
|
let mut records = vec![record];
|
|
|
|
let mut killed = vec![];
|
|
let changed = super::kill_stale_tracked_processes_with(
|
|
&mut records,
|
|
&runtimes,
|
|
|_pid| true,
|
|
|pid| {
|
|
killed.push(pid);
|
|
Ok(())
|
|
},
|
|
);
|
|
|
|
assert!(!changed, "live pair must not be marked changed");
|
|
assert!(killed.is_empty(), "live pair process must not be killed");
|
|
assert_eq!(
|
|
records[0].runtime_pid, original_pid,
|
|
"live pair runtime_pid must not be cleared"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn receipt_valid_with_marker_and_running() {
|
|
// Custom harness receipt: custom binary (not in KNOWN_AGENT_BINARIES), has_marker=true, is_running=true.
|
|
// Must be valid — marker is the authoritative gate.
|
|
use crate::managed_agents::ManagedAgentRuntimeKey;
|
|
let key = ManagedAgentRuntimeKey::new("bb".repeat(32), "wss://relay.example").unwrap();
|
|
let receipt = receipt_fixture(key.clone());
|
|
let path = std::path::PathBuf::from(format!("{}.json", key.runtime_id()));
|
|
|
|
let valid = super::valid_agent_runtime_receipt_with(
|
|
&path,
|
|
&receipt,
|
|
"test-instance",
|
|
|_pid| true, // is_running
|
|
|_pid, _iid| true, // has_marker: true (custom binary — no name gate)
|
|
);
|
|
assert!(
|
|
valid,
|
|
"custom harness with marker and running pid must be valid"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn receipt_invalid_known_binary_without_marker() {
|
|
// Known binary name but no marker — stray process, must not be valid.
|
|
use crate::managed_agents::ManagedAgentRuntimeKey;
|
|
let key = ManagedAgentRuntimeKey::new("cc".repeat(32), "wss://relay.example").unwrap();
|
|
let receipt = receipt_fixture(key.clone());
|
|
let path = std::path::PathBuf::from(format!("{}.json", key.runtime_id()));
|
|
|
|
let valid = super::valid_agent_runtime_receipt_with(
|
|
&path,
|
|
&receipt,
|
|
"test-instance",
|
|
|_pid| true, // is_running
|
|
|_pid, _iid| false, // has_marker: false (not our process)
|
|
);
|
|
assert!(!valid, "known binary without marker must not be valid");
|
|
}
|
|
|
|
#[test]
|
|
fn receipt_invalid_when_process_not_running() {
|
|
// Even with marker, a non-running process must not be valid.
|
|
use crate::managed_agents::ManagedAgentRuntimeKey;
|
|
let key = ManagedAgentRuntimeKey::new("dd".repeat(32), "wss://relay.example").unwrap();
|
|
let receipt = receipt_fixture(key.clone());
|
|
let path = std::path::PathBuf::from(format!("{}.json", key.runtime_id()));
|
|
|
|
let valid = super::valid_agent_runtime_receipt_with(
|
|
&path,
|
|
&receipt,
|
|
"test-instance",
|
|
|_pid| false, // is_running: false
|
|
|_pid, _iid| true, // has_marker
|
|
);
|
|
assert!(
|
|
!valid,
|
|
"non-running process must not be valid regardless of marker"
|
|
);
|
|
}
|
|
|
|
// ── Test helpers ────────────────────────────────────────────────────────────
|
|
|
|
fn minimal_record(pubkey: &str) -> crate::managed_agents::ManagedAgentRecord {
|
|
serde_json::from_str(&format!(
|
|
r#"{{
|
|
"pubkey": "{pubkey}",
|
|
"name": "test",
|
|
"private_key_nsec": "nsec1fake",
|
|
"relay_url": "",
|
|
"acp_command": "buzz-acp",
|
|
"agent_command": "buzz-agent",
|
|
"agent_args": [],
|
|
"mcp_command": "",
|
|
"turn_timeout_seconds": 320,
|
|
"system_prompt": null,
|
|
"model": null,
|
|
"provider": null,
|
|
"env_vars": {{}},
|
|
"created_at": "2026-01-01T00:00:00Z",
|
|
"updated_at": "2026-01-01T00:00:00Z",
|
|
"last_started_at": null,
|
|
"last_stopped_at": null,
|
|
"last_exit_code": null,
|
|
"last_error": null
|
|
}}"#
|
|
))
|
|
.expect("minimal_record fixture")
|
|
}
|
|
|
|
fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRuntime {
|
|
use std::process::{Command, Stdio};
|
|
// Spawn a real child so ManagedAgentProcess's Child field is satisfied.
|
|
// `true` exits immediately with 0 — just a handle we need for type purposes.
|
|
//
|
|
// Absolute `/usr/bin/true` on unix (present on both macOS and Linux):
|
|
// parallel tests holding `lock_path_mutex` swap PATH to a tempdir, and a
|
|
// bare `true` lookup during that window fails with NotFound (observed
|
|
// flake). Windows keeps the PATH lookup — no test there swaps PATH.
|
|
#[cfg(unix)]
|
|
let program = "/usr/bin/true";
|
|
#[cfg(windows)]
|
|
let program = "true";
|
|
let child = Command::new(program)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("spawn true for placeholder");
|
|
let process = crate::managed_agents::ManagedAgentProcess {
|
|
child,
|
|
log_path: std::path::PathBuf::new(),
|
|
spawn_config_hash: 0,
|
|
setup_mode: false,
|
|
adapter_availability: None,
|
|
start_nonce: "test-nonce".to_string(),
|
|
#[cfg(windows)]
|
|
job: None,
|
|
};
|
|
crate::managed_agents::ManagedAgentPairRuntime::starting(process)
|
|
}
|
|
|
|
// ── restart_eligible tests ──────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn restart_eligible_true_when_non_orphan_has_hash_drift() {
|
|
assert!(super::restart_eligible(false, true, false));
|
|
}
|
|
|
|
#[test]
|
|
fn restart_eligible_true_when_non_orphan_has_availability_drift() {
|
|
assert!(super::restart_eligible(false, false, true));
|
|
}
|
|
|
|
#[test]
|
|
fn restart_eligible_false_when_orphan_has_hash_drift() {
|
|
// An orphan can never be restarted successfully — spawn refuses it —
|
|
// so hash drift alone must not surface "Restart required".
|
|
assert!(!super::restart_eligible(true, true, false));
|
|
}
|
|
|
|
#[test]
|
|
fn restart_eligible_false_when_orphan_has_availability_drift() {
|
|
assert!(!super::restart_eligible(true, false, true));
|
|
}
|
|
|
|
#[test]
|
|
fn restart_eligible_false_when_orphan_has_no_drift() {
|
|
assert!(!super::restart_eligible(true, false, false));
|
|
}
|
|
|
|
#[test]
|
|
fn restart_eligible_false_when_non_orphan_has_no_drift() {
|
|
assert!(!super::restart_eligible(false, false, false));
|
|
}
|