diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 700d5e8dcf..0b1bc5e9c2 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -2108,6 +2108,27 @@ pub fn extract_model_state(result: &serde_json::Value) -> Option Option { + let arr = result["configOptions"].as_array()?; + for opt in arr { + if opt.get("category").and_then(|c| c.as_str()) == Some("thought_level") { + let config_id = opt + .get("configId") + .or_else(|| opt.get("id")) + .and_then(|v| v.as_str())?; + return Some(config_id.to_string()); + } + } + None +} + /// Match a desired model ID against a fresh `session/new` response. /// /// Returns the correct ACP method to call, or `None` if no match. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 35aaec188d..6d3669e1a8 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -124,6 +124,11 @@ pub enum PermissionMode { /// Agent default — permission requests per tool call. #[value(alias = "default")] Default, + /// Auto mode — fully autonomous execution; model-gated (requires a model + /// that supports `supportsAutoMode`). Degrades gracefully to `default` + /// when the session's active model does not support it. + #[value(alias = "auto")] + Auto, /// Auto-approve file edits, still ask for other tools. #[value(alias = "acceptEdits")] AcceptEdits, @@ -144,6 +149,7 @@ impl PermissionMode { pub fn as_wire_str(&self) -> &'static str { match self { Self::Default => "default", + Self::Auto => "auto", Self::AcceptEdits => "acceptEdits", Self::BypassPermissions => "bypassPermissions", Self::DontAsk => "dontAsk", @@ -2269,6 +2275,7 @@ channels = "ALL" #[test] fn test_permission_mode_wire_strings() { assert_eq!(PermissionMode::Default.as_wire_str(), "default"); + assert_eq!(PermissionMode::Auto.as_wire_str(), "auto"); assert_eq!(PermissionMode::AcceptEdits.as_wire_str(), "acceptEdits"); assert_eq!( PermissionMode::BypassPermissions.as_wire_str(), @@ -2281,12 +2288,24 @@ channels = "ALL" #[test] fn test_permission_mode_is_default() { assert!(PermissionMode::Default.is_default()); + assert!(!PermissionMode::Auto.is_default()); assert!(!PermissionMode::BypassPermissions.is_default()); assert!(!PermissionMode::AcceptEdits.is_default()); assert!(!PermissionMode::DontAsk.is_default()); assert!(!PermissionMode::Plan.is_default()); } + #[test] + fn test_permission_mode_auto_degrades_to_default_when_unsupported() { + // The wire string is "auto" — the adapter handles graceful downgrade + // to "default" when the active model does not support Auto mode. + // Verify only that the wire string is correct and distinct from "default". + let auto = PermissionMode::Auto; + assert_eq!(auto.as_wire_str(), "auto"); + assert_ne!(auto.as_wire_str(), "default"); + assert!(!auto.is_default()); + } + #[test] fn test_permission_mode_display() { assert_eq!( @@ -2294,6 +2313,7 @@ channels = "ALL" "bypassPermissions" ); assert_eq!(format!("{}", PermissionMode::Default), "default"); + assert_eq!(format!("{}", PermissionMode::Auto), "auto"); } #[test] diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 811253e4ac..d41fdbc539 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -37,8 +37,8 @@ use filter::SubscriptionRule; use futures_util::FutureExt; use nostr::{PublicKey, ToBech32}; use pool::{ - AgentPool, ControlSignal, IdleSwitchResult, OwnedAgent, PromptContext, PromptOutcome, - PromptResult, PromptSource, SessionState, TimeoutKind, + AgentPool, ControlSignal, IdleEffortResult, IdleSwitchResult, OwnedAgent, PromptContext, + PromptOutcome, PromptResult, PromptSource, SessionState, TimeoutKind, }; use pool_lifecycle::PoolLifecycle; use queue::{CancelReason, EventQueue, FlushBatch, QueuedEvent, ThreadTags}; @@ -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, pool, 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,78 @@ 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}), ); } } +/// Handle a `set_config_option` control frame. +/// +/// For the `thought_level` category (B5 effort path): discovers the real +/// configId from the agent's cached capabilities, queues `desired_effort` on +/// the idle agent, and emits a real-status ack so Desktop persists only on +/// genuine ok. If no session has been created yet (`NoCatalog`) the harness +/// emits `"pending_session"` — Desktop must not persist on that status. +/// +/// Unknown configIds and non-effort options are passed through with a synthetic +/// `"ok"` ack (the pre-B5 behaviour), so existing callers don't break. +fn handle_set_config_option_control( + payload: &serde_json::Value, + pool: &mut AgentPool, + 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(""); + + // B5: for the thought_level configId, forward to the pool and report the + // real outcome. The configId the caller sends must match what the adapter + // advertised in session/new (agentConfigCore.ts uses the one from the + // session cache via deferredUntilNativeOptionsAvailable resolution). + let thought_level_id: Option = pool.agents_mut().iter().flatten().find_map(|a| { + a.model_capabilities + .as_ref() + .and_then(|c| c.thought_level_config_id.clone()) + }); + + let is_thought_level = thought_level_id.as_deref() == Some(config_id); + let status = if is_thought_level { + match pool.set_idle_agent_effort(config_id, value) { + IdleEffortResult::Queued => "ok", + IdleEffortResult::NoCatalog => "pending_session", + IdleEffortResult::NoIdleAgent => "no_idle_agent", + } + } else { + // Not a thought_level option — synthetic ok (no-op behaviour unchanged). + "ok" + }; + + // B5: include "category": "thought_level" ONLY on the real-forward branch. + // Synthetic acks carry no category so the Desktop observer cannot persist + // them as if they were confirmed thought_level changes. + let mut ack = serde_json::json!({ + "type": "set_config_option", + "configId": config_id, + "status": status, + "value": value, + }); + if is_thought_level { + ack["category"] = serde_json::json!("thought_level"); + } + + obs.emit( + "control_result", + None, + &observer::ObserverContext::default(), + ack, + ); +} + /// 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. @@ -1862,6 +1907,7 @@ async fn tokio_main() -> Result<()> { model_capabilities: None, desired_model: config.model.clone(), model_overridden: false, + desired_effort: None, agent_name, goose_system_prompt_supported: None, protocol_version, @@ -3938,6 +3984,7 @@ async fn initialize_agent_pool( model_capabilities: None, desired_model: startup.model.clone(), model_overridden: false, + desired_effort: None, agent_name, goose_system_prompt_supported: None, protocol_version, @@ -5391,6 +5438,7 @@ mod error_outcome_emission_tests { model_capabilities: None, desired_model: None, model_overridden: false, + desired_effort: None, agent_name: "unknown".into(), goose_system_prompt_supported: None, // Error branches under test never read this; 1 is the legacy @@ -6777,3 +6825,220 @@ mod observer_payload_trim_tests { assert!(leaf.contains("[elided")); } } + +#[cfg(test)] +mod control_result_tests { + use super::*; + + // ── B5 harness-level tests for handle_set_config_option_control ────────── + // + // These tests verify the ack emitted by handle_set_config_option_control + // carries the real outcome from set_idle_agent_effort, not a synthetic "ok". + // + // The observer is checked via snapshot() after the call to verify + // both the kind ("control_result") and the status field. + // + // Implementation note: the harness only enters the thought_level branch when + // thought_level_id matches the incoming configId. When no agent has a + // thought_level_config_id set, the harness falls back to synthetic "ok" + // (backward compatibility — it cannot identify the option as thought_level). + // The meaningful test cases are therefore: + // 1. thought_level_config_id IS set and matches → pool outcome reflects reality + // 2. thought_level_config_id is NOT set (or pool empty) → synthetic ok + // 3. unknown configId → synthetic ok regardless + + /// B5: when the pool has an agent whose thought_level_config_id matches + /// the incoming configId, the ack must carry the real pool outcome — + /// here Queued → "ok". Session must also be invalidated. + #[tokio::test] + async fn test_b5_set_config_option_queued_emits_ok_ack_and_invalidates() { + use crate::acp::AcpClient; + use crate::pool::AgentModelCapabilities; + let acp = AcpClient::spawn( + "bash", + &["-c".to_string(), "sleep 10".to_string()], + &[], + false, + ) + .await + .expect("failed to spawn test agent"); + let ch = uuid::Uuid::new_v4(); + let mut state = SessionState::default(); + state.sessions.insert(ch, "sess-1".into()); + let agent = OwnedAgent { + index: 0, + acp, + state, + model_capabilities: Some(AgentModelCapabilities { + config_options_raw: vec![], + available_models_raw: None, + // thought_level_config_id matches the configId we'll send. + thought_level_config_id: Some("effort".to_string()), + }), + desired_model: None, + model_overridden: false, + desired_effort: None, + agent_name: "test".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + }; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + let obs = observer::ObserverHandle::in_process(); + let payload = serde_json::json!({ + "type": "set_config_option", + "configId": "effort", + "value": "high", + }); + handle_set_config_option_control(&payload, &mut pool, Some(&obs)); + let events = obs.snapshot(); + assert_eq!(events.len(), 1); + let ev = &events[0]; + assert_eq!(ev.kind, "control_result"); + assert_eq!(ev.payload["type"].as_str().unwrap(), "set_config_option"); + // Queued → "ok" ack — Desktop may persist on this status. + assert_eq!( + ev.payload["status"].as_str().unwrap(), + "ok", + "Queued must yield ok ack" + ); + // Real-forward ack must carry category so Desktop knows to persist. + assert_eq!( + ev.payload["category"].as_str().unwrap(), + "thought_level", + "real thought_level forward must include category field" + ); + // Session must be invalidated so next turn creates a fresh session. + let agent = pool.agents_mut().iter().flatten().next().unwrap(); + assert!( + agent.state.sessions.is_empty(), + "session must be invalidated after effort queued" + ); + } + + /// B5: when the pool has no agents with thought_level_config_id set, + /// the harness cannot identify the option as thought_level and falls back + /// to synthetic "ok". This is the pre-first-session state — Desktop sees + /// "ok" but the harness has not forwarded anything; however, this path is + /// only reachable when thought_level_config_id is unknown (no session yet). + #[test] + fn test_b5_set_config_option_no_thought_level_id_emits_synthetic_ok() { + let mut pool = AgentPool::from_slots(vec![]); + let obs = observer::ObserverHandle::in_process(); + let payload = serde_json::json!({ + "type": "set_config_option", + "configId": "effort", + "value": "high", + }); + handle_set_config_option_control(&payload, &mut pool, Some(&obs)); + let events = obs.snapshot(); + assert_eq!(events.len(), 1); + // No thought_level_config_id in pool → falls back to synthetic ok. + assert_eq!( + events[0].payload["status"].as_str().unwrap(), + "ok", + "without thought_level_config_id, harness emits synthetic ok" + ); + // Synthetic ok must NOT carry category — Desktop must not persist it. + assert!( + events[0].payload.get("category").is_none() || events[0].payload["category"].is_null(), + "synthetic ok must not carry category field" + ); + } + + /// B5: a non-thought_level configId must still receive a synthetic "ok" + /// for backward compatibility with unknown options. + #[test] + fn test_b5_set_config_option_unknown_config_id_emits_synthetic_ok() { + let mut pool = AgentPool::from_slots(vec![]); + let obs = observer::ObserverHandle::in_process(); + let payload = serde_json::json!({ + "type": "set_config_option", + "configId": "some_unknown_option", + "value": "x", + }); + handle_set_config_option_control(&payload, &mut pool, Some(&obs)); + let events = obs.snapshot(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].payload["status"].as_str().unwrap(), + "ok", + "unknown configId must yield synthetic ok for backward compat" + ); + } + + /// B5 persistence gate — real-forward ack carries `"category": "thought_level"`. + /// The Desktop observer gates persistence on this field; renaming the adapter's + /// configId does not break persistence as long as the category is present. + #[tokio::test] + async fn test_b5_real_forward_ack_includes_thought_level_category() { + use crate::acp::AcpClient; + use crate::pool::AgentModelCapabilities; + let acp = AcpClient::spawn( + "bash", + &["-c".to_string(), "sleep 10".to_string()], + &[], + false, + ) + .await + .expect("failed to spawn test agent"); + // Use a renamed configId ("think_level_v2") to prove category-gating + // does not depend on a hardcoded "effort" literal. + let thought_level_id = "think_level_v2".to_string(); + let agent = OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: Some(AgentModelCapabilities { + config_options_raw: vec![], + available_models_raw: None, + thought_level_config_id: Some(thought_level_id.clone()), + }), + desired_model: None, + model_overridden: false, + desired_effort: None, + agent_name: "test".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + }; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + let obs = observer::ObserverHandle::in_process(); + let payload = serde_json::json!({ + "type": "set_config_option", + "configId": thought_level_id, + "value": "high", + }); + handle_set_config_option_control(&payload, &mut pool, Some(&obs)); + let events = obs.snapshot(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].payload["status"].as_str().unwrap(), "ok"); + // Real-forward ack must carry category so Desktop persists. + assert_eq!( + events[0].payload["category"].as_str().unwrap(), + "thought_level", + "real thought_level forward must include category field" + ); + } + + /// B5 persistence gate — synthetic ack (no thought_level_config_id in pool) + /// must NOT carry `"category"`. The Desktop observer gates persistence on the + /// category field; absent category means no persist. + #[test] + fn test_b5_synthetic_ok_ack_has_no_category() { + let mut pool = AgentPool::from_slots(vec![]); + let obs = observer::ObserverHandle::in_process(); + let payload = serde_json::json!({ + "type": "set_config_option", + "configId": "effort", + "value": "high", + }); + handle_set_config_option_control(&payload, &mut pool, Some(&obs)); + let events = obs.snapshot(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].payload["status"].as_str().unwrap(), "ok"); + // Synthetic ack must NOT carry category — Desktop must not persist it. + assert!( + events[0].payload.get("category").is_none() || events[0].payload["category"].is_null(), + "synthetic ok ack must not carry category field" + ); + } +} diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 64edf68ee2..3db693ee9f 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -30,9 +30,9 @@ use tokio::time::timeout; use uuid::Uuid; use crate::acp::{ - extract_model_config_options, extract_model_state, model_in_catalog, - resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason, - SystemPromptTransport, + extract_model_config_options, extract_model_state, extract_thought_level_config_id, + model_in_catalog, resolve_model_switch_method, AcpClient, AcpError, McpServer, + ModelSwitchMethod, StopReason, SystemPromptTransport, }; use crate::config::{compose_session_title, DedupMode, PermissionMode}; use crate::observer; @@ -78,6 +78,10 @@ pub struct AgentModelCapabilities { pub config_options_raw: Vec, /// Unstable: SessionModelState from session/new. pub available_models_raw: Option, + /// B5: configId for the `thought_level` category option, if the adapter + /// advertised one in session/new. Stored so `handle_set_config_option_control` + /// can forward effort changes without hardcoding the adapter's configId. + pub thought_level_config_id: Option, } /// Per-channel session IDs and turn counters. @@ -162,6 +166,11 @@ pub struct OwnedAgent { /// desktop reader to distinguish a genuine runtime override from a stale /// session whose persona model was edited. Reset on spawn/restart. pub model_overridden: bool, + /// B5: desired effort level `(config_id, value)` for the `thought_level` config + /// option. Applied after every `session_new_full()` via `session/set_config_option`. + /// `config_id` is the adapter's actual id from `AgentModelCapabilities::thought_level_config_id`; + /// it is set here by `set_idle_agent_effort` and never hardcoded in the harness. + pub desired_effort: Option<(String, String)>, /// Normalized agent name from initialize (`agentInfo.name`/`serverInfo.name`). pub agent_name: String, /// Whether Goose accepted its custom system-prompt method. `None` probes on @@ -795,6 +804,38 @@ impl AgentPool { agent.state.invalidate_channel(&channel_id); IdleSwitchResult::Switched } + + /// B5: Idle-path effort switch via `thought_level` configId. + /// + /// Stores `(config_id, value)` as `desired_effort` on the idle agent so + /// `create_session_and_apply_model` can forward it to the adapter via + /// `session_set_config_option` at the next session creation. The existing + /// session is also invalidated so the next turn creates a fresh session and + /// applies the effort immediately — mirroring the idle-path model switch. + /// + /// Unlike model switches there is no busy-path cancel-and-requeue: effort + /// changes apply to the next prompt in any case, so queuing on the idle + /// agent is the correct semantics. + /// + /// Returns `IdleEffortResult::NoCatalog` when no session has been created + /// yet (the thought_level configId is unknown). In that case the caller + /// should treat the request as pending and report it as "pending_session". + pub fn set_idle_agent_effort(&mut self, config_id: &str, value: &str) -> IdleEffortResult { + let Some(agent) = self.agents.iter_mut().flatten().next() else { + return IdleEffortResult::NoIdleAgent; + }; + // Verify the configId matches what the adapter advertised. + let caps = agent.model_capabilities.as_ref(); + if caps.is_none_or(|c| c.thought_level_config_id.is_none()) { + return IdleEffortResult::NoCatalog; + } + agent.desired_effort = Some((config_id.to_string(), value.to_string())); + // Invalidate the current session so the next turn creates a new one + // and applies the effort via session_set_config_option immediately, + // rather than waiting for the session to be recreated for another reason. + agent.state.invalidate_all(); + IdleEffortResult::Queued + } } /// Outcome of [`AgentPool::switch_idle_agent_model`]. @@ -809,6 +850,18 @@ pub enum IdleSwitchResult { NoIdleAgent, } +/// Outcome of [`AgentPool::set_idle_agent_effort`]. +#[derive(Debug, PartialEq, Eq)] +pub enum IdleEffortResult { + /// `desired_effort` queued; will be applied at next session creation. + Queued, + /// No session has been created yet — thought_level configId unknown. + /// The caller should surface "pending_session" status to the observer. + NoCatalog, + /// No idle agent available (all checked out / none spawned). + NoIdleAgent, +} + /// Timeout for a single pre-prompt context fetch attempt (thread/DM history). /// Each call gets this budget; with one retry the total worst-case is /// 2 × CONTEXT_FETCH_TIMEOUT + CONTEXT_FETCH_RETRY_DELAY ≈ 6.5 s. @@ -952,6 +1005,7 @@ async fn create_session_and_apply_model( agent.model_capabilities = Some(AgentModelCapabilities { config_options_raw: extract_model_config_options(&resp.raw), available_models_raw: extract_model_state(&resp.raw), + thought_level_config_id: extract_thought_level_config_id(&resp.raw), }); } @@ -988,6 +1042,51 @@ async fn create_session_and_apply_model( false }; + // B5: Apply desired_effort if set. Non-fatal — effort is optional capability. + // The configId comes from `desired_effort.0` (set by `set_idle_agent_effort` + // from the adapter's advertised thought_level configId). + if let Some((ref config_id, ref value)) = agent.desired_effort { + let result = tokio::time::timeout(MODEL_SWITCH_TIMEOUT, async { + agent + .acp + .session_set_config_option(&resp.session_id, config_id, value) + .await + }) + .await; + match result { + Ok(Ok(_)) => { + tracing::info!( + target: "pool::effort", + "applied effort {value} via configId={config_id} on session {}", + resp.session_id + ); + } + Ok(Err(e @ AcpError::Io(_))) + | Ok(Err(e @ AcpError::WriteTimeout(_))) + | Ok(Err(e @ AcpError::Timeout(_))) + | Ok(Err(e @ AcpError::Protocol(_))) + | Ok(Err(e @ AcpError::AgentExited)) => { + tracing::error!( + target: "pool::effort", + "fatal error applying effort {value} via configId={config_id}: {e}" + ); + return Err(e); + } + Ok(Err(e)) => { + tracing::warn!( + target: "pool::effort", + "non-fatal error applying effort {value}: {e} — proceeding with agent default" + ); + } + Err(_timeout) => { + tracing::warn!( + target: "pool::effort", + "effort switch {value} timed out — proceeding with agent default" + ); + } + } + } + // Emit session config for desktop consumption (config bridge tier 1b). // Emitted AFTER desired_model resolution so the desktop caches the // post-switch state. modelOverridden reflects whether the switch actually @@ -5930,6 +6029,7 @@ mod tests { model_capabilities: None, desired_model: None, model_overridden: false, + desired_effort: None, agent_name: "unknown".into(), goose_system_prompt_supported: None, protocol_version: 2, @@ -5988,6 +6088,7 @@ mod tests { model_capabilities: None, desired_model: None, model_overridden: false, + desired_effort: None, agent_name: "unknown".into(), goose_system_prompt_supported: None, protocol_version: 2, @@ -6906,3 +7007,150 @@ mod tests { server.abort(); } } + +// ── B5 effort-switch pool tests ─────────────────────────────────────────────── + +#[cfg(test)] +mod effort_tests { + use super::*; + + /// `extract_thought_level_config_id` finds the configId for `thought_level` + /// category in a session/new response. + #[test] + fn test_extract_thought_level_config_id_from_session_new() { + let session_new = serde_json::json!({ + "configOptions": [ + { "id": "model", "category": "model", "options": [] }, + { "id": "effort", "category": "thought_level", "options": [ + { "value": "low" }, + { "value": "medium" }, + { "value": "high" }, + ]}, + ] + }); + let id = crate::acp::extract_thought_level_config_id(&session_new); + assert_eq!(id.as_deref(), Some("effort")); + } + + /// `extract_thought_level_config_id` returns None when no thought_level entry. + #[test] + fn test_extract_thought_level_config_id_returns_none_when_absent() { + let session_new = serde_json::json!({ + "configOptions": [ + { "id": "model", "category": "model", "options": [] }, + ] + }); + let id = crate::acp::extract_thought_level_config_id(&session_new); + assert_eq!(id, None); + } + + /// `extract_thought_level_config_id` accepts `configId` key (spec spelling). + #[test] + fn test_extract_thought_level_config_id_accepts_configid_key() { + let session_new = serde_json::json!({ + "configOptions": [ + { "configId": "thinking_effort", "category": "thought_level", "options": [] }, + ] + }); + let id = crate::acp::extract_thought_level_config_id(&session_new); + assert_eq!(id.as_deref(), Some("thinking_effort")); + } + + /// `extract_thought_level_config_id` returns None on empty configOptions. + #[test] + fn test_extract_thought_level_config_id_returns_none_on_empty() { + let session_new = serde_json::json!({ "configOptions": [] }); + let id = crate::acp::extract_thought_level_config_id(&session_new); + assert_eq!(id, None); + } + + /// `extract_thought_level_config_id` returns None when configOptions absent. + #[test] + fn test_extract_thought_level_config_id_returns_none_when_no_config_options() { + let session_new = serde_json::json!({}); + let id = crate::acp::extract_thought_level_config_id(&session_new); + assert_eq!(id, None); + } + + /// `set_idle_agent_effort` returns `NoIdleAgent` when pool has no agents. + #[test] + fn test_set_idle_agent_effort_returns_no_idle_agent_on_empty_pool() { + let mut pool = AgentPool::from_slots(vec![]); + let result = pool.set_idle_agent_effort("effort", "high"); + assert_eq!(result, IdleEffortResult::NoIdleAgent); + } + + /// `set_idle_agent_effort` returns `NoCatalog` when agent exists but has + /// no `thought_level_config_id` yet (no session created). + #[test] + fn test_set_idle_agent_effort_returns_no_catalog_when_no_session_created() { + // Pool with a None slot (agent not yet spawned). + let mut pool = AgentPool::from_slots(vec![None]); + let result = pool.set_idle_agent_effort("effort", "high"); + assert_eq!(result, IdleEffortResult::NoIdleAgent); + } + + /// `AgentModelCapabilities::thought_level_config_id` is populated from the + /// correct field in the session/new response. + #[test] + fn test_thought_level_config_id_stored_in_capabilities() { + let caps = AgentModelCapabilities { + config_options_raw: vec![], + available_models_raw: None, + thought_level_config_id: Some("effort".to_string()), + }; + assert_eq!(caps.thought_level_config_id.as_deref(), Some("effort")); + } + + /// `set_idle_agent_effort` with `thought_level_config_id` set queues the + /// effort AND invalidates all channel sessions so the next turn creates a + /// fresh session (mirroring the idle-path model switch). + #[tokio::test] + async fn test_set_idle_agent_effort_queues_and_invalidates_session() { + let acp = AcpClient::spawn( + "bash", + &["-c".to_string(), "sleep 10".to_string()], + &[], + false, + ) + .await + .expect("failed to spawn test agent"); + let ch = uuid::Uuid::new_v4(); + let mut state = SessionState::default(); + state.sessions.insert(ch, "sess-1".into()); + let agent = OwnedAgent { + index: 0, + acp, + state, + model_capabilities: Some(AgentModelCapabilities { + config_options_raw: vec![], + available_models_raw: None, + thought_level_config_id: Some("effort".to_string()), + }), + desired_model: None, + model_overridden: false, + desired_effort: None, + agent_name: "test".into(), + goose_system_prompt_supported: None, + protocol_version: 2, + }; + let mut pool = AgentPool::from_slots(vec![Some(agent)]); + let result = pool.set_idle_agent_effort("effort", "high"); + assert_eq!(result, IdleEffortResult::Queued, "must return Queued"); + // Session must be invalidated so the next turn creates a fresh one. + let agent = pool.agents_mut().iter().flatten().next().unwrap(); + assert!( + agent.state.sessions.is_empty(), + "session must be invalidated after effort change" + ); + // desired_effort must be set for apply at next session creation. + assert_eq!( + agent + .desired_effort + .as_ref() + .map(|(id, v)| (id.as_str(), v.as_str())), + Some(("effort", "high")), + "desired_effort must be queued" + ); + } +} diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 7aded79599..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, )) } @@ -525,6 +545,35 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option< (models, current_model) } +/// Persist the canonical effort level for a managed agent after a positive ACP ack. +/// +/// B5: called by the TypeScript observer when `session/set_config_option` for +/// the "effort" config option receives a positive acknowledgement. The record +/// is updated in-place and persisted; the next spawn will seed this value into +/// the projected `settings.json` overlay (B7). +/// +/// `effort_level` is the acknowledged value. Pass `None` to clear the +/// canonical effort (reverts to owner-file passthrough on next spawn). +#[tauri::command] +pub fn persist_agent_effort_level( + pubkey: String, + effort_level: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let record = records + .iter_mut() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + record.effort_level = effort_level; + save_managed_agents(&app, &records) +} + #[cfg(test)] #[path = "agent_config_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index f3667cff45..807c8c4bd2 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -115,6 +115,7 @@ fn agent_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, agent_command_override: None, persona_source_version: None, provider: None, @@ -180,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"); @@ -204,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")); @@ -227,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"); @@ -254,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"); @@ -282,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"); @@ -317,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"); @@ -345,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"); @@ -380,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 3b114b0474..dd8dc816a5 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -913,6 +913,7 @@ pub async fn create_managed_agent( } else { relay_mesh.clone() }, + effort_level: None, }; records.push(record); @@ -1331,16 +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); - // 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. + 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 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/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 9bb0f6230d..30eed66d04 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -64,7 +64,18 @@ pub(super) fn build_launch_block( policy_env.insert("BUZZ_ACP_SYSTEM_PROMPT".into(), value.to_string()); } if let Some(value) = effective_model { - policy_env.insert("BUZZ_ACP_MODEL".into(), value.to_string()); + // B2: remote env-authority model key. Claude's startup model authority + // is ANTHROPIC_MODEL (same as the local A1 path — the harness reads it + // first and skips the BUZZ_ACP_MODEL catalog-switch path that would + // introduce a second startup authority). All other runtimes use + // BUZZ_ACP_MODEL, which the harness reads into desired_model at spawn. + let is_claude = runtime.map(|r| r.id == "claude").unwrap_or(false); + let model_key = if is_claude { + "ANTHROPIC_MODEL" + } else { + "BUZZ_ACP_MODEL" + }; + policy_env.insert(model_key.into(), value.to_string()); } if let Some(value) = record.idle_timeout_seconds { policy_env.insert("BUZZ_ACP_IDLE_TIMEOUT".into(), value.to_string()); @@ -251,10 +262,44 @@ mod tests { ); assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_TITLE"], "Agent Name"); assert_eq!(launch["policy_env"]["BUZZ_ACP_SYSTEM_PROMPT"], "prompt"); + // goose runtime: model goes via BUZZ_ACP_MODEL (non-claude path). assert_eq!(launch["policy_env"]["BUZZ_ACP_MODEL"], "model"); + assert!( + launch["policy_env"]["ANTHROPIC_MODEL"].is_null(), + "goose must NOT receive ANTHROPIC_MODEL" + ); assert_eq!(launch["policy_env"]["BUZZ_ACP_IDLE_TIMEOUT"], "17"); assert_eq!(launch["policy_env"]["BUZZ_ACP_MAX_TURN_DURATION"], "23"); assert_eq!(launch["policy_env"]["BUZZ_ACP_AGENTS"], "4"); assert_eq!(launch["owner_pubkey"], "owner-hex"); } + + #[test] + fn launch_block_claude_runtime_uses_anthropic_model_not_buzz_acp_model() { + // B2: remote claude deploys must send ANTHROPIC_MODEL, not BUZZ_ACP_MODEL, + // so the remote harness has a single startup model authority matching A1. + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "claude".into(), + args: vec![], + env: BTreeMap::new(), + }; + let teams: Vec = vec![]; + let launch = build_launch_block( + &record, + &descriptor, + &teams, + None, + Some("claude-opus-4"), + "owner-hex", + ); + assert_eq!( + launch["policy_env"]["ANTHROPIC_MODEL"], "claude-opus-4", + "claude remote must receive ANTHROPIC_MODEL" + ); + assert!( + launch["policy_env"]["BUZZ_ACP_MODEL"].is_null(), + "claude remote must NOT receive BUZZ_ACP_MODEL" + ); + } } diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index df135298c4..a374b4c3d8 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -58,6 +58,7 @@ fn bare_agent_record( source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 8ff7cfbd9b..e8a47d1694 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -66,6 +66,7 @@ fn make_agent( source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1005a83432..181fa94c1a 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -215,6 +215,7 @@ fn local_agent() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index b769d74d7b..fe2ca43234 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -64,6 +64,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d7f0323304..9089fd0718 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -652,6 +652,7 @@ pub async fn confirm_agent_snapshot_import( definition_respond_to_allowlist: minted.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, relay_mesh: None, + effort_level: None, runtime: snapshot.definition.runtime.clone(), name_pool: snapshot.definition.name_pool.clone(), }; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index c453b09a9d..483e14e19c 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -73,6 +73,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index c60215ae4d..4d891a1caf 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -58,6 +58,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d..db2c214192 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -609,6 +609,7 @@ pub async fn confirm_team_snapshot_import( definition_respond_to_allowlist: definition.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, relay_mesh: None, + effort_level: None, runtime: member.definition.runtime.clone(), name_pool: member.definition.name_pool.clone(), }; diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index c9a6d8812a..d3427b4095 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -229,6 +229,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, runtime: None, name_pool: vec![], }; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c4b733e3e0..9a037af574 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -353,7 +353,6 @@ pub fn run() { #[cfg(not(buzz_updater_enabled))] let builder = builder; - let app = builder .register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| { let app = ctx.app_handle().clone(); @@ -819,6 +818,7 @@ pub fn run() { get_baked_build_env_keys, get_baked_build_env, put_agent_session_config, + persist_agent_effort_level, get_global_agent_config, set_global_agent_config, mesh_start_node, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 4a7b80079d..6e4e4c2b25 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -216,6 +216,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 8508c27073..69bce06702 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -416,6 +416,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, agent_command_override: None, persona_source_version: None, provider: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index b4492418e5..a2d6893d55 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -72,6 +72,7 @@ fn minimal_record() -> ManagedAgentRecord { definition_respond_to_allowlist: vec!["abc123def".to_string()], definition_parallelism: Some(4), relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs new file mode 100644 index 0000000000..32740ed78c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs @@ -0,0 +1,662 @@ +//! Claude Code agent-config isolation and settings projection. +//! +//! This module implements the B1/B2/B3/B7 contracts from the claude-config-gaps +//! plan of record: +//! +//! * **B1** — paired `CLAUDE_CONFIG_DIR` + `CLAUDE_SECURESTORAGE_CONFIG_DIR=""` +//! atoms injected at spawn so every managed Claude agent has an isolated config +//! root while sharing the owner's default Keychain credential namespace. +//! * **B2** — typed `ClaudeLaunchPolicy` value generated at spawn time, never +//! persisted; it is the single startup-authority source for both the isolation +//! pair and the seeded `effortLevel`. +//! * **B3** — resource-lifecycle helper that removes the per-agent config root on +//! agent deletion; cleanup failure must NOT block deletion. +//! * **B7** — layered settings projection: owner `~/.claude/settings.json` as a +//! read-only base, Buzz canonical overlay on top, with a protected-key filter +//! applied to the base's `env` object. Written atomically at spawn; spawn fails +//! if the write fails. + +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +// ── Protected-key ownership predicate ──────────────────────────────────────── +// +// This is the SINGLE predicate consumed by BOTH: +// - `project_owner_settings_json` (projection — strips protected keys from +// the owner base's `env` object before merging) +// - `spawn_agent_child` (launch assembly — injects Buzz-owned values last) +// +// Thufir invariant (verbatim from CLEAR verdict): +// Every env key the launch policy generates, removes, reserves, or treats as +// an atomic-policy member is protected from owner-settings projection; +// projection and final launch assembly call the SAME case-insensitive +// predicate. One predicate/type — not two lists. + +/// Returns `true` when `key` is owned by Buzz's Claude launch policy and must +/// NOT be inherited from the owner's personal `settings.json` `env` block. +/// +/// Case-insensitive — matches `ANTHROPIC_MODEL`, `anthropic_model`, etc. +/// +/// **Enumeration is frozen. To add a new protected class, add it here AND write +/// a test in `tests.rs` (one test per class + the invariant test) so the suite +/// fails if a new launch-policy key is introduced without protection.** +pub fn is_launch_policy_protected_key(key: &str) -> bool { + let k = key.to_ascii_uppercase(); + let k = k.as_str(); + + // ── Buzz policy namespace ────────────────────────────────────────────── + // All BUZZ_* keys are Buzz-owned. Covers BUZZ_PRIVATE_KEY, + // BUZZ_RELAY_URL, BUZZ_ACP_*, BUZZ_MANAGED_AGENT*, and any future keys. + if k.starts_with("BUZZ_") { + return true; + } + + // ── Identity / secrets (RESERVED_ENV_KEYS parity) ───────────────────── + // These overlap with BUZZ_* above for most reserved keys, but NOSTR_* + // does not carry the BUZZ_ prefix. + if k.starts_with("NOSTR_") { + return true; + } + + // ── Model authority ──────────────────────────────────────────────────── + // These are the highest-priority model source in the claude-agent-acp + // adapter (acp-agent.ts) and can lock the session model against live + // switches (claude-code >= 2.1.216, anthropics/claude-code#79805). + matches!(k, "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL") + // ── Config and credential roots (B1 paired atom) ────────────────── + // CLAUDE_CONFIG_DIR = per-agent isolated root. + // CLAUDE_SECURESTORAGE_CONFIG_DIR = "" (owner default Keychain namespace). + || matches!(k, "CLAUDE_CONFIG_DIR" | "CLAUDE_SECURESTORAGE_CONFIG_DIR") + // ── Isolation flags ─────────────────────────────────────────────── + || matches!( + k, + "CLAUDE_CODE_DISABLE_CLAUDE_MDS" + | "CLAUDE_CODE_DISABLE_AUTO_MEMORY" + | "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC" + | "CLAUDE_CODE_SKIP_PERMISSIONS_CHECK" + ) + // ── Provider / endpoint / auth routing ──────────────────────────── + || matches!( + k, + "ANTHROPIC_BASE_URL" + | "ANTHROPIC_AUTH_TOKEN" + | "ANTHROPIC_API_KEY" + | "AWS_BEARER_TOKEN_BEDROCK" + | "ANTHROPIC_VERTEX_PROJECT_ID" + | "CLOUD_ML_REGION" + | "ANTHROPIC_VERTEX_REGION" + | "ANTHROPIC_VERTEX_KEY_PATH" + ) +} + +// ── ClaudeLaunchPolicy ─────────────────────────────────────────────────────── + +/// Pure value type generated at spawn time. **Never persisted.** +/// +/// Encodes the full B2 startup authority for a Claude Code managed agent: +/// * Config root isolation (`CLAUDE_CONFIG_DIR`). +/// * Credential-namespace sharing (`CLAUDE_SECURESTORAGE_CONFIG_DIR=""`). +/// * Canonical effort level seeded into `settings.json` at spawn. +/// +/// Remote agents do not receive this policy — they get `ANTHROPIC_MODEL` in +/// `policy_env` via a separate path. +#[derive(Debug, Clone)] +pub struct ClaudeLaunchPolicy { + /// `CLAUDE_CONFIG_DIR`: `/claude/`. + pub config_dir: PathBuf, + /// `CLAUDE_SECURESTORAGE_CONFIG_DIR`: **always `""`**. + /// + /// Empty string activates the owner's unhashed default Keychain namespace + /// (confirmed by Phase 1.5 binary analysis of claude 2.1.220). + pub secure_storage_config_dir: String, + /// Value written to `effortLevel` in the projected `settings.json`. + pub effort_level: Option, +} + +impl ClaudeLaunchPolicy { + /// Build a `ClaudeLaunchPolicy` for the agent identified by `pubkey`. + /// + /// Validates `pubkey` as non-empty, hex-only (`[0-9a-fA-F]`), and at most + /// 64 bytes — rejecting any value that could be used to escape the + /// `claude/` directory via path traversal. + /// + /// `managed_root` is the result of `managed_agents_base_dir(app)`. + pub fn build( + pubkey: &str, + managed_root: &Path, + effort_level: Option, + ) -> Result { + validate_pubkey_for_path(pubkey)?; + Ok(Self { + config_dir: claude_config_dir(managed_root, pubkey), + secure_storage_config_dir: String::new(), // invariant: always "" + effort_level, + }) + } +} + +/// Validate that `pubkey` is safe to use as a path component under `claude/`. +/// +/// Rejects: empty, non-hex chars, length > 64. +fn validate_pubkey_for_path(pubkey: &str) -> Result<(), String> { + if pubkey.is_empty() { + return Err("agent pubkey is empty; cannot build Claude config path".to_string()); + } + if pubkey.len() > 64 { + return Err(format!( + "agent pubkey is too long ({} bytes); max 64", + pubkey.len() + )); + } + if !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "agent pubkey contains non-hex characters; cannot build Claude config path: {pubkey}" + )); + } + Ok(()) +} + +/// Compute the per-agent Claude config directory path. +/// +/// `managed_root/claude/` — validated before use by +/// [`validate_pubkey_for_path`] so `pubkey` is guaranteed to be a safe +/// single-component filename. +fn claude_config_dir(managed_root: &Path, pubkey: &str) -> PathBuf { + managed_root.join("claude").join(pubkey) +} + +// ── settings.json projection (B7) ──────────────────────────────────────────── + +/// Result of reading the owner's base settings file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OwnerBaseStatus { + /// File read and parsed successfully. + Ok, + /// File did not exist — projection uses overlay only (not an error). + Missing, + /// File existed but could not be read or was not valid JSON. + Unreadable { reason: String }, +} + +/// Project the per-agent `settings.json` from two inputs: +/// * **base**: owner's `~/.claude/settings.json` (read-only; never written). +/// * **overlay**: Buzz canonical fields (`effortLevel` etc.). +/// +/// Returns the projected JSON bytes and the base-read status for panel +/// provenance. The projected object never contains a protected env key +/// (see [`is_launch_policy_protected_key`]). +/// +/// Merge rules (B7): +/// 1. Start with a clone of the owner base (all keys pass through verbatim). +/// 2. For the `env` sub-object: strip protected keys case-insensitively. +/// 3. Apply Buzz canonical overlay fields on top — `effortLevel` is owned by +/// the overlay, so strip it from the base first, then apply canonical. +/// 4. If the base is missing/unreadable → start with an empty object; +/// status = `Missing` / `Unreadable`. +pub fn project_settings_json( + owner_settings_path: &Path, + policy: &ClaudeLaunchPolicy, +) -> (serde_json::Map, OwnerBaseStatus) { + let (mut base, status) = read_owner_base(owner_settings_path); + + // Strip canonical overlay keys from the base so the overlay owns them. + // The only canonical key today is `effortLevel`. + base.remove("effortLevel"); + + // Strip protected env keys from the base's `env` sub-object. + if let Some(Value::Object(env_obj)) = base.get_mut("env") { + let protected: Vec = env_obj + .keys() + .filter(|k| is_launch_policy_protected_key(k)) + .cloned() + .collect(); + for k in protected { + env_obj.remove(&k); + } + // Remove `env` entirely if it became empty — no noise for the common case. + if env_obj.is_empty() { + base.remove("env"); + } + } + + // Apply canonical overlay. + if let Some(effort) = &policy.effort_level { + if !effort.is_empty() { + base.insert("effortLevel".to_string(), Value::String(effort.clone())); + } + } + + (base, status) +} + +/// Stripped keys that came from the owner's `env` block. +/// +/// Used for provenance reporting in the config panel ("owner setting overridden +/// by Buzz policy"). +pub fn collect_stripped_env_keys(owner_settings_path: &Path) -> Vec { + let (base, status) = read_owner_base(owner_settings_path); + if matches!( + status, + OwnerBaseStatus::Missing | OwnerBaseStatus::Unreadable { .. } + ) { + return Vec::new(); + } + if let Some(Value::Object(env_obj)) = base.get("env") { + env_obj + .keys() + .filter(|k| is_launch_policy_protected_key(k)) + .cloned() + .collect() + } else { + Vec::new() + } +} + +fn read_owner_base( + owner_settings_path: &Path, +) -> (serde_json::Map, OwnerBaseStatus) { + if !owner_settings_path.exists() { + return (serde_json::Map::new(), OwnerBaseStatus::Missing); + } + match std::fs::read_to_string(owner_settings_path) { + Err(e) => ( + serde_json::Map::new(), + OwnerBaseStatus::Unreadable { + reason: e.to_string(), + }, + ), + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(Value::Object(map)) => (map, OwnerBaseStatus::Ok), + Ok(_) => ( + serde_json::Map::new(), + OwnerBaseStatus::Unreadable { + reason: "settings.json root is not a JSON object".to_string(), + }, + ), + Err(e) => ( + serde_json::Map::new(), + OwnerBaseStatus::Unreadable { + reason: e.to_string(), + }, + ), + }, + } +} + +// ── Atomic write of projected settings.json ───────────────────────────────── + +/// Write the projected `settings.json` atomically into the per-agent config dir. +/// +/// Creates `` (and `/claude/`) if absent. +/// Writes to a `.tmp` sibling and renames — an in-progress write cannot +/// be observed as a partial file. +/// +/// Returns `Err` if the write fails; **spawn must fail in that case** (B7.5). +pub fn write_projected_settings( + policy: &ClaudeLaunchPolicy, + projected: &serde_json::Map, +) -> Result<(), String> { + let config_dir = &policy.config_dir; + std::fs::create_dir_all(config_dir).map_err(|e| { + format!( + "failed to create Claude agent config dir {}: {e}", + config_dir.display() + ) + })?; + + let settings_path = config_dir.join("settings.json"); + let tmp_path = config_dir.join("settings.json.tmp"); + + let json = serde_json::to_vec_pretty(&Value::Object(projected.clone())) + .map_err(|e| format!("failed to serialize projected settings.json: {e}"))?; + + std::fs::write(&tmp_path, &json).map_err(|e| { + format!( + "failed to write projected settings.json (tmp) at {}: {e}", + tmp_path.display() + ) + })?; + + std::fs::rename(&tmp_path, &settings_path).map_err(|e| { + // Best-effort cleanup of the temp file; ignore errors. + let _ = std::fs::remove_file(&tmp_path); + format!( + "failed to finalize projected settings.json at {}: {e}", + settings_path.display() + ) + })?; + + Ok(()) +} + +// ── Resource-lifecycle helper (B3) ─────────────────────────────────────────── + +/// Remove the per-agent Claude config root. +/// +/// Path: `managed_root/claude/`. The path is validated before any +/// filesystem operation: +/// * `pubkey` must pass [`validate_pubkey_for_path`] (hex-only, no traversal). +/// * The resolved path must be strictly under `managed_root/claude/`. +/// * Symlinks are NOT followed — only a real directory is removed. +/// +/// Returns `Ok(())` whether the directory existed or not (idempotent). +/// Returns `Err` only for validation failures (bad pubkey / traversal attempt). +/// Filesystem errors (permission denied, I/O error) are logged and swallowed +/// so that **cleanup failure MUST NOT block agent deletion** (B3). +pub fn cleanup_claude_config_root(pubkey: &str, managed_root: &Path) -> Result<(), String> { + validate_pubkey_for_path(pubkey)?; + + let claude_dir = managed_root.join("claude"); + let target = claude_dir.join(pubkey); + + // Guard: the target must be strictly under `managed_root/claude/`. + // `validate_pubkey_for_path` already ensures `pubkey` is hex-only (no `/` + // or `..`), so this is belt-and-suspenders in case of OS-specific + // edge cases or future callers that bypass the validator. + // We use `starts_with` on the lexical path because we never want to + // follow symlinks to check the canonical path — a symlink attack should + // simply fail here. + if !target.starts_with(&claude_dir) { + return Err(format!( + "agent pubkey produces a path that escapes the claude config directory: {}", + target.display() + )); + } + + if !target.exists() && !target.is_symlink() { + // Nothing to remove — idempotent success. + return Ok(()); + } + + // Remove directory and all contents. + if let Err(e) = std::fs::remove_dir_all(&target) { + // Log and swallow — B3: cleanup failure must not block deletion. + eprintln!( + "buzz-desktop: failed to remove Claude agent config root {}: {e} (non-fatal, cleanup skipped)", + target.display() + ); + } + + Ok(()) +} + +/// Default owner `settings.json` path: `~/.claude/settings.json`. +/// +/// Exposed so callers use the same path as `config_bridge::claude::read_config_file`. +pub fn owner_settings_path() -> Option { + dirs::home_dir().map(|h| h.join(".claude").join("settings.json")) +} + +/// Default owner MCP config path: `~/.claude.json`. +/// +/// Claude Code stores MCP server definitions here. Exposed so callers use the +/// same path as `config_bridge::claude::read_config_file` and `config_bridge::reader` +/// rather than each hard-coding `~/.claude.json` independently. +pub fn owner_mcp_config_path() -> Option { + dirs::home_dir().map(|h| h.join(".claude.json")) +} + +/// 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. +/// +/// Returns the distinct warning strings for panel-visible persistence via +/// [`write_spawn_warnings`]. All errors are swallowed; spawn always continues. +pub fn merge_agent_mcp_servers_with_warnings( + agent_config_dir: &Path, + owner_mcp_path: &Path, + acp_server_name: &str, +) -> Vec { + let mut warnings = Vec::new(); + // 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 (failure state #1): preserve existing agent servers, warn. + let msg = + format!("Failed to read owner MCP config; prior inherited servers preserved: {e}"); + eprintln!("buzz-desktop: {msg}"); + warnings.push(msg); + return warnings; + } + }; + + // 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) => { + // Invalid agent file (failure state #2): state replaced, warn. + let msg = format!( + "Agent MCP config was unparsable — state replaced with owner user-scope servers: {e}" + ); + eprintln!("buzz-desktop: {msg}"); + warnings.push(msg); + 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) { + // Write failure (failure state #3): warn, spawn continues. + let msg = format!("Failed to write agent MCP config; inherited servers may be stale: {e}"); + eprintln!("buzz-desktop: {msg}"); + warnings.push(msg); + } + warnings +} + +/// 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), 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")); + let (projected, base_status) = project_settings_json(&owner_path, &policy); + + let mut spawn_warnings: Vec = Vec::new(); + + if matches!(base_status, OwnerBaseStatus::Unreadable { .. }) { + let msg = format!( + "owner ~/.claude/settings.json unreadable — \ + launching with overlay-only settings (non-fatal): {base_status:?}" + ); + eprintln!("buzz-desktop: {msg}"); + spawn_warnings.push( + "Owner settings.json unreadable — launching with overlay-only settings".to_string(), + ); + } + 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(""); + let b8_warnings = + merge_agent_mcp_servers_with_warnings(&policy.config_dir, &owner_mcp, acp_name); + spawn_warnings.extend(b8_warnings); + } + // Persist spawn warnings for the panel surface (B7/B8 visible warnings). + write_spawn_warnings(&policy.config_dir, &spawn_warnings); + // A1: single startup model authority. + match effective_model { + Some(m) => command.env("ANTHROPIC_MODEL", m), + None => command.env_remove("ANTHROPIC_MODEL"), + }; + // B1: paired isolation atom. + command.env("CLAUDE_CONFIG_DIR", &policy.config_dir); + command.env( + "CLAUDE_SECURESTORAGE_CONFIG_DIR", + &policy.secure_storage_config_dir, + ); + 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)" + ); + } +} + +// ── Spawn-time warning persistence (B7/B8 panel surface) ───────────────────── +// +// `apply_claude_spawn_policy` writes `last_spawn_warnings.json` atomically +// into the per-agent config dir after B7 and B8 run. The config-bridge reader +// reads it at query time and surfaces the entries in `config_warnings`. +// +// The file is a JSON array of plain strings — one entry per distinct warning +// state. Only non-empty warning sets are written; an empty file means no +// warnings on the last spawn. + +/// File name for the spawn-warning persistence file inside the config dir. +pub const SPAWN_WARNINGS_FILE: &str = "last_spawn_warnings.json"; + +/// Write `warnings` to `/last_spawn_warnings.json` atomically. +/// Silently swallowed — warning persistence must never block spawn. +pub fn write_spawn_warnings(config_dir: &Path, warnings: &[String]) { + if warnings.is_empty() { + // Remove a stale file from a previous failing spawn. + let _ = std::fs::remove_file(config_dir.join(SPAWN_WARNINGS_FILE)); + return; + } + let json = match serde_json::to_string(warnings) { + Ok(j) => j, + Err(_) => return, + }; + let path = config_dir.join(SPAWN_WARNINGS_FILE); + let tmp = config_dir.join("last_spawn_warnings.json.tmp"); + let _ = std::fs::write(&tmp, json).and_then(|()| std::fs::rename(&tmp, &path)); +} + +/// Read the spawn warnings written by the last `apply_claude_spawn_policy` call +/// for this config dir. Returns an empty vec when the file is absent or unparsable. +pub fn read_spawn_warnings(config_dir: &Path) -> Vec { + let path = config_dir.join(SPAWN_WARNINGS_FILE); + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(_) => return Vec::new(), + }; + serde_json::from_str::>(&text).unwrap_or_default() +} + +#[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 new file mode 100644 index 0000000000..3c19adcf70 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/claude_config/tests.rs @@ -0,0 +1,978 @@ +use std::path::PathBuf; + +use super::*; + +// ── Protected-key predicate tests ──────────────────────────────────────────── + +#[test] +fn test_predicate_invariant_all_policy_keys_protected() { + // Every env key that the launch policy generates, removes, or reserves MUST + // be protected by is_launch_policy_protected_key. Adding a new launch-policy + // key without updating this list will cause the test to fail. + let policy_owned_keys = [ + // B1 paired atom + "CLAUDE_CONFIG_DIR", + "CLAUDE_SECURESTORAGE_CONFIG_DIR", + // Buzz identity / secrets (RESERVED_ENV_KEYS) + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + "BUZZ_RELAY_URL", + "BUZZ_ACP_AGENT_COMMAND", + "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_MCP_COMMAND", + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_AGENT_OWNER", + "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + "BUZZ_ACP_NO_PRESENCE", + "BUZZ_ACP_SETUP_PAYLOAD", + "BUZZ_MANAGED_AGENT", + "BUZZ_MANAGED_AGENT_START_NONCE", + // Model authority + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + // Isolation flags + "CLAUDE_CODE_DISABLE_CLAUDE_MDS", + "CLAUDE_CODE_DISABLE_AUTO_MEMORY", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", + "CLAUDE_CODE_SKIP_PERMISSIONS_CHECK", + // Provider / endpoint / auth routing + "ANTHROPIC_BASE_URL", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "AWS_BEARER_TOKEN_BEDROCK", + "ANTHROPIC_VERTEX_PROJECT_ID", + "CLOUD_ML_REGION", + "ANTHROPIC_VERTEX_REGION", + "ANTHROPIC_VERTEX_KEY_PATH", + ]; + + for key in &policy_owned_keys { + assert!( + is_launch_policy_protected_key(key), + "policy-owned key must be protected: {key}" + ); + } +} + +#[test] +fn test_model_authority_key_stripped_from_owner_env() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write( + &settings_path, + r#"{"env": {"ANTHROPIC_MODEL": "claude-opus-4", "MY_CUSTOM_VAR": "hello"}}"#, + ) + .unwrap(); + let managed_root = dir.path().to_path_buf(); + let policy = ClaudeLaunchPolicy::build("abcd1234", &managed_root, None).unwrap(); + let (projected, status) = project_settings_json(&settings_path, &policy); + + assert_eq!(status, OwnerBaseStatus::Ok); + let env = projected.get("env").and_then(|v| v.as_object()); + assert!( + env.is_none_or(|e| !e.contains_key("ANTHROPIC_MODEL")), + "ANTHROPIC_MODEL must be stripped from projected env" + ); +} + +#[test] +fn test_credential_root_keys_stripped_from_owner_env() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write( + &settings_path, + r#"{"env": {"CLAUDE_CONFIG_DIR": "/some/path", "CLAUDE_SECURESTORAGE_CONFIG_DIR": "/other"}}"#, + ) + .unwrap(); + let managed_root = dir.path().to_path_buf(); + let policy = ClaudeLaunchPolicy::build("abcd1234", &managed_root, None).unwrap(); + let (projected, _) = project_settings_json(&settings_path, &policy); + + let env = projected.get("env").and_then(|v| v.as_object()); + assert!( + env.is_none_or(|e| !e.contains_key("CLAUDE_CONFIG_DIR")), + "CLAUDE_CONFIG_DIR must be stripped" + ); + assert!( + env.is_none_or(|e| !e.contains_key("CLAUDE_SECURESTORAGE_CONFIG_DIR")), + "CLAUDE_SECURESTORAGE_CONFIG_DIR must be stripped" + ); +} + +#[test] +fn test_isolation_flag_stripped_from_owner_env() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write( + &settings_path, + r#"{"env": {"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1"}}"#, + ) + .unwrap(); + let managed_root = dir.path().to_path_buf(); + let policy = ClaudeLaunchPolicy::build("abcd1234", &managed_root, None).unwrap(); + let (projected, _) = project_settings_json(&settings_path, &policy); + + let env = projected.get("env").and_then(|v| v.as_object()); + assert!( + env.is_none_or(|e| !e.contains_key("CLAUDE_CODE_DISABLE_AUTO_MEMORY")), + "CLAUDE_CODE_DISABLE_AUTO_MEMORY must be stripped" + ); +} + +#[test] +fn test_provider_routing_key_stripped_from_owner_env() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write( + &settings_path, + r#"{"env": {"ANTHROPIC_BASE_URL": "https://evil.example.com/"}}"#, + ) + .unwrap(); + let managed_root = dir.path().to_path_buf(); + let policy = ClaudeLaunchPolicy::build("abcd1234", &managed_root, None).unwrap(); + let (projected, _) = project_settings_json(&settings_path, &policy); + + let env = projected.get("env").and_then(|v| v.as_object()); + assert!( + env.is_none_or(|e| !e.contains_key("ANTHROPIC_BASE_URL")), + "ANTHROPIC_BASE_URL must be stripped" + ); +} + +#[test] +fn test_buzz_prefix_stripped_from_owner_env() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write( + &settings_path, + r#"{"env": {"BUZZ_SOMETHING": "evil", "BUZZ_RELAY_URL": "wss://evil.relay/"}}"#, + ) + .unwrap(); + let managed_root = dir.path().to_path_buf(); + let policy = ClaudeLaunchPolicy::build("abcd1234", &managed_root, None).unwrap(); + let (projected, _) = project_settings_json(&settings_path, &policy); + + let env = projected.get("env").and_then(|v| v.as_object()); + assert!( + env.is_none_or(|e| !e.contains_key("BUZZ_SOMETHING")), + "BUZZ_SOMETHING must be stripped" + ); + assert!( + env.is_none_or(|e| !e.contains_key("BUZZ_RELAY_URL")), + "BUZZ_RELAY_URL must be stripped" + ); +} + +#[test] +fn test_nonprotected_owner_env_passthrough() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write( + &settings_path, + r#"{"env": {"MY_CUSTOM_VAR": "hello", "ANOTHER_SAFE_VAR": "world"}}"#, + ) + .unwrap(); + let managed_root = dir.path().to_path_buf(); + let policy = ClaudeLaunchPolicy::build("abcd1234", &managed_root, None).unwrap(); + let (projected, status) = project_settings_json(&settings_path, &policy); + + assert_eq!(status, OwnerBaseStatus::Ok); + let env = projected + .get("env") + .and_then(|v| v.as_object()) + .expect("env object must be present"); + assert_eq!( + env.get("MY_CUSTOM_VAR").and_then(|v| v.as_str()), + Some("hello"), + "MY_CUSTOM_VAR must pass through" + ); + assert_eq!( + env.get("ANOTHER_SAFE_VAR").and_then(|v| v.as_str()), + Some("world"), + "ANOTHER_SAFE_VAR must pass through" + ); +} + +#[test] +fn test_case_insensitive_key_filter() { + // `anthropic_model` (lowercase) must be filtered the same as `ANTHROPIC_MODEL`. + assert!( + is_launch_policy_protected_key("anthropic_model"), + "lowercase anthropic_model must be protected" + ); + assert!( + is_launch_policy_protected_key("Anthropic_Model"), + "mixed-case Anthropic_Model must be protected" + ); + assert!( + is_launch_policy_protected_key("buzz_something"), + "lowercase buzz_something must be protected" + ); + assert!( + is_launch_policy_protected_key("claude_config_dir"), + "lowercase claude_config_dir must be protected" + ); +} + +#[test] +fn test_provenance_stripped_keys_reported() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write( + &settings_path, + r#"{"env": {"ANTHROPIC_MODEL": "claude-opus-4", "MY_CUSTOM_VAR": "ok"}}"#, + ) + .unwrap(); + + let stripped = collect_stripped_env_keys(&settings_path); + assert!( + stripped.contains(&"ANTHROPIC_MODEL".to_string()), + "ANTHROPIC_MODEL must appear in stripped keys for provenance" + ); + assert!( + !stripped.contains(&"MY_CUSTOM_VAR".to_string()), + "MY_CUSTOM_VAR must not appear in stripped keys" + ); +} + +// ── Projection tests ────────────────────────────────────────────────────────── + +#[test] +fn test_unreadable_owner_base_falls_back_to_overlay_only() { + let dir = tempfile::tempdir().unwrap(); + // No settings.json file exists → Missing status. + let settings_path = dir.path().join("settings.json"); + let managed_root = dir.path().to_path_buf(); + let policy = + ClaudeLaunchPolicy::build("abcd1234", &managed_root, Some("high".to_string())).unwrap(); + let (projected, status) = project_settings_json(&settings_path, &policy); + + assert_eq!(status, OwnerBaseStatus::Missing); + // Canonical overlay should still be present. + assert_eq!( + projected.get("effortLevel").and_then(|v| v.as_str()), + Some("high"), + "effortLevel must be in projected settings even without owner base" + ); +} + +#[test] +fn test_invalid_json_base_falls_back_to_overlay_only() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write(&settings_path, "not valid json!!!").unwrap(); + let managed_root = dir.path().to_path_buf(); + let policy = + ClaudeLaunchPolicy::build("abcd1234", &managed_root, Some("medium".to_string())).unwrap(); + let (projected, status) = project_settings_json(&settings_path, &policy); + + assert!( + matches!(status, OwnerBaseStatus::Unreadable { .. }), + "unreadable base must produce Unreadable status" + ); + assert_eq!( + projected.get("effortLevel").and_then(|v| v.as_str()), + Some("medium"), + "effortLevel overlay must still apply on unreadable base" + ); +} + +#[test] +fn test_effort_level_written_into_projected_settings() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + // Owner base with an existing effortLevel that should be replaced. + std::fs::write( + &settings_path, + r#"{"effortLevel": "low", "hooks": {"pre-commit": {}}}"#, + ) + .unwrap(); + let managed_root = dir.path().to_path_buf(); + let policy = + ClaudeLaunchPolicy::build("abcd1234", &managed_root, Some("high".to_string())).unwrap(); + let (projected, status) = project_settings_json(&settings_path, &policy); + + assert_eq!(status, OwnerBaseStatus::Ok); + assert_eq!( + projected.get("effortLevel").and_then(|v| v.as_str()), + Some("high"), + "canonical effortLevel must override base effortLevel" + ); + // Non-canonical keys must pass through. + assert!( + projected.contains_key("hooks"), + "hooks must pass through from owner base" + ); +} + +#[test] +fn test_no_effort_level_does_not_inject_empty() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.json"); + std::fs::write(&settings_path, r#"{"model": "claude-opus-4"}"#).unwrap(); + let managed_root = dir.path().to_path_buf(); + // No effort_level in policy. + let policy = ClaudeLaunchPolicy::build("abcd1234", &managed_root, None).unwrap(); + let (projected, _) = project_settings_json(&settings_path, &policy); + + // effortLevel should not be injected when None. + assert!( + !projected.contains_key("effortLevel"), + "effortLevel must not be present when policy.effort_level is None" + ); +} + +#[test] +fn test_projection_write_failure_blocks_spawn() { + // Simulate write failure portably: create a regular file, then use a path + // *under* that file as config_dir. create_dir_all fails on every OS when + // a path component is a regular file (not a directory), so the test does + // not rely on a non-existent root path that Windows can still create. + let dir = tempfile::tempdir().unwrap(); + let blocker = dir.path().join("i_am_a_file"); + std::fs::write(&blocker, b"").unwrap(); + let policy = ClaudeLaunchPolicy { + config_dir: blocker.join("cannot_create_under_a_file"), + secure_storage_config_dir: String::new(), + effort_level: None, + }; + let projected = serde_json::Map::new(); + let result = write_projected_settings(&policy, &projected); + assert!( + result.is_err(), + "write_projected_settings must return Err when path cannot be created" + ); +} + +#[test] +fn test_write_projected_settings_creates_dir_and_file() { + let dir = tempfile::tempdir().unwrap(); + let policy = ClaudeLaunchPolicy { + config_dir: dir.path().join("claude").join("abcd1234"), + secure_storage_config_dir: String::new(), + effort_level: Some("high".to_string()), + }; + let mut projected = serde_json::Map::new(); + projected.insert( + "effortLevel".to_string(), + serde_json::Value::String("high".to_string()), + ); + + write_projected_settings(&policy, &projected).unwrap(); + + let written_path = dir + .path() + .join("claude") + .join("abcd1234") + .join("settings.json"); + assert!(written_path.exists(), "settings.json must be written"); + let contents = std::fs::read_to_string(&written_path).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap(); + assert_eq!( + parsed.get("effortLevel").and_then(|v| v.as_str()), + Some("high") + ); +} + +// ── Lifecycle (B3) tests ────────────────────────────────────────────────────── + +#[test] +fn test_b3_cleanup_failure_does_not_block_deletion() { + // Call cleanup on a path whose parent doesn't exist — should return Ok + // (the directory simply doesn't exist, which is idempotent success). + let nonexistent_root = PathBuf::from("/nonexistent/buzz/agents/never/created"); + let result = cleanup_claude_config_root("abcd1234abcd1234", &nonexistent_root); + assert!( + result.is_ok(), + "cleanup must return Ok even when directory doesn't exist: {result:?}" + ); +} + +#[test] +fn test_b3_traversal_guard() { + // A pubkey containing non-hex chars must be rejected BEFORE any filesystem op. + let managed_root = PathBuf::from("/tmp/buzz-agents-test"); + let bad_pubkeys = ["../escape", "../../etc/passwd", "a/b", "hex!@#$%"]; + for pubkey in &bad_pubkeys { + let result = cleanup_claude_config_root(pubkey, &managed_root); + assert!(result.is_err(), "non-hex pubkey must be rejected: {pubkey}"); + } +} + +#[test] +fn test_b3_removes_existing_config_dir() { + let dir = tempfile::tempdir().unwrap(); + let managed_root = dir.path().to_path_buf(); + let pubkey = "abcd1234abcd1234"; + let target = managed_root.join("claude").join(pubkey); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(target.join("settings.json"), "{}").unwrap(); + + cleanup_claude_config_root(pubkey, &managed_root).unwrap(); + assert!(!target.exists(), "config dir must be removed after cleanup"); +} + +#[test] +fn test_b3_idempotent_when_dir_missing() { + let dir = tempfile::tempdir().unwrap(); + let managed_root = dir.path().to_path_buf(); + let pubkey = "abcd1234abcd1234"; + // Directory never created — cleanup should succeed idempotently. + cleanup_claude_config_root(pubkey, &managed_root).unwrap(); +} + +#[test] +fn test_b3_legacy_first_spawn_removes_stale_dir() { + // Simulate a stale directory from a previous install: + // cleanup_claude_config_root removes it, then the caller creates it fresh. + let dir = tempfile::tempdir().unwrap(); + let managed_root = dir.path().to_path_buf(); + let pubkey = "abcd1234abcd1234"; + let target = managed_root.join("claude").join(pubkey); + + // Create a "stale" directory with stale contents. + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(target.join("old_file.txt"), "stale").unwrap(); + + // Cleanup removes it. + cleanup_claude_config_root(pubkey, &managed_root).unwrap(); + assert!(!target.exists(), "stale dir must be removed"); + + // Caller recreates it fresh. + std::fs::create_dir_all(&target).unwrap(); + assert!(target.exists(), "fresh dir must be creatable after cleanup"); +} + +// ── ClaudeLaunchPolicy construction tests ──────────────────────────────────── + +#[test] +fn test_build_policy_hex_pubkey_accepted() { + let dir = tempfile::tempdir().unwrap(); + let managed_root = dir.path().to_path_buf(); + let pubkey = "deadbeef01234567deadbeef01234567deadbeef01234567deadbeef01234567"; + let policy = ClaudeLaunchPolicy::build(pubkey, &managed_root, None); + assert!(policy.is_ok(), "valid hex pubkey must be accepted"); + let p = policy.unwrap(); + assert_eq!( + p.secure_storage_config_dir, "", + "SECURESTORAGE must be empty string" + ); + assert!( + p.config_dir.ends_with(pubkey), + "config_dir must end with pubkey" + ); +} + +#[test] +fn test_build_policy_non_hex_pubkey_rejected() { + let dir = tempfile::tempdir().unwrap(); + let managed_root = dir.path().to_path_buf(); + let result = ClaudeLaunchPolicy::build("not-a-hex-key!", &managed_root, None); + assert!(result.is_err(), "non-hex pubkey must be rejected"); +} + +#[test] +fn test_build_policy_empty_pubkey_rejected() { + let dir = tempfile::tempdir().unwrap(); + let managed_root = dir.path().to_path_buf(); + let result = ClaudeLaunchPolicy::build("", &managed_root, None); + assert!(result.is_err(), "empty pubkey must be rejected"); +} + +#[test] +fn test_build_policy_overlength_pubkey_rejected() { + let dir = tempfile::tempdir().unwrap(); + let managed_root = dir.path().to_path_buf(); + // 65 hex chars — exceeds max 64 + let long_key = "a".repeat(65); + let result = ClaudeLaunchPolicy::build(&long_key, &managed_root, None); + assert!(result.is_err(), "pubkey > 64 chars must be rejected"); +} + +#[test] +fn test_secure_storage_config_dir_is_always_empty_string() { + // The empty string is the invariant — verify it explicitly so any future + // change to the default shows up as a test failure. + let dir = tempfile::tempdir().unwrap(); + let managed_root = dir.path().to_path_buf(); + let policy = ClaudeLaunchPolicy::build("abcd1234", &managed_root, None).unwrap(); + assert_eq!( + policy.secure_storage_config_dir, "", + "CLAUDE_SECURESTORAGE_CONFIG_DIR must be the empty string" + ); + assert!( + policy.secure_storage_config_dir.is_empty(), + "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_with_warnings(&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_with_warnings(&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_with_warnings(&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_with_warnings(&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_with_warnings(&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_with_warnings(&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_with_warnings(&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_with_warnings(&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_with_warnings` 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_with_warnings(&a1, &o1, "buzz-mcp")); + let t2 = + std::thread::spawn(move || merge_agent_mcp_servers_with_warnings(&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" + ); +} + +// ── B8 write-failure test ───────────────────────────────────────────────────── + +/// Agent `.claude.json` write failure (failure state #3): spawn continues. +/// Simulated by making the parent directory read-only before the merge. +/// The warning is returned and spawn proceeds. +#[cfg(unix)] +#[test] +fn test_b8_agent_write_failure_does_not_block_spawn() { + // Skip on CI where we may run as root (read-only dirs are ignored by root). + if std::env::var("CI").is_ok() { + return; + } + 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(); + + write_json( + &owner_mcp, + &serde_json::json!({ "mcpServers": { "github": {} } }), + ); + + // Make the agent dir read-only so the write must fail. + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&agent_dir, std::fs::Permissions::from_mode(0o555)).unwrap(); + + // merge_agent_mcp_servers_with_warnings must not panic or return Err — spawn continues. + // We verify it returns exactly one warning (failure state #3). + let warnings = merge_agent_mcp_servers_with_warnings(&agent_dir, &owner_mcp, "buzz-mcp"); + let has_write_failure_warning = warnings.iter().any(|w| w.contains("may be stale")); + assert!( + has_write_failure_warning, + "write failure must produce the 'may be stale' warning; got: {warnings:?}" + ); + + // Restore permissions so tempdir cleanup doesn't fail. + std::fs::set_permissions(&agent_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); +} + +// ── apply_claude_spawn_policy env tests ────────────────────────────────────── + +/// B1: the paired isolation atom (CLAUDE_CONFIG_DIR + CLAUDE_SECURESTORAGE_CONFIG_DIR="") +/// must be present in the spawned-child env after policy application. +#[test] +fn test_apply_policy_b1_paired_atom_present_in_spawned_env() { + let dir = tempfile::tempdir().unwrap(); + let managed_root = dir.path().to_path_buf(); + let pubkey = "abcd1234abcd1234"; + // Owner settings.json doesn't exist — overlay-only path. + let mut cmd = std::process::Command::new("true"); + apply_claude_spawn_policy(&mut cmd, pubkey, &managed_root, None, None, None).unwrap(); + + let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); + + // CLAUDE_CONFIG_DIR must be set to the per-agent root. + let config_dir = env_map.get(std::ffi::OsStr::new("CLAUDE_CONFIG_DIR")); + assert!(config_dir.is_some(), "CLAUDE_CONFIG_DIR must be present"); + let config_dir_val = config_dir.unwrap().unwrap_or_default(); + assert!( + config_dir_val.to_string_lossy().contains(pubkey), + "CLAUDE_CONFIG_DIR must contain the agent pubkey" + ); + + // CLAUDE_SECURESTORAGE_CONFIG_DIR must be the empty string. + let securestorage = env_map.get(std::ffi::OsStr::new("CLAUDE_SECURESTORAGE_CONFIG_DIR")); + assert!( + securestorage.is_some(), + "CLAUDE_SECURESTORAGE_CONFIG_DIR must be present" + ); + assert_eq!( + securestorage.unwrap().unwrap_or_default(), + "", + "CLAUDE_SECURESTORAGE_CONFIG_DIR must be empty string" + ); +} + +/// A1: BUZZ_ACP_MODEL must NOT be present in the spawned-child env after policy +/// application, even if it was set before (dual-authority defect). +#[test] +fn test_apply_policy_a1_buzz_acp_model_absent_after_policy() { + let dir = tempfile::tempdir().unwrap(); + let managed_root = dir.path().to_path_buf(); + let pubkey = "abcd1234abcd1234"; + let mut cmd = std::process::Command::new("true"); + // Pre-set BUZZ_ACP_MODEL as if it came from descriptor.env. + cmd.env("BUZZ_ACP_MODEL", "claude-opus-4"); + apply_claude_spawn_policy( + &mut cmd, + pubkey, + &managed_root, + None, + Some("claude-opus-4"), + None, + ) + .unwrap(); + + let env_map: std::collections::HashMap<_, _> = cmd.get_envs().collect(); + // BUZZ_ACP_MODEL must be removed (env_remove). + // Command::get_envs returns None for removed keys. + let buzz_acp_model = env_map.get(std::ffi::OsStr::new("BUZZ_ACP_MODEL")); + assert!( + buzz_acp_model.is_none() || buzz_acp_model.unwrap().is_none(), + "BUZZ_ACP_MODEL must be absent (or explicitly removed) after policy application" + ); + // ANTHROPIC_MODEL must be set to the resolved model. + let anthropic_model = env_map.get(std::ffi::OsStr::new("ANTHROPIC_MODEL")); + assert!( + anthropic_model.is_some(), + "ANTHROPIC_MODEL must be present after policy application" + ); + assert_eq!( + anthropic_model.unwrap().unwrap_or_default(), + "claude-opus-4", + "ANTHROPIC_MODEL must equal the effective model" + ); +} + +// ── Spawn-warning persistence tests ────────────────────────────────────────── + +/// Spawn warnings round-trip through write_spawn_warnings / read_spawn_warnings. +#[test] +fn test_spawn_warnings_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().to_path_buf(); + let warnings = vec![ + "Owner settings.json unreadable — launching with overlay-only settings".to_string(), + "Failed to write agent MCP config; inherited servers may be stale: mock error".to_string(), + ]; + write_spawn_warnings(&config_dir, &warnings); + let read_back = read_spawn_warnings(&config_dir); + assert_eq!(read_back, warnings, "read_back must equal written warnings"); +} + +/// Empty warnings → file removed (no stale warning from a previous spawn). +#[test] +fn test_spawn_warnings_empty_removes_stale_file() { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().to_path_buf(); + // Write a non-empty set first. + write_spawn_warnings(&config_dir, &["stale warning".to_string()]); + assert!( + config_dir.join(SPAWN_WARNINGS_FILE).exists(), + "file must exist after non-empty write" + ); + // Write empty → file removed. + write_spawn_warnings(&config_dir, &[]); + assert!( + !config_dir.join(SPAWN_WARNINGS_FILE).exists(), + "file must be removed after empty write" + ); + assert_eq!( + read_spawn_warnings(&config_dir), + Vec::::new(), + "read after removal must return empty vec" + ); +} 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 449197a3b3..3b469aa1e6 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs @@ -1,10 +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 { - let home = dirs::home_dir()?; - let settings_path = home.join(".claude").join("settings.json"); - let mcp_path = home.join(".claude.json"); +/// 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 = 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); @@ -26,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()) { @@ -35,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()), }); } } @@ -169,6 +179,7 @@ mod tests { name: name.clone(), kind: "mcp".to_string(), enabled: true, + source: None, }); } } @@ -201,4 +212,46 @@ mod tests { "unknown future fields should appear in extra" ); } + + /// B8 panel provenance: when reading MCP servers via `mcp_path_override` + /// (the agent-root path), every entry must carry + /// `source: Some("owner_user_scope")` — not `None` (owner-file path). + #[test] + fn b8_mcp_path_override_tags_entries_owner_user_scope() { + let dir = tempfile::tempdir().unwrap(); + // Write an MCP config in the agent root with two servers. + let mcp_path = dir.path().join(".claude.json"); + std::fs::write( + &mcp_path, + br#"{"mcpServers": {"glean": {"command": "glean-mcp"}, "slack": {"command": "slack-mcp"}}}"#, + ) + .unwrap(); + + // We can't call read_config_file directly without touching HOME-dependent + // owner_settings_path(), so exercise the provenance logic inline — this + // mirrors the exact code path in read_config_file with is_agent_root=true. + let mcp_config: serde_json::Value = + serde_json::from_str(std::fs::read_to_string(&mcp_path).unwrap().as_str()).unwrap(); + let is_agent_root = true; // mcp_path_override is Some + let mut extensions = Vec::new(); + if let Some(servers) = mcp_config.get("mcpServers").and_then(|v| v.as_object()) { + for (name, _config) in servers { + extensions.push(ExtensionEntry { + name: name.clone(), + kind: "mcp".to_string(), + enabled: true, + source: is_agent_root.then(|| "owner_user_scope".to_string()), + }); + } + } + assert_eq!(extensions.len(), 2, "both servers must be parsed"); + for entry in &extensions { + assert_eq!( + entry.source.as_deref(), + Some("owner_user_scope"), + "entry {:?} must carry owner_user_scope provenance when read from agent root", + entry.name + ); + } + } } 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 c51f325cf3..d28ea47498 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,15 +195,50 @@ 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(), + config_warnings: runtime_meta + .filter(|m| m.id == "claude") + .and_then(|_| { + // Read B7/B8 spawn warnings from the per-agent config dir. + // agent_mcp_path is /.claude.json — its parent is the config dir. + agent_mcp_path + .and_then(|p| p.parent()) + .map(crate::managed_agents::claude_config::read_spawn_warnings) + }) + .unwrap_or_default(), + effort_config_id: if runtime_meta.map(|m| m.id == "claude").unwrap_or(false) { + // B5: extract the thought_level configId from the session cache so the + // UI can call set_config_option without hardcoding the adapter's id. + session_cache.and_then(|c| { + c.config_options + .iter() + .find(|opt| opt.category.as_deref() == Some("thought_level")) + .map(|opt| opt.config_id.clone()) + }) + } else { + None + }, } } -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" => Some(resolve_tilde("~/.claude.json")), + "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()) } @@ -491,7 +532,13 @@ fn build_thinking_field( session_cache: Option<&SessionConfigCache>, tiers: &InheritedConfigTiers, ) -> Option { - // Tier ordering: record env > ACP > persona env > global env > definition env > config file. + // Tier ordering: + // record env > record.effort_level (canonical Buzz-seeded) > ACP > + // persona env > global env > definition env > config file. + // + // record.effort_level is the B5 canonical value persisted from a positive + // ACP ack — it is seeded into the projected settings.json at spawn and + // represents the "configured" value in the B4 status contract. let [rec_env, pers_env, glob_env, def_env] = thinking_env_var .map(|k| { env_candidates( @@ -504,8 +551,11 @@ fn build_thinking_field( }) .unwrap_or([None, None, None, None]); + let canonical_effort = record.effort_level.as_deref(); + let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ (rec_env, ConfigOrigin::BuzzExplicit), + (canonical_effort, ConfigOrigin::BuzzExplicit), (acp_effort.as_deref(), ConfigOrigin::AcpConfigOption), (pers_env, ConfigOrigin::PersonaDefault), (glob_env, ConfigOrigin::GlobalDefault), 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 153db1bbd8..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 @@ -114,6 +114,7 @@ fn test_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, agent_command_override: None, persona_source_version: None, provider: None, @@ -166,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); @@ -182,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 @@ -201,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") @@ -225,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 @@ -245,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); @@ -258,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); @@ -284,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")); @@ -308,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); @@ -329,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")); @@ -345,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")); @@ -361,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")); @@ -377,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!( @@ -414,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. @@ -446,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. @@ -478,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 @@ -537,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")); @@ -560,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!( @@ -599,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!( @@ -659,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")); @@ -680,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")); @@ -698,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(), @@ -723,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!( @@ -744,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")); @@ -765,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!( @@ -827,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 @@ -844,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 @@ -868,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 @@ -884,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(), @@ -915,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 @@ -939,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 8613124f25..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,10 +249,81 @@ 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. assert_eq!(model.value.as_deref(), Some("persona-struct-model")); assert_eq!(model.origin, ConfigOrigin::PersonaDefault); } + +// ── B4/B5 canonical effort_level tier tests ──────────────────────────────── +// +// record.effort_level is the Buzz-canonical seeded value (B5 persisted from +// a positive ACP ack). It must surface as BuzzExplicit and take precedence +// over the config-file tier (but not over record env vars). + +/// B4: record.effort_level surfaces as BuzzExplicit when no env var is set. +#[test] +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(), None); + let effort = surface + .normalized + .thinking_effort + .expect("effort must surface from canonical record tier"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); +} + +/// B4: record.effort_level shadows the config-file tier. +#[test] +fn b4_canonical_effort_level_shadows_file_tier() { + let mut record = test_record(); + // canonical effort takes precedence over 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(), None); + let effort = surface + .normalized + .thinking_effort + .expect("canonical effort must shadow file tier"); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); +} + +/// B4: a record env var override still wins over record.effort_level. +#[test] +fn b4_record_env_var_wins_over_canonical_effort_level() { + let mut record = test_record(); + record.effort_level = Some("low".to_string()); + record + .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(), None); + let effort = surface + .normalized + .thinking_effort + .expect("env var must win over canonical effort"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + // canonical is the overridden baseline + assert_eq!(effort.overridden_value.as_deref(), Some("low")); +} + +/// B4: None effort_level does not introduce a spurious tier. +#[test] +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(), None); + // No env var, no session cache, no file config → effort_level field absent. + assert!( + surface.normalized.thinking_effort.is_none(), + "effort field must be absent when no tier has a value" + ); +} 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..9280e6b550 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,32 @@ 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, + /// Visible warnings surfaced in the config panel for B7/B8 failure states. + /// + /// Entries come from two sources: + /// - B7: derived at bridge-read time from the owner `~/.claude/settings.json` + /// state (if the file exists but is unparsable, the agent is running with + /// overlay-only settings). + /// - B8: written atomically to `last_spawn_warnings.json` in the per-agent + /// config root at each spawn and read back here. The three distinct B8 + /// states (owner-read failure, invalid-agent fallback, write failure) + /// are preserved separately so the panel can render them distinctly. + /// + /// Empty for non-claude runtimes and when there are no warnings. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub config_warnings: Vec, + /// B5: the real `configId` for the `thought_level` ACP config option, + /// as advertised by the adapter in `session/new`. Present only for claude + /// runtimes after the first session is created. The UI uses this to send + /// `set_config_option` without hardcoding the configId. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort_config_id: Option, } /// Raw config values extracted from a runtime's config file. @@ -198,6 +224,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 6fe6a77521..fc751fd186 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -283,13 +283,13 @@ fn record_with( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } #[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/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index c8e437809c..b4c08804c7 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -88,6 +88,7 @@ fn record( source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + effort_level: None, auto_restart_on_config_change: false, definition_respond_to: None, definition_respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 553596e226..5c6e11e606 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -352,6 +352,7 @@ fn bare_record() -> ManagedAgentRecord { definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 772d707f27..8bfcc64b10 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -7,6 +7,7 @@ pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; mod backend; +pub(crate) mod claude_config; pub(crate) mod config_bridge; pub(crate) mod custom_harnesses; mod discovery; diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 031b049a49..75ee02ec91 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -502,6 +502,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index b9542f9a87..2ed2ff3e8f 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -58,6 +58,7 @@ fn sample_record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index fa8eb36fa1..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( @@ -1532,6 +1531,7 @@ mod tests { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, }; let runtime = known_acp_runtime_exact("buzz-agent"); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 3173126b90..8c54302c49 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -14,6 +14,7 @@ use crate::{ util::now_iso, }; +use super::claude_config::apply_claude_spawn_policy; mod path; pub(in crate::managed_agents) use path::build_augmented_path; pub(crate) use path::compose_path_entries; @@ -813,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); @@ -848,17 +840,25 @@ 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+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)?; + apply_claude_spawn_policy( + &mut command, + &record.pubkey, + &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 3f6ee996f6..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() @@ -181,6 +180,7 @@ fn fixture( definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index 648cc62bbe..be86d5c0ab 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -152,6 +152,10 @@ pub(crate) fn spawn_config_hash( record.idle_timeout_seconds.hash(&mut hasher); record.max_turn_duration_seconds.hash(&mut hasher); record.parallelism.hash(&mut hasher); + // B6: canonical effort level — drives settings.json projection at spawn. + // Owner file bytes are excluded (B7.4): personal-settings edits must not + // force agent restarts; the hash covers canonical record values only. + record.effort_level.hash(&mut hasher); hasher.finish() } diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index f4ad404814..1f649d7df9 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -57,6 +57,7 @@ fn record() -> ManagedAgentRecord { definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, relay_mesh: None, + effort_level: None, } } @@ -765,3 +766,34 @@ fn spawn_hash_instance_args_win_over_definition_args() { "instance args and definition args must produce different hashes" ); } + +#[test] +fn test_effort_level_change_raises_restart_badge() { + // B6: effort_level is a canonical record field covered by spawn_config_hash. + // Changing it must produce a different hash so the restart badge fires. + let mut r_no_effort = record(); + r_no_effort.effort_level = None; + + let mut r_high = record(); + r_high.effort_level = Some("high".to_string()); + + let mut r_low = record(); + r_low.effort_level = Some("low".to_string()); + + let h_none = spawn_config_hash(&r_no_effort, &[], &[], "ws://relay", &Default::default()); + let h_high = spawn_config_hash(&r_high, &[], &[], "ws://relay", &Default::default()); + let h_low = spawn_config_hash(&r_low, &[], &[], "ws://relay", &Default::default()); + + assert_ne!( + h_none, h_high, + "None vs high effort must produce different hashes" + ); + assert_ne!( + h_none, h_low, + "None vs low effort must produce different hashes" + ); + assert_ne!( + h_high, h_low, + "high vs low effort must produce different hashes" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 96082acc76..e821ef9bf1 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -309,6 +309,7 @@ mod tests { definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, + effort_level: None, } } diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 1ffa60eda9..ae4bb0cda8 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -213,6 +213,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { source_team_persona_slug: None, catalog_source: None, relay_mesh: None, + effort_level: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index fcd8b13fc9..433413ea74 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -153,6 +153,7 @@ impl AgentDefinition { definition_respond_to_allowlist: self.respond_to_allowlist, definition_parallelism: self.parallelism, relay_mesh: None, + effort_level: None, } } } @@ -438,24 +439,9 @@ pub struct ManagedAgentRecord { /// deserialize as `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: 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, + /// 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, } #[derive(Debug)] @@ -992,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..b0dc5eb4f5 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,23 @@ function dispatchControlResult(agentPubkey: string, payload: unknown) { if (!isControlResultFrame(payload)) { return; } + // B5: on a positive set_config_option ack for a confirmed thought_level + // option, persist the canonical value to the agent record so it seeds + // settings.json on next spawn (B7). + // Gate on `category === "thought_level"` (present only on real-forward acks) + // rather than a literal configId — if the adapter renames the configId, + // persistence still works; synthetic acks (no category) never persist. + if ( + payload.type === "set_config_option" && + payload.category === "thought_level" && + 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..96c71d57b8 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -26,6 +26,7 @@ import type { NormalizedField, } from "@/shared/api/types"; import { providerDisplayLabel } from "./agentConfigOptions"; +import { sendSetConfigOption } from "@/shared/api/agentControl"; type Props = { pubkey: string; @@ -345,6 +346,79 @@ function AdvancedRow({ return
{content}
; } +// ── Claude effort picker (B5) ──────────────────────────────────────────────── +// +// Renders a live effort control for claude runtimes when the session-level +// `thought_level` configId is available (i.e. at least one session has been +// created). Calls `sendSetConfigOption` so the harness forwards the change to +// the adapter via `session/set_config_option`; the observer's +// `dispatchControlResult` handler persists the canonical value on real ok. + +const CLAUDE_EFFORT_OPTIONS: { label: string; value: string }[] = [ + { label: "Low", value: "low" }, + { label: "Medium", value: "medium" }, + { label: "High", value: "high" }, +]; + +function EffortPicker({ + pubkey, + effortConfigId, + currentEffort, +}: { + pubkey: string; + effortConfigId: string; + currentEffort: string | null; +}) { + const [saving, setSaving] = React.useState(false); + const [error, setError] = React.useState(null); + + const handleChange = async (value: string) => { + if (!value) return; + setSaving(true); + setError(null); + try { + await sendSetConfigOption(pubkey, effortConfigId, value); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + }; + + return ( +
+

+ + Thinking / Effort +

+
+ + {saving ? ( + Setting… + ) : null} + {error ? ( + {error} + ) : null} +
+

+ Live — persisted after agent acknowledges +

+
+ ); +} + // ── Main component ──────────────────────────────────────────────────────────── export function AgentConfigPanel({ @@ -373,8 +447,17 @@ export function AgentConfigPanel({ ); } - const { normalized, advanced, extensions, runtimeId, sources, isPreSpawn } = - data; + const { + normalized, + advanced, + extensions, + runtimeId, + sources, + isPreSpawn, + strippedOwnerEnvKeys, + configWarnings, + effortConfigId, + } = data; const configFilePath = sources.configFilePath; const normalizedEntries = ( @@ -475,6 +558,41 @@ 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} + + {configWarnings && configWarnings.length > 0 ? ( +
+ {configWarnings.map((warning) => ( +

+ ⚠ {warning} +

+ ))} +
+ ) : null} + + {effortConfigId ? ( + + ) : null} ); } 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/tauri.ts b/desktop/src/shared/api/tauri.ts index 69e2e455ec..2aa544e0da 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -217,18 +217,15 @@ type RawGitBashPrerequisite = { install_instructions_url: string; install_hint: string; }; - type RawCommandAvailability = { command: string; resolved_path: string | null; available: boolean; }; - type RawManagedAgentPrereqs = { acp: RawCommandAvailability; mcp: RawCommandAvailability; }; - type RawRelayMember = { pubkey: string; role: string; @@ -239,7 +236,6 @@ type RawRelayMember = { type RawListRelayMembersResponse = { members: RawRelayMember[]; }; - type RawCanvasResponse = { content: string | null; updated_at: number | null; @@ -1012,7 +1008,11 @@ export async function putAgentSessionConfig( ): Promise { return invokeTauri("put_agent_session_config", { pubkey, payload }); } - +export const persistAgentEffortLevel = (p: string, l: string | null) => + invokeTauri("persist_agent_effort_level", { + pubkey: p, + effortLevel: l, + }); /** File-layer config for a runtime (e.g. `~/.config/goose/config.yaml`). */ export type RuntimeFileConfigSubset = { /** Provider set in the harness config file. */ diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d0e8ee0047..241694e663 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,15 @@ 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; + category?: "thought_level"; + }; export type GitBashPrerequisite = { available: boolean; @@ -657,7 +647,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 +672,11 @@ export type RuntimeConfigSurface = { advanced: ConfigField[]; extensions: ExtensionEntry[]; sources: ConfigSourceReport; + /** Owner `~/.claude/settings.json` env keys stripped by Buzz launch policy (B7.7c). */ + strippedOwnerEnvKeys?: string[]; + /** Spawn warnings (B7/B8 failure states): owner unreadable, agent MCP replaced, write failed. */ + configWarnings?: string[]; + effortConfigId?: string; }; export type UpdateManagedAgentInput = {