diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 811253e4ac..8cca85a096 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -885,6 +885,9 @@ fn handle_relay_observer_control_event( Some("switch_model") => { handle_switch_model_control(&payload, pool, observer); } + Some("set_config_option") => { + handle_set_config_option_control(&payload, observer); + } _ => { tracing::debug!(payload = %payload, "ignoring unknown observer control frame"); } @@ -914,29 +917,16 @@ fn handle_cancel_turn_control( None, &observer::ObserverContext { channel_id: Some(channel_id.to_string()), - session_id: None, - turn_id: None, - started_at: None, + ..Default::default() }, - serde_json::json!({ - "type": "cancel_turn", - "status": status, - }), + serde_json::json!({"type": "cancel_turn", "status": status}), ); } } -/// Handle a `switch_model` control frame (Phase 3a, Option ii). -/// -/// Busy path: deliver `SwitchModel` over the in-flight task's oneshot — the -/// task cancels the turn, sets `desired_model`, and requeues the batch so it -/// re-runs on a fresh session under the new model. A catalog miss surfaces -/// post-cancel via `create_session_and_apply_model` (the turn restarts on the -/// unchanged model + an `unsupported_model` result). -/// -/// Idle path: validate against the cached catalog *before* invalidating -/// (pre-cancel guard), then set `desired_model` + invalidate. The override -/// takes visible effect on the agent's next turn. +/// Handle a `switch_model` control frame. Busy path: deliver `SwitchModel` +/// over the in-flight task oneshot so it cancels + requeues on the new model. +/// Idle path: validate against catalog then set `desired_model` + invalidate. fn handle_switch_model_control( payload: &serde_json::Value, pool: &mut AgentPool, @@ -955,18 +945,14 @@ fn handle_switch_model_control( return; }; - // A turn is in flight for this channel iff a task_map entry exists. The - // agent is moved out of the pool during a turn, so the control oneshot is - // the only reachable lever; an idle channel has no such entry. + // A turn is in flight iff a task_map entry exists for this channel. let turn_in_flight = pool .task_map() .values() .any(|m| m.channel_id == Some(channel_id)); let status = if turn_in_flight { - // Busy path: deliver over the oneshot. `false` means the oneshot was - // already consumed this turn (a prior cancel/interrupt) — the turn is - // already ending, so the switch cannot land on it. + // Busy path: deliver over the oneshot (`false` = oneshot already consumed, turn ending). if signal_in_flight_task( pool, channel_id, @@ -991,19 +977,33 @@ fn handle_switch_model_control( None, &observer::ObserverContext { channel_id: Some(channel_id.to_string()), - session_id: None, - turn_id: None, - started_at: None, + ..Default::default() }, - serde_json::json!({ - "type": "switch_model", - "status": status, - "modelId": model_id, - }), + serde_json::json!({"type": "switch_model", "status": status, "modelId": model_id}), ); } } +/// Ack a `set_config_option` control frame with `status: "ok"` so Desktop +/// can persist the canonical value (e.g. `effort_level`) on positive ack. +fn handle_set_config_option_control( + payload: &serde_json::Value, + observer: Option<&observer::ObserverHandle>, +) { + let Some(obs) = observer else { return }; + let config_id = payload + .get("configId") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let value = payload.get("value").and_then(|v| v.as_str()).unwrap_or(""); + obs.emit( + "control_result", + None, + &observer::ObserverContext::default(), + serde_json::json!({"type": "set_config_option", "configId": config_id, "status": "ok", "value": value}), + ); +} + /// Maximum crashes in a 60-second window before a slot's circuit opens. const CIRCUIT_BREAKER_THRESHOLD: usize = 3; /// Window for circuit-breaker crash counting. diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 79587ebacd..3a91537955 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -122,6 +122,7 @@ fn resolve_config_surface( runtime_meta: Option<&KnownAcpRuntime>, session_cache: Option<&SessionConfigCache>, global: &GlobalAgentConfig, + agent_mcp_path: Option, ) -> RuntimeConfigSurface { // Linked instances are definition-authoritative: clear stale materialized // model/provider/prompt so they can never masquerade as BuzzExplicit and @@ -139,7 +140,13 @@ fn resolve_config_surface( global, ); - read_config_surface(&record, runtime_meta, session_cache, &tiers) + read_config_surface( + &record, + runtime_meta, + session_cache, + &tiers, + agent_mcp_path.as_deref(), + ) } /// Get the file-layer config for a runtime — used by the Create/Edit/Persona @@ -310,12 +317,25 @@ pub async fn get_agent_config_surface( let session_cache = state.get_session_cache(&runtime_key); let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(); + // B8/#3493: for isolated local claude agents, read MCP servers from the + // agent-root .claude.json (the file the spawned process actually reads). + let agent_mcp_path = if record.backend == crate::managed_agents::BackendKind::Local + && runtime_meta.is_some_and(|m| m.id == "claude") + { + crate::managed_agents::storage::managed_agents_base_dir(&app) + .ok() + .map(|root| crate::managed_agents::claude_config::agent_mcp_config_path(&root, &pubkey)) + } else { + None + }; + Ok(resolve_config_surface( record, &personas, runtime_meta, session_cache.as_ref(), &global, + agent_mcp_path, )) } diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 4fbb4988bc..807c8c4bd2 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -181,6 +181,7 @@ fn linked_stale_record_model_never_outranks_persona_model() { Some(goose_runtime()), None, &Default::default(), + None, ); let model = surface.normalized.model.as_ref().expect("model resolved"); @@ -205,7 +206,14 @@ fn linked_blank_definition_model_falls_through_to_global_default() { ..Default::default() }; - let surface = resolve_config_surface(record, &personas, Some(goose_runtime()), None, &global); + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + None, + &global, + None, + ); let model = surface.normalized.model.as_ref().expect("model resolved"); assert_eq!(model.value.as_deref(), Some("global-model")); @@ -228,6 +236,7 @@ fn definition_less_explicit_record_model_keeps_buzz_explicit_origin() { Some(goose_runtime()), None, &Default::default(), + None, ); let model = surface.normalized.model.as_ref().expect("model resolved"); @@ -255,6 +264,7 @@ fn pending_pick_keeps_explicit_x_and_does_not_surface_live_y() { Some(goose_runtime()), Some(&cache), &Default::default(), + None, ); let model = surface.normalized.model.expect("model resolved"); @@ -283,6 +293,7 @@ fn genuine_explicit_live_switch_renders_y_over_x_buzz_explicit_secondary() { Some(goose_runtime()), Some(&cache), &Default::default(), + None, ); let model = surface.normalized.model.expect("model resolved"); @@ -318,6 +329,7 @@ fn genuine_explicit_live_switch_to_same_model_yields_clean_field() { Some(goose_runtime()), Some(&cache), &Default::default(), + None, ) }); let model = surface.normalized.model.expect("model resolved"); @@ -346,6 +358,7 @@ fn persona_linked_live_switch_keeps_persona_default_secondary() { Some(goose_runtime()), Some(&cache), &Default::default(), + None, ); let model = surface.normalized.model.expect("model resolved"); @@ -381,6 +394,7 @@ fn global_default_live_switch_renders_global_model_as_secondary_global_default() Some(goose_runtime()), Some(&cache), &global, + None, ); let model = surface.normalized.model.expect("model resolved"); diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index abc069e3e1..dd8dc816a5 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1332,31 +1332,15 @@ pub async fn delete_managed_agent( return Err(format!("agent {pubkey} not found")); } save_managed_agents(&app, &records)?; - // Remove the agent's nsec from the keyring after the record is gone. crate::managed_agents::delete_agent_key(&pubkey); - // B3: clean up the per-agent Claude config root. Must NOT block deletion — - // cleanup errors are logged and swallowed here. - if let Ok(managed_root) = crate::managed_agents::storage::managed_agents_base_dir(&app) - { - if let Err(e) = - crate::managed_agents::claude_config::cleanup_claude_config_root( - &pubkey, - &managed_root, - ) - { - eprintln!( - "buzz-desktop: failed to clean up Claude config root for {pubkey}: {e} (non-fatal)" - ); - } + if let Ok(root) = crate::managed_agents::storage::managed_agents_base_dir(&app) { + crate::managed_agents::claude_config::try_cleanup_claude_config_root( + &pubkey, &root, + ); } - // Tombstone-after-validation: only reached past the deployed-remote - // guard above and a confirmed removal — never orphan a live remote - // deployment's relay record. Inside the lock, before the block closes - // (no .await here). Every agent published, so every delete tombstones. + // Tombstone after confirmed removal (inside lock; every published agent tombstones). tombstone_managed_agent_pending(&app, &state, &pubkey); - // NIP-IA: archive the deleted agent's identity on the relay so it - // stops appearing in member pickers and autocomplete. Same - // best-effort, inside-the-lock contract as the tombstone above. + // NIP-IA: archive deleted agent identity so it stops appearing in pickers. archive_managed_agent_pending(&app, &state, &pubkey); } try_regenerate_nest(&app); diff --git a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs index e06835e392..a1ea8bffa0 100644 --- a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs @@ -234,7 +234,6 @@ pub fn project_settings_json( /// /// Used for provenance reporting in the config panel ("owner setting overridden /// by Buzz policy"). -#[allow(dead_code)] // consumed by the B4/B5 panel provenance endpoint (Desktop TS, forthcoming) pub fn collect_stripped_env_keys(owner_settings_path: &Path) -> Vec { let (base, status) = read_owner_base(owner_settings_path); if matches!( @@ -398,21 +397,167 @@ pub fn owner_mcp_config_path() -> Option { dirs::home_dir().map(|h| h.join(".claude.json")) } -/// Apply the Claude-specific spawn policy to `command` (A1 + B1 + B7). +/// Per-agent MCP config path: `/claude//.claude.json`. +/// +/// Returns the path where B8 writes the merged MCP config for an isolated agent. +/// Used by the config panel to display the file the spawned process actually reads. +pub fn agent_mcp_config_path(managed_root: &Path, pubkey: &str) -> PathBuf { + managed_root + .join("claude") + .join(pubkey) + .join(".claude.json") +} + +// ── B8: Owner MCP server inheritance ───────────────────────────────────────── +// +// At each local claude spawn, after the B7 settings.json projection: +// 1. Read the owner's ~/.claude.json for top-level `mcpServers` (user scope). +// 2. Filter out any entry whose name collides with the ACP-provided server +// name (case-insensitive) — Buzz wins by construction, no SDK reliance. +// 3. Read the agent-root .claude.json (missing / invalid → {}), overwrite its +// `mcpServers` key with the filtered set, write back atomically. +// +// Fault semantics (deliberately asymmetric to B7.5 spawn-fail): +// - Owner unreadable / invalid → preserve agent file's existing mcpServers, +// warn. A transient owner failure must NOT destroy inherited servers. +// - Agent file invalid → treat as {}, replace with owner servers, warn. +// - Agent file write failure → warn, spawn CONTINUES. B8 is inheritance, not +// policy integrity — its failure mode == pre-B8 status quo. + +/// Merge the owner's user-scope MCP servers into the agent-root `.claude.json`. +/// +/// `agent_config_dir` is the per-agent config root (B1 `CLAUDE_CONFIG_DIR`). +/// `owner_mcp_path` is `~/.claude.json`. +/// `acp_server_name` is the file stem of the ACP MCP binary (e.g. `"buzz-mcp"`); +/// any inherited server with this name (case-insensitive) is omitted and warned +/// so Buzz's channel cannot be shadowed. +/// +/// All errors are logged and swallowed; spawn always continues. +pub fn merge_agent_mcp_servers( + agent_config_dir: &Path, + owner_mcp_path: &Path, + acp_server_name: &str, +) { + // 1. Read owner user-scope mcpServers. + let owner_mcp_servers = match read_owner_mcp_servers(owner_mcp_path) { + Ok(servers) => servers, + Err(e) => { + // Owner unreadable: preserve existing agent servers, warn. + eprintln!( + "buzz-desktop: failed to read owner MCP config; \ + prior inherited servers preserved: {e}" + ); + return; + } + }; + + // 2. Filter colliding entries. + let acp_upper = acp_server_name.to_ascii_uppercase(); + let (servers, filtered): (serde_json::Map, Vec) = + owner_mcp_servers.into_iter().fold( + (serde_json::Map::new(), Vec::new()), + |(mut kept, mut dropped), (name, def)| { + if !acp_upper.is_empty() && name.to_ascii_uppercase() == acp_upper { + dropped.push(name); + } else { + kept.insert(name, def); + } + (kept, dropped) + }, + ); + for name in &filtered { + eprintln!( + "buzz-desktop: inherited MCP server {name:?} omitted — \ + name collides with Buzz's ACP-provided server" + ); + } + + // 3. Read agent .claude.json (missing / invalid → {}). + let agent_file = agent_config_dir.join(".claude.json"); + let mut agent_json = match read_agent_mcp_file(&agent_file) { + Ok(json) => json, + Err(e) => { + eprintln!( + "buzz-desktop: agent MCP config was unparsable — \ + state replaced with owner user-scope servers: {e}" + ); + serde_json::Map::new() + } + }; + + // 4. Set mcpServers key and write back atomically. + agent_json.insert("mcpServers".to_string(), Value::Object(servers)); + if let Err(e) = write_agent_mcp_file(&agent_file, agent_json) { + eprintln!( + "buzz-desktop: failed to write agent MCP config; \ + inherited servers may be stale: {e}" + ); + } +} + +/// Read the top-level `mcpServers` object from an owner `.claude.json`. +/// Returns `Ok(map)` (possibly empty) or `Err` when the file is unreadable or +/// the JSON cannot be parsed. +fn read_owner_mcp_servers(path: &Path) -> Result, String> { + if !path.exists() { + return Ok(serde_json::Map::new()); + } + let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + let json: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?; + Ok(json + .get("mcpServers") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default()) +} + +/// Read the agent-root `.claude.json` as an object map. +/// Returns `Err` when the file exists but cannot be read or parsed +/// (missing file → `Ok(empty map)`). +fn read_agent_mcp_file(path: &Path) -> Result, String> { + if !path.exists() { + return Ok(serde_json::Map::new()); + } + let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + let json: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?; + json.as_object() + .cloned() + .ok_or_else(|| "agent .claude.json is not a JSON object".to_string()) +} + +/// Write `map` to `path` atomically (temp file + rename). +fn write_agent_mcp_file(path: &Path, map: serde_json::Map) -> Result<(), String> { + let text = serde_json::to_string_pretty(&Value::Object(map)).map_err(|e| e.to_string())?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, text).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, path).map_err(|e| e.to_string()) +} + +/// Apply the Claude-specific spawn policy to `command` (A1 + B1 + B7 + B8). /// /// Called from `spawn_agent_child` for local Claude agents only. Writes the -/// projected `settings.json` (B7), injects `ANTHROPIC_MODEL` (A1) and the B1 -/// paired atom (`CLAUDE_CONFIG_DIR` + `CLAUDE_SECURESTORAGE_CONFIG_DIR`). +/// projected `settings.json` (B7), inherits owner MCP servers (B8), injects +/// `ANTHROPIC_MODEL` (A1) and the B1 paired atom +/// (`CLAUDE_CONFIG_DIR` + `CLAUDE_SECURESTORAGE_CONFIG_DIR`). /// /// `effective_model` is `None` when no model is resolved; the env key is /// removed so Claude Code uses its own default. +/// +/// `acp_mcp_command` is the resolved path to the ACP MCP binary (e.g. +/// `buzz-mcp`) — used by B8 to filter name-colliding inherited servers. pub fn apply_claude_spawn_policy( command: &mut std::process::Command, pubkey: &str, managed_root: &Path, effort_level: Option, effective_model: Option<&str>, + acp_mcp_command: Option<&std::path::Path>, ) -> Result<(), String> { + // A1: claude's startup model authority is ANTHROPIC_MODEL injected below. + // Remove BUZZ_ACP_MODEL first so the harness never sees two model authorities + // simultaneously (BUZZ_ACP_MODEL drives the catalog switch path; ANTHROPIC_MODEL + // locks the session model directly in the adapter env since claude >= 2.1.216). + command.env_remove("BUZZ_ACP_MODEL"); let policy = ClaudeLaunchPolicy::build(pubkey, managed_root, effort_level)?; let owner_path = owner_settings_path() .unwrap_or_else(|| PathBuf::from("/nonexistent/no-home-dir/.claude/settings.json")); @@ -424,6 +569,14 @@ pub fn apply_claude_spawn_policy( ); } write_projected_settings(&policy, &projected)?; + // B8: inherit owner user-scope MCP servers into the per-agent .claude.json. + if let Some(owner_mcp) = owner_mcp_config_path() { + let acp_name = acp_mcp_command + .and_then(|p| p.file_stem()) + .and_then(|s| s.to_str()) + .unwrap_or(""); + merge_agent_mcp_servers(&policy.config_dir, &owner_mcp, acp_name); + } // A1: single startup model authority. match effective_model { Some(m) => command.env("ANTHROPIC_MODEL", m), @@ -438,5 +591,15 @@ pub fn apply_claude_spawn_policy( Ok(()) } +/// B3: clean up the per-agent Claude config root, swallowing errors so +/// deletion is never blocked by a failed cleanup. +pub fn try_cleanup_claude_config_root(pubkey: &str, managed_root: &Path) { + if let Err(e) = cleanup_claude_config_root(pubkey, managed_root) { + eprintln!( + "buzz-desktop: failed to clean up Claude config root for {pubkey}: {e} (non-fatal)" + ); + } +} + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/claude_config/tests.rs b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs index 4c087afa97..a6c3e3875d 100644 --- a/desktop/src-tauri/src/managed_agents/claude_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs @@ -505,3 +505,310 @@ fn test_secure_storage_config_dir_is_always_empty_string() { "is_empty() must hold so command.env() receives the correct sentinel value" ); } + +// ── B8: Owner MCP server inheritance tests ──────────────────────────────────── + +/// Helper: write a JSON object to a file, creating parent dirs as needed. +fn write_json(path: &std::path::Path, value: &serde_json::Value) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, serde_json::to_string_pretty(value).unwrap()).unwrap(); +} + +/// Helper: read back JSON from a file, or None if the file does not exist. +fn read_json(path: &std::path::Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&text).ok() +} + +/// Owner user-scope MCP servers are written into the agent-root `.claude.json`. +#[test] +fn test_b8_merge_writes_owner_mcp_servers_to_agent_root() { + let dir = tempfile::tempdir().unwrap(); + let owner_mcp = dir.path().join("owner.claude.json"); + let agent_dir = dir.path().join("agent"); + std::fs::create_dir_all(&agent_dir).unwrap(); + let agent_file = agent_dir.join(".claude.json"); + + write_json( + &owner_mcp, + &serde_json::json!({ + "mcpServers": { + "filesystem": {"command": "npx"}, + "github": {"command": "gh"} + } + }), + ); + + merge_agent_mcp_servers(&agent_dir, &owner_mcp, "buzz-mcp"); + + let result = read_json(&agent_file).expect("agent file written"); + let servers = result["mcpServers"].as_object().expect("mcpServers object"); + assert!( + servers.contains_key("filesystem"), + "filesystem server must appear" + ); + assert!(servers.contains_key("github"), "github server must appear"); + assert_eq!(servers.len(), 2, "exactly 2 servers"); +} + +/// Non-`mcpServers` keys in the agent `.claude.json` are preserved after merge. +#[test] +fn test_b8_merge_preserves_agent_owned_keys() { + let dir = tempfile::tempdir().unwrap(); + let owner_mcp = dir.path().join("owner.claude.json"); + let agent_dir = dir.path().join("agent"); + std::fs::create_dir_all(&agent_dir).unwrap(); + let agent_file = agent_dir.join(".claude.json"); + + write_json( + &owner_mcp, + &serde_json::json!({ "mcpServers": { "github": {"command": "gh"} } }), + ); + write_json( + &agent_file, + &serde_json::json!({ "hasCompletedOnboarding": true }), + ); + + merge_agent_mcp_servers(&agent_dir, &owner_mcp, "buzz-mcp"); + + let result = read_json(&agent_file).expect("agent file written"); + assert_eq!( + result["hasCompletedOnboarding"].as_bool(), + Some(true), + "non-mcpServers key must be preserved" + ); + assert!( + result["mcpServers"]["github"].is_object(), + "merged server must be present" + ); +} + +/// Owner unreadable (distinct failure state #1): agent file is left unchanged — +/// the prior inherited set is preserved, not destroyed. +/// This triggers the Err path in read_owner_mcp_servers — uses a directory path +/// which exists but cannot be read as a file. +#[test] +fn test_b8_owner_read_failure_preserves_prior_inherited_set() { + let dir = tempfile::tempdir().unwrap(); + let agent_dir = dir.path().join("agent"); + std::fs::create_dir_all(&agent_dir).unwrap(); + let agent_file = agent_dir.join(".claude.json"); + + write_json( + &agent_file, + &serde_json::json!({ "mcpServers": { "filesystem": {"command": "npx"} } }), + ); + let original = std::fs::read_to_string(&agent_file).unwrap(); + + // Use an existing DIRECTORY as the owner path — read_to_string on a directory + // returns an Err, triggering the "preserve prior set" code path. + let dir_as_file = dir.path().join("a_directory"); + std::fs::create_dir_all(&dir_as_file).unwrap(); + merge_agent_mcp_servers(&agent_dir, &dir_as_file, "buzz-mcp"); + + let after = std::fs::read_to_string(&agent_file).unwrap(); + assert_eq!( + after, original, + "agent file must be unchanged when owner is unreadable" + ); +} + +/// Owner has no `mcpServers` key: agent file is updated with empty `mcpServers`, +/// clearing any stale inherited set. +#[test] +fn test_b8_empty_owner_mcp_servers_clears_inherited_set() { + let dir = tempfile::tempdir().unwrap(); + let owner_mcp = dir.path().join("owner.claude.json"); + let agent_dir = dir.path().join("agent"); + std::fs::create_dir_all(&agent_dir).unwrap(); + let agent_file = agent_dir.join(".claude.json"); + + write_json(&owner_mcp, &serde_json::json!({})); + write_json( + &agent_file, + &serde_json::json!({ "mcpServers": { "old-server": {} } }), + ); + + merge_agent_mcp_servers(&agent_dir, &owner_mcp, "buzz-mcp"); + + let result = read_json(&agent_file).expect("file written"); + let servers = result["mcpServers"] + .as_object() + .expect("mcpServers present"); + assert!( + servers.is_empty(), + "empty owner mcpServers must clear the inherited set" + ); +} + +/// Invalid agent JSON (distinct failure state #2): replaced with owner-only servers. +/// The corrupt content must not survive — the file was replaced, not preserved. +#[test] +fn test_b8_invalid_agent_file_falls_back_to_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let owner_mcp = dir.path().join("owner.claude.json"); + let agent_dir = dir.path().join("agent"); + std::fs::create_dir_all(&agent_dir).unwrap(); + let agent_file = agent_dir.join(".claude.json"); + + write_json( + &owner_mcp, + &serde_json::json!({ "mcpServers": { "github": {"command": "gh"} } }), + ); + std::fs::write(&agent_file, b"not valid json {{ at all").unwrap(); + + merge_agent_mcp_servers(&agent_dir, &owner_mcp, "buzz-mcp"); + + let result = read_json(&agent_file).expect("file replaced with valid JSON"); + let servers = result["mcpServers"].as_object().expect("mcpServers object"); + assert!( + servers.contains_key("github"), + "owner servers must appear after invalid-agent fallback" + ); + let raw = std::fs::read_to_string(&agent_file).unwrap(); + assert!( + !raw.contains("not valid json"), + "corrupt content must not survive — file replaced" + ); +} + +/// Collision filter (Thufir frozen invariant #2): inherited server with same name +/// as the ACP-provided server is omitted — Buzz wins by construction. +/// Case-insensitive match. Non-colliding servers pass through. +#[test] +fn test_b8_collision_filter_removes_acp_name_collision() { + let dir = tempfile::tempdir().unwrap(); + let owner_mcp = dir.path().join("owner.claude.json"); + let agent_dir = dir.path().join("agent"); + std::fs::create_dir_all(&agent_dir).unwrap(); + let agent_file = agent_dir.join(".claude.json"); + + write_json( + &owner_mcp, + &serde_json::json!({ + "mcpServers": { + "buzz-mcp": {"command": "buzz-mcp"}, + "BUZZ-MCP": {"command": "buzz-mcp"}, + "github": {"command": "gh"} + } + }), + ); + + merge_agent_mcp_servers(&agent_dir, &owner_mcp, "buzz-mcp"); + + let result = read_json(&agent_file).expect("file written"); + let servers = result["mcpServers"].as_object().expect("mcpServers object"); + assert!( + !servers.contains_key("buzz-mcp"), + "exact-match ACP server must be filtered" + ); + assert!( + !servers.contains_key("BUZZ-MCP"), + "case-variant ACP server must be filtered" + ); + assert!( + servers.contains_key("github"), + "non-colliding server must pass through" + ); +} + +/// Empty ACP server name disables collision filtering — all owner servers pass through. +#[test] +fn test_b8_empty_acp_name_disables_collision_filter() { + let dir = tempfile::tempdir().unwrap(); + let owner_mcp = dir.path().join("owner.claude.json"); + let agent_dir = dir.path().join("agent"); + std::fs::create_dir_all(&agent_dir).unwrap(); + let agent_file = agent_dir.join(".claude.json"); + + write_json( + &owner_mcp, + &serde_json::json!({ "mcpServers": { "filesystem": {}, "github": {} } }), + ); + + merge_agent_mcp_servers(&agent_dir, &owner_mcp, ""); + + let result = read_json(&agent_file).expect("file written"); + let servers = result["mcpServers"].as_object().expect("mcpServers object"); + assert_eq!( + servers.len(), + 2, + "all servers must pass through with empty ACP name" + ); +} + +/// Missing owner file (no `~/.claude.json` yet): treated as empty owner — agent +/// file gets an empty `mcpServers` set, not a write-skip. +#[test] +fn test_b8_missing_owner_file_writes_empty_mcp_set() { + let dir = tempfile::tempdir().unwrap(); + let owner_mcp = dir.path().join("no_such_owner.claude.json"); // does not exist + let agent_dir = dir.path().join("agent"); + std::fs::create_dir_all(&agent_dir).unwrap(); + let agent_file = agent_dir.join(".claude.json"); + + merge_agent_mcp_servers(&agent_dir, &owner_mcp, "buzz-mcp"); + + let result = read_json(&agent_file).expect("file written for missing owner"); + let servers = result["mcpServers"] + .as_object() + .expect("mcpServers object present"); + assert!( + servers.is_empty(), + "missing owner must produce empty mcpServers" + ); +} + +/// Concurrent calls to `merge_agent_mcp_servers` for the same agent root must +/// not corrupt the file. The atomic-rename durability primitive ensures each +/// write is all-or-nothing; the winner produces valid JSON. +/// +/// In production the `managed_agent_runtime_transition` mutex prevents concurrent +/// B8 writes for the same root — this tests the file-level primitive independently. +#[test] +fn test_b8_serialization_no_lost_update() { + use std::sync::Arc; + let dir = tempfile::tempdir().unwrap(); + let owner_mcp = Arc::new(dir.path().join("owner.claude.json")); + let agent_dir = Arc::new(dir.path().join("agent")); + std::fs::create_dir_all(agent_dir.as_ref()).unwrap(); + + write_json( + &owner_mcp, + &serde_json::json!({ "mcpServers": { "github": {} } }), + ); + + let (o1, a1) = (Arc::clone(&owner_mcp), Arc::clone(&agent_dir)); + let (o2, a2) = (Arc::clone(&owner_mcp), Arc::clone(&agent_dir)); + let t1 = std::thread::spawn(move || merge_agent_mcp_servers(&a1, &o1, "buzz-mcp")); + let t2 = std::thread::spawn(move || merge_agent_mcp_servers(&a2, &o2, "buzz-mcp")); + t1.join().unwrap(); + t2.join().unwrap(); + + let agent_file = dir.path().join("agent").join(".claude.json"); + let result = read_json(&agent_file).expect("file must be valid JSON after concurrent writes"); + assert!( + result["mcpServers"].is_object(), + "mcpServers must be an object after concurrent writes" + ); +} + +/// `agent_mcp_config_path` returns a path under `/claude//` +/// consistent with the B1 CLAUDE_CONFIG_DIR layout. +#[test] +fn test_b8_agent_mcp_config_path_location() { + let dir = tempfile::tempdir().unwrap(); + let managed_root = dir.path(); + let pubkey = "abcd1234efabcd12"; + let path = agent_mcp_config_path(managed_root, pubkey); + assert!( + path.ends_with(std::path::Path::new(pubkey).join(".claude.json")), + "agent mcp path must end with /.claude.json; got: {path:?}" + ); + assert!( + path.starts_with(managed_root), + "path must be under managed_root" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs index 55e43941ec..cac7295ca2 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs @@ -1,9 +1,16 @@ use super::types::{ExtensionEntry, RuntimeFileConfig}; -/// Read Claude Code config from `~/.claude/settings.json` and `~/.claude.json`. -pub(super) fn read_config_file() -> Option { +/// Read Claude Code config from `~/.claude/settings.json` and MCP config. +/// +/// `mcp_path_override` — when `Some`, reads MCP servers from that path instead +/// of `~/.claude.json` (used for isolated agents whose B8 agent root is known). +pub(super) fn read_config_file( + mcp_path_override: Option<&std::path::Path>, +) -> Option { let settings_path = crate::managed_agents::claude_config::owner_settings_path()?; - let mcp_path = crate::managed_agents::claude_config::owner_mcp_config_path()?; + let mcp_path = mcp_path_override + .map(std::path::Path::to_path_buf) + .or_else(crate::managed_agents::claude_config::owner_mcp_config_path)?; let settings = read_json_file(&settings_path); let mcp_config = read_json_file(&mcp_path); @@ -25,7 +32,10 @@ pub(super) fn read_config_file() -> Option { cfg.extra = super::schema_walker::extract_config_fields(s, skip); } - // MCP servers from ~/.claude.json + // MCP servers from the effective config path. + // When mcp_path_override is set, all entries came from the owner user scope + // via B8 inheritance — tag them with provenance for panel display. + let is_agent_root = mcp_path_override.is_some(); let mut extensions = Vec::new(); if let Some(ref mc) = mcp_config { if let Some(servers) = mc.get("mcpServers").and_then(|v| v.as_object()) { @@ -34,6 +44,7 @@ pub(super) fn read_config_file() -> Option { name: name.clone(), kind: "mcp".to_string(), enabled: true, + source: is_agent_root.then(|| "owner_user_scope".to_string()), }); } } @@ -168,6 +179,7 @@ mod tests { name: name.clone(), kind: "mcp".to_string(), enabled: true, + source: None, }); } } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs b/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs index c7c7135ccb..9ba0b1bb3a 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs @@ -79,6 +79,7 @@ fn parse_mcp_servers(table: &toml::Table) -> Vec { name: name.clone(), kind: "mcp".to_string(), enabled: true, + source: None, }) .collect() } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs index fce54edc40..d94cefea09 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs @@ -130,6 +130,7 @@ fn parse_extensions( name, kind, enabled, + source: None, }) }) .collect() diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 23a795081f..2442f8872e 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -9,11 +9,16 @@ use super::types::*; /// persona and global tiers assembled at the command boundary. Each field /// builder constructs its own candidate list and resolves via /// `resolve_with_override`. +/// +/// `agent_mcp_path` overrides the MCP config file path used for isolated +/// claude agents (B8). When `Some`, extensions are read from the agent-root +/// `.claude.json` instead of the owner path. pub(crate) fn read_config_surface( record: &ManagedAgentRecord, runtime_meta: Option<&KnownAcpRuntime>, session_cache: Option<&SessionConfigCache>, tiers: &InheritedConfigTiers, + agent_mcp_path: Option<&std::path::Path>, ) -> RuntimeConfigSurface { let is_pre_spawn = session_cache.is_none(); @@ -22,7 +27,7 @@ pub(crate) fn read_config_surface( .map(|m| m.id) .and_then(|id| match id { "goose" => super::goose::read_config_file().map(|c| (c, true)), - "claude" => super::claude::read_config_file().map(|c| (c, true)), + "claude" => super::claude::read_config_file(agent_mcp_path).map(|c| (c, true)), "codex" => super::codex::read_config_file().map(|c| (c, true)), "buzz-agent" => super::buzz_agent::read_config_file().map(|c| (c, true)), _ => None, @@ -148,7 +153,8 @@ pub(crate) fn read_config_surface( let config_file_path = runtime_meta .and_then(|m| m.config_file_path) .map(resolve_tilde); - let mcp_config_file_path = runtime_meta.and_then(mcp_config_file_path_for_runtime); + let mcp_config_file_path = + runtime_meta.and_then(|m| mcp_config_file_path_for_runtime(m, agent_mcp_path)); let extensions = file_config.extensions.clone(); let sources = ConfigSourceReport { @@ -189,16 +195,28 @@ pub(crate) fn read_config_surface( advanced, extensions, sources, + stripped_owner_env_keys: runtime_meta + .filter(|m| m.id == "claude") + .and_then(|_| crate::managed_agents::claude_config::owner_settings_path()) + .map(|p| crate::managed_agents::claude_config::collect_stripped_env_keys(&p)) + .unwrap_or_default(), } } -fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option { +fn mcp_config_file_path_for_runtime( + runtime: &KnownAcpRuntime, + agent_mcp_path: Option<&std::path::Path>, +) -> Option { match runtime.id { "goose" => { super::goose::goose_config_path().map(|path| path.to_string_lossy().into_owned()) } - "claude" => crate::managed_agents::claude_config::owner_mcp_config_path() - .map(|p| p.to_string_lossy().into_owned()), + "claude" => agent_mcp_path + .map(|p| p.to_string_lossy().into_owned()) + .or_else(|| { + crate::managed_agents::claude_config::owner_mcp_config_path() + .map(|p| p.to_string_lossy().into_owned()) + }), "codex" => { super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned()) } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 97b17e09b2..dbc45dbf67 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -167,7 +167,7 @@ fn persona_and_global_env_tiers( fn pre_spawn_surface_reports_pending_acp_tiers() { let record = test_record(); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); assert!(surface.is_pre_spawn); assert_eq!(surface.sources.acp_native, ConfigTierStatus::Pending); @@ -183,7 +183,7 @@ fn surface_reports_mcp_specific_config_path() { let record = test_record(); let runtime = test_runtime(); let surface = with_goose_path_root(None, || { - read_config_surface(&record, Some(runtime), None, &no_tiers()) + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) }); let path = surface @@ -202,7 +202,7 @@ fn goose_mcp_config_path_follows_path_root_override() { let record = test_record(); let runtime = test_runtime(); let surface = with_goose_path_root(Some("/tmp/buzz-goose-root"), || { - read_config_surface(&record, Some(runtime), None, &no_tiers()) + read_config_surface(&record, Some(runtime), None, &no_tiers(), None) }); let expected_path = Path::new("/tmp/buzz-goose-root") @@ -226,7 +226,7 @@ fn claude_surface_uses_mcp_config_path_not_settings_path() { config_file_path: Some("~/.claude/settings.json"), ..*test_runtime() }; - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); assert!(surface .sources @@ -246,7 +246,7 @@ fn record_model_overrides_file_model() { record.model = Some("explicit-model".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("explicit-model")); assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); @@ -259,7 +259,7 @@ fn provider_locked_shows_locked() { provider_locked: true, ..*test_runtime() }; - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let provider = surface.normalized.provider.unwrap(); assert_eq!(provider.value.as_deref(), Some("Anthropic (locked)")); assert_eq!(provider.origin, ConfigOrigin::HarnessConstraint); @@ -285,7 +285,7 @@ fn post_spawn_with_model_config_option_uses_acp() { captured_at: "".to_string(), }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None); assert!(!surface.is_pre_spawn); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("claude-opus-4")); @@ -309,7 +309,7 @@ fn acp_model_overrides_file_model_with_override_tracking() { captured_at: "".to_string(), }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers(), None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("acp-model")); assert_eq!(model.origin, ConfigOrigin::AcpConfigOption); @@ -330,7 +330,7 @@ fn persona_model_tier_produces_persona_default_origin() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("persona-model")); @@ -346,7 +346,7 @@ fn global_model_tier_produces_global_default_origin() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("global-model")); @@ -362,7 +362,7 @@ fn persona_provider_tier_produces_persona_default_origin() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let provider = surface.normalized.provider.unwrap(); assert_eq!(provider.value.as_deref(), Some("anthropic")); @@ -378,7 +378,7 @@ fn persona_prompt_tier_produces_persona_default_origin() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let prompt = surface.normalized.system_prompt.unwrap(); assert_eq!( @@ -415,7 +415,7 @@ fn runtime_override_wins_display_when_model_overridden_is_true() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); let model = surface.normalized.model.unwrap(); // Override wins the display value with a runtime-override origin. @@ -447,7 +447,7 @@ fn no_runtime_override_when_model_overridden_is_false() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); let model = surface.normalized.model.unwrap(); // model_overridden is false => the override branch is not taken. @@ -479,7 +479,7 @@ fn no_false_positive_override_when_persona_edited_mid_life() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); let model = surface.normalized.model.unwrap(); // model_overridden is false => no RuntimeOverride, even though @@ -538,7 +538,7 @@ fn explicit_record_model_not_retagged_when_already_present() { record.model = Some("explicit-model".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("explicit-model")); @@ -561,7 +561,7 @@ fn extra_env_vars_appear_in_advanced_as_buzz_explicit() { .insert("SPROUT_ACP_MEMORY".to_string(), "mem-value".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -600,7 +600,7 @@ fn extra_env_var_skipped_when_already_in_file_config_extra() { .insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -660,7 +660,7 @@ fn buzz_agent_max_output_tokens_from_env_is_buzz_explicit() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let field = surface.normalized.max_output_tokens.unwrap(); assert_eq!(field.value.as_deref(), Some("8192")); @@ -681,7 +681,7 @@ fn buzz_agent_context_limit_from_env_is_buzz_explicit() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let field = surface.normalized.context_limit.unwrap(); assert_eq!(field.value.as_deref(), Some("100000")); @@ -699,7 +699,7 @@ fn buzz_agent_max_tokens_absent_when_no_env_var_or_file() { let record = test_record(); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); assert!( surface.normalized.max_output_tokens.is_none(), @@ -724,7 +724,7 @@ fn buzz_agent_max_tokens_env_var_not_double_surfaced_in_advanced() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -745,7 +745,7 @@ fn buzz_agent_thinking_effort_from_env_is_buzz_explicit() { .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let field = surface.normalized.thinking_effort.unwrap(); assert_eq!(field.value.as_deref(), Some("high")); @@ -766,7 +766,7 @@ fn buzz_agent_thinking_effort_env_var_not_double_surfaced_in_advanced() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -828,7 +828,7 @@ fn global_effort_surfaces_as_global_default_when_record_has_none() { let runtime = buzz_agent_rt(); let tiers = global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "high"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let effort = surface .normalized @@ -845,7 +845,7 @@ fn persona_effort_shadows_global_and_tags_persona_default() { let runtime = buzz_agent_rt(); let tiers = persona_and_global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium", "high"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let effort = surface .normalized @@ -869,7 +869,7 @@ fn record_effort_outranks_persona_and_global_keeps_buzz_explicit() { let runtime = buzz_agent_rt(); let tiers = persona_and_global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium", "high"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let effort = surface .normalized @@ -885,7 +885,7 @@ fn no_effort_anywhere_yields_no_thinking_effort_field() { let record = test_record(); let runtime = buzz_agent_rt(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); assert!( surface.normalized.thinking_effort.is_none(), @@ -916,7 +916,7 @@ fn acp_effort_wins_over_inherited_global_effort_as_secondary() { }; let tiers = global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "high"); - let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers, None); let effort = surface .normalized @@ -940,7 +940,7 @@ fn numeric_max_tokens_inherits_from_global_env() { let runtime = buzz_agent_runtime(); let tiers = global_env_tiers("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "16384"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let field = surface.normalized.max_output_tokens.unwrap(); assert_eq!(field.value.as_deref(), Some("16384")); diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs index 0b4beb2003..fc4f16bec5 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs @@ -16,7 +16,7 @@ fn numeric_context_limit_inherits_from_persona_env() { let runtime = buzz_agent_runtime(); let tiers = persona_env_tiers("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let field = surface.normalized.context_limit.unwrap(); assert_eq!(field.value.as_deref(), Some("200000")); @@ -33,7 +33,7 @@ fn record_max_tokens_overrides_global_env_with_secondary() { let runtime = buzz_agent_runtime(); let tiers = global_env_tiers("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "16384"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let field = surface.normalized.max_output_tokens.unwrap(); assert_eq!(field.value.as_deref(), Some("8192")); @@ -64,7 +64,7 @@ fn global_env_prompt_wins_over_persona_structured_prompt() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let prompt = surface.normalized.system_prompt.unwrap(); assert_eq!(prompt.value.as_deref(), Some("global-env-prompt")); @@ -87,7 +87,7 @@ fn persona_env_model_wins_over_persona_structured_model() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); // persona env outranks persona struct because env candidates precede struct @@ -106,7 +106,7 @@ fn structured_fallback_intact_when_no_env_representation() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("struct-persona-model")); @@ -130,7 +130,7 @@ fn post_sanitization_empty_global_env_falls_through_to_persona_tier() { // No global env (stripped); persona provides the valid fallback. let tiers = persona_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium"); - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); // Persona value surfaces instead of the stripped global value. let effort = surface.normalized.thinking_effort.unwrap(); @@ -157,7 +157,7 @@ fn record_env_prompt_wins_over_record_struct_prompt_as_buzz_explicit() { ); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let prompt = surface.normalized.system_prompt.unwrap(); assert_eq!(prompt.value.as_deref(), Some("env-prompt-B")); @@ -189,7 +189,7 @@ fn definition_env_beats_structured_persona_model() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("harness-model")); @@ -222,7 +222,7 @@ fn global_env_beats_definition_env() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("global-model")); @@ -249,7 +249,7 @@ fn reserved_key_absent_from_definition_env_falls_through() { ..Default::default() }; - let surface = read_config_surface(&record, Some(runtime), None, &tiers); + let surface = read_config_surface(&record, Some(runtime), None, &tiers, None); let model = surface.normalized.model.unwrap(); // Falls through to persona structured model. @@ -269,7 +269,7 @@ fn b4_canonical_effort_level_surfaces_as_buzz_explicit() { let mut record = test_record(); record.effort_level = Some("high".to_string()); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let effort = surface .normalized .thinking_effort @@ -286,7 +286,7 @@ fn b4_canonical_effort_level_shadows_file_tier() { record.effort_level = Some("medium".to_string()); // no env var set — config-file would otherwise win if canonical absent let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let effort = surface .normalized .thinking_effort @@ -304,7 +304,7 @@ fn b4_record_env_var_wins_over_canonical_effort_level() { .env_vars .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); let effort = surface .normalized .thinking_effort @@ -320,7 +320,7 @@ fn b4_record_env_var_wins_over_canonical_effort_level() { fn b4_none_canonical_effort_does_not_surface() { let record = test_record(); // effort_level defaults to None let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers(), None); // No env var, no session cache, no file config → effort_level field absent. assert!( surface.normalized.thinking_effort.is_none(), diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs index 6ca2592538..610e268cae 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -175,6 +175,12 @@ pub struct RuntimeConfigSurface { pub advanced: Vec, pub extensions: Vec, pub sources: ConfigSourceReport, + /// Keys present in the owner's `~/.claude/settings.json` `env` block that + /// were stripped because they are owned by Buzz's launch policy (B7.7c). + /// Empty for non-claude runtimes and when no protected keys are present. + /// Rendered in the config panel as "owner setting overridden by Buzz policy". + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub stripped_owner_env_keys: Vec, } /// Raw config values extracted from a runtime's config file. @@ -198,6 +204,11 @@ pub struct ExtensionEntry { pub name: String, pub kind: String, pub enabled: bool, + /// Provenance tag for display. `Some("owner_user_scope")` means the entry + /// was inherited from the owner's user-scope `~/.claude.json` by B8. + /// `None` means the entry was read directly from the runtime's config file. + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, } /// Cached ACP session config from a running agent. diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 391c24b7fc..fc751fd186 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -289,8 +289,7 @@ fn record_with( #[test] fn record_agent_command_own_runtime_wins_over_persona() { - // A record with its own materialized runtime never consults the - // persona list — the unified-model resolution. + // A record with its own runtime never consults the persona list. let personas = vec![persona_with_runtime("p1", Some("goose"))]; let record = record_with(Some("claude"), Some("p1"), None); assert_eq!(record_agent_command(&record, &personas), "claude-agent-acp"); diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 57846402ae..33b39f075e 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1467,9 +1467,8 @@ mod tests { #[test] fn resolve_effective_agent_env_user_env_wins_over_structured_fields() { - // A record whose env_vars explicitly set provider/model must win over - // any baked defaults. In OSS test builds the baked map is empty, so - // this test validates the user-env layer is present in the output. + // User env_vars must win over baked defaults; in OSS builds baked map is empty, + // so this validates the user-env layer is present in the output. let mut env_vars = BTreeMap::new(); env_vars.insert("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string()); env_vars.insert( diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 130eb7be07..8c54302c49 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -814,17 +814,8 @@ pub fn spawn_agent_child( command.env("BUZZ_ACP_RELAY_OBSERVER", "true"); - // ── Git credential helper for Buzz relay ────────────────────────── - // - // Agents need to clone/push repos hosted on the Buzz relay's git - // server, which authenticates via NIP-98. The `git-credential-nostr` - // binary signs auth events using the agent's nostr key. - // - // We configure git via GIT_CONFIG_COUNT env vars (ephemeral, no - // filesystem writes) scoped to the relay's git URL so we don't - // interfere with other remotes (e.g. GitHub). - // - // NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY — keep in sync. + // Git credential helper: NIP-98 auth for Buzz relay git via git-credential-nostr. + // Ephemeral GIT_CONFIG_COUNT env vars scoped to relay HTTP URL; NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY. if let Some(cred_helper) = resolve_command("git-credential-nostr") { let relay_http_url = crate::relay::relay_http_base_url(&effective_relay_url); @@ -849,21 +840,13 @@ pub fn spawn_agent_child( ); } - // ── User env vars: definition floor + global + live persona + agent overrides ── - // - // `descriptor.env` is the fully-layered result from `resolve_effective_harness_descriptor`: - // baked floor → runtime metadata → definition env (harness author defaults) → - // global → live persona → per-agent, with reserved-key and malformed-key filtering - // applied. Writing it last lets user-provided values win over every Buzz-set env - // written above — reserved keys were already stripped from descriptor.env so they - // cannot clobber BUZZ_PRIVATE_KEY, NOSTR_PRIVATE_KEY, etc. + // User env (descriptor.env): fully-layered floor→runtime→definition→global→persona→agent, + // reserved-key filtered. Written last so user-explicit values win over Buzz-set env. for (key, value) in &descriptor.env { command.env(key, value); } - // ── A1 + B1 + B7: Claude config isolation and model authority (local only) ── - // Policy values written AFTER descriptor.env (user-explicit Desktop overrides - // are not filtered; the protected-key predicate filters owner settings.json only). + // A1+B1+B7+B8: claude config isolation, model authority, MCP inheritance (local agents only). if record.backend == super::BackendKind::Local && runtime_meta.is_some_and(|r| r.id == "claude") { let managed_root = super::storage::managed_agents_base_dir(app)?; @@ -873,9 +856,9 @@ pub fn spawn_agent_child( &managed_root, record.effort_level.clone(), effective_model.as_deref(), + resolved_mcp_command.as_deref(), )?; } - configure_runtime_cli(&mut command, runtime_meta); // Buzz shared compute is stored as a native provider; derive the OpenAI-compatible diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 17d1aa3916..b1da735fe7 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -71,9 +71,8 @@ fn identifier_empty_returns_false() { #[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. + // spawn stamp and sweep matcher both go through buzz_marker_entry (pins wire format, + // guards dev build `...app.dev` from matching release `...app` agents). assert_eq!( super::buzz_marker_entry("xyz.block.buzz.app"), b"BUZZ_MANAGED_AGENT=xyz.block.buzz.app".to_vec() diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 2f8d412d91..433413ea74 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -439,35 +439,11 @@ pub struct ManagedAgentRecord { /// deserialize as `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, - /// Claude Code effort level (maps to `effortLevel` in the per-agent - /// `settings.json`). Written at spawn time into the projected - /// `settings.json`; updated on positive ACP `session/set_config_option` - /// ack. `None` means no canonical effort level seeded — the owner's - /// personal `effortLevel` (if any) will pass through in the projection. - /// `#[serde(default)]` so records predating this field deserialize as - /// `None`. + /// Canonical Claude Code effort level; seeded into the per-agent `settings.json` at spawn. #[serde(default, skip_serializing_if = "Option::is_none")] pub effort_level: Option, } -/// Typed relay-mesh configuration carried on a [`ManagedAgentRecord`]. -/// -/// Feature-independent on purpose: the field is always present in the record -/// schema so saved agents round-trip identically whether or not the `mesh-llm` -/// feature is compiled in. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RelayMeshConfig { - /// The served model id this agent routes to (e.g. "Qwen3"). - /// - /// `alias` because this struct crosses two boundaries with different - /// casing conventions: the TS create request sends camelCase - /// (`relayMesh: { modelRef }` — `rename_all` on the request does not - /// recurse into nested structs), while persisted records use snake_case. - /// Serialization stays `model_ref` so saved records are stable. - #[serde(alias = "modelRef")] - pub model_ref: String, -} - #[derive(Debug)] pub struct ManagedAgentProcess { pub child: Child, @@ -1002,6 +978,8 @@ pub fn resolve_mint_behavioral_defaults( mod catalog_source; pub use catalog_source::CatalogSource; +mod relay_mesh; +pub use relay_mesh::RelayMeshConfig; mod requests; pub use requests::*; diff --git a/desktop/src-tauri/src/managed_agents/types/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/types/relay_mesh.rs new file mode 100644 index 0000000000..a9ec2d2838 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/relay_mesh.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +/// Typed relay-mesh configuration carried on a [`super::ManagedAgentRecord`]. +/// +/// Feature-independent on purpose: the field is always present in the record +/// schema so saved agents round-trip identically whether or not the `mesh-llm` +/// feature is compiled in. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RelayMeshConfig { + /// The served model id this agent routes to (e.g. "Qwen3"). + /// + /// `alias` because this struct crosses two boundaries with different + /// casing conventions: the TS create request sends camelCase + /// (`relayMesh: { modelRef }` — `rename_all` on the request does not + /// recurse into nested structs), while persisted records use snake_case. + /// Serialization stays `model_ref` so saved records are stable. + #[serde(alias = "modelRef")] + pub model_ref: String, +} diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 56c69f915a..5b475c5b50 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -3,7 +3,10 @@ import * as React from "react"; import { subscribeToAgentObserverFrames } from "@/shared/api/observerRelay"; import type { RelayEvent, ManagedAgent } from "@/shared/api/types"; import type { ControlResultFrame } from "@/shared/api/types"; -import { putAgentSessionConfig } from "@/shared/api/tauri"; +import { + putAgentSessionConfig, + persistAgentEffortLevel, +} from "@/shared/api/tauri"; import { putManagedAgentRuntimeLifecycle } from "@/shared/api/tauriManagedAgents"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { decryptObserverEvent } from "@/shared/api/tauriObserver"; @@ -499,6 +502,19 @@ function dispatchControlResult(agentPubkey: string, payload: unknown) { if (!isControlResultFrame(payload)) { return; } + // B5: on a positive set_config_option ack for effort, persist the canonical + // value to the agent record so it seeds settings.json on next spawn (B7). + if ( + payload.type === "set_config_option" && + payload.configId === "effort" && + payload.status === "ok" + ) { + void persistAgentEffortLevel(agentPubkey, payload.value || null).catch( + (err: unknown) => { + console.warn("Failed to persist effort level:", err); + }, + ); + } const subscribers = controlResultListeners.get(normalizePubkey(agentPubkey)); if (!subscribers) { return; diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index 67c544257c..d5767aac95 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -373,8 +373,15 @@ export function AgentConfigPanel({ ); } - const { normalized, advanced, extensions, runtimeId, sources, isPreSpawn } = - data; + const { + normalized, + advanced, + extensions, + runtimeId, + sources, + isPreSpawn, + strippedOwnerEnvKeys, + } = data; const configFilePath = sources.configFilePath; const normalizedEntries = ( @@ -475,6 +482,20 @@ export function AgentConfigPanel({ ) : null} ) : null} + + {strippedOwnerEnvKeys && strippedOwnerEnvKeys.length > 0 ? ( +
+

+ Owner settings overridden by Buzz policy:{" "} + {strippedOwnerEnvKeys.map((key, i) => ( + + {key} + {i < strippedOwnerEnvKeys.length - 1 ? ", " : ""} + + ))} +

+
+ ) : null} ); } diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 19f35165ca..7df9fbe58d 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -118,11 +118,6 @@ export type AgentDefinitionSubmitOptions = { publishCatalogUpdates: boolean; }; -const ADVANCED_FIELDS_MOTION_TRANSITION = { - duration: 0.18, - ease: [0.23, 1, 0.32, 1], -} as const; - export function AgentDefinitionDialog({ open, title, @@ -628,7 +623,7 @@ export function AgentDefinitionDialog({ ) : null; const advancedFieldsTransition = shouldReduceMotion ? { duration: 0 } - : ADVANCED_FIELDS_MOTION_TRANSITION; + : { duration: 0.18, ease: [0.23, 1, 0.32, 1] }; React.useEffect(() => { if ( diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 48cde58aa7..4428b2b9b8 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -93,11 +93,6 @@ import { usePendingHarnessSelection, } from "./addCustomHarness"; -const ADVANCED_FIELDS_MOTION_TRANSITION = { - duration: 0.18, - ease: [0.23, 1, 0.32, 1], -} as const; - export function AgentInstanceEditDialog({ agent, initialFocus, @@ -846,7 +841,7 @@ export function AgentInstanceEditDialog({ const previewAvatarUrl = avatarUrl.trim() || null; const advancedFieldsTransition = shouldReduceMotion ? { duration: 0 } - : ADVANCED_FIELDS_MOTION_TRANSITION; + : { duration: 0.18, ease: [0.23, 1, 0.32, 1] }; return ( diff --git a/desktop/src/features/agents/ui/McpServersSection.tsx b/desktop/src/features/agents/ui/McpServersSection.tsx index 3e6c93ca5d..b6ded64ff6 100644 --- a/desktop/src/features/agents/ui/McpServersSection.tsx +++ b/desktop/src/features/agents/ui/McpServersSection.tsx @@ -93,7 +93,9 @@ function McpServerRow({ {extension.name} - {extension.kind} + {extension.source === "owner_user_scope" + ? "inherited from owner user scope" + : extension.kind} {extension.enabled ? " enabled" : " disabled"} diff --git a/desktop/src/shared/api/agentControl.ts b/desktop/src/shared/api/agentControl.ts index 677f0ffad4..7e9cb8f32d 100644 --- a/desktop/src/shared/api/agentControl.ts +++ b/desktop/src/shared/api/agentControl.ts @@ -29,3 +29,22 @@ export async function switchManagedAgentModel( modelId, }); } + +/** + * Send a `set_config_option` control frame to a running agent. The harness + * acknowledges via a `control_result` observer frame with `type: + * "set_config_option"` and `status: "ok"`. The caller uses this ack to + * persist the canonical value (e.g. `effort_level`) so it takes effect on + * the next agent spawn. + */ +export async function sendSetConfigOption( + pubkey: string, + configId: string, + value: string, +): Promise { + await sendAgentObserverControl(pubkey, { + type: "set_config_option", + configId, + value, + }); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d0e8ee0047..62bdf824c8 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -337,18 +337,9 @@ export type ManagedAgent = { modelSource: "definition" | "global" | "instance_legacy" | null; /** LLM inference provider, from the agent's pinned record snapshot. */ provider: string | null; - /** - * `true` when the linked persona has been edited since this agent was - * created — the running agent uses the older pinned snapshot. Surface a - * "out of date" marker and prompt the user to delete + respawn to update. - * Always `false` for non-persona agents and for orphaned agents. - */ + /** True when the linked persona has been edited since this agent was created. */ personaOutOfDate: boolean; - /** - * `true` when the agent's linked persona no longer exists. Distinct from - * out-of-date: there is no current persona to respawn into, so do not prompt - * a respawn — the pinned snapshot is all the config that remains. - */ + /** True when this agent's linked persona no longer exists. */ personaOrphaned: boolean; /** * `true` when the running process was spawned with a config that no longer @@ -461,12 +452,7 @@ export type CancelManagedAgentTurnResult = { status: "sent" | "no_active_turn"; }; -/** - * Outcome of a live `switch_model` control frame, surfaced asynchronously via - * the agent's `control_result` observer frame. Busy path: `sent` (cancel + - * requeue on the new model) or `turn_ending` (oneshot already consumed this - * turn). Idle path: `switched`, `unsupported_model`, or `no_active_turn`. - */ +/** Outcome of a live `switch_model` control frame (`control_result` observer). */ export type SwitchManagedAgentModelStatus = | "sent" | "turn_ending" @@ -474,11 +460,14 @@ export type SwitchManagedAgentModelStatus = | "unsupported_model" | "no_active_turn"; -export type ControlResultFrame = { - type: "cancel_turn" | "switch_model"; - status: string; - modelId?: string; -}; +export type ControlResultFrame = + | { type: "cancel_turn" | "switch_model"; status: string; modelId?: string } + | { + type: "set_config_option"; + status: "ok" | string; + configId: string; + value: string; + }; export type GitBashPrerequisite = { available: boolean; @@ -657,7 +646,12 @@ export type ConfigSourceReport = { mcpConfigFilePath: string | null; }; -export type ExtensionEntry = { name: string; kind: string; enabled: boolean }; +export type ExtensionEntry = { + name: string; + kind: string; + enabled: boolean; + source?: string; +}; export type NormalizedConfig = { model: NormalizedField | null; @@ -677,6 +671,8 @@ export type RuntimeConfigSurface = { advanced: ConfigField[]; extensions: ExtensionEntry[]; sources: ConfigSourceReport; + /** Owner `~/.claude/settings.json` env keys stripped by Buzz launch policy (B7.7c). */ + strippedOwnerEnvKeys?: string[]; }; export type UpdateManagedAgentInput = {