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