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:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-08-03 16:12:21 -04:00
parent 662c398d26
commit a64110ece7
26 changed files with 760 additions and 208 deletions

View File

@@ -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.

View File

@@ -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,
))
}

View File

@@ -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");

View File

@@ -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);

View File

@@ -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;

View File

@@ -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"
);
}

View File

@@ -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,
});
}
}

View File

@@ -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()
}

View File

@@ -130,6 +130,7 @@ fn parse_extensions(
name,
kind,
enabled,
source: None,
})
})
.collect()

View File

@@ -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())
}

View File

@@ -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"));

View File

@@ -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(),

View File

@@ -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.

View File

@@ -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");

View File

@@ -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(

View File

@@ -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

View File

@@ -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()

View File

@@ -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::*;

View 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,
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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 (

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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,
});
}

View File

@@ -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 = {