From 662c398d26474646f0c40c967e646c3e26ccba2f Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 3 Aug 2026 13:21:50 -0400 Subject: [PATCH] =?UTF-8?q?feat(managed-agents):=20A1/A7/B4/B5/#3493=20?= =?UTF-8?q?=E2=80=94=20claude=20model=20authority,=20PermissionMode=20Auto?= =?UTF-8?q?,=20effort=20persistence,=20bridge=20path=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A1 — single startup model authority for Claude Code (local agents). Add apply_claude_spawn_policy() to claude_config/mod.rs and call it from spawn_agent_child() for local Claude agents. Injects ANTHROPIC_MODEL from the Buzz-resolved model, writes the B7 projected settings.json, and injects the B1 paired atom (CLAUDE_CONFIG_DIR + CLAUDE_SECURESTORAGE_CONFIG_DIR='') in one call. The protected-key predicate covers ANTHROPIC_MODEL so the owner settings.json env block cannot fight it. Collapses the prior 57-line inline block in runtime.rs; corrects a false comment that claimed descriptor.env was pre-filtered by the predicate (it is not — policy values win by being written after descriptor.env). Also adds owner_mcp_config_path() to claude_config/mod.rs for use by the config_bridge paths fixed in #3493 below. A7 — PermissionMode::Auto variant (wire string 'auto', alias 'auto'). buzz-acp/src/config.rs gets the Auto variant with tests for wire string, is_default() == false, and documented adapter-side graceful downgrade. B4 — canonical effort tier in build_thinking_field. record.effort_level is now the BuzzExplicit tier immediately above the record env tier in the tiers_list, making the B5-persisted effort the 'configured' value in the B4 status contract (configured == running on positive ack, unknown with both values shown on divergence). B5 — persist_agent_effort_level Tauri command and TypeScript binding. Adds persist_agent_effort_level(pubkey, effortLevel) in agent_config.rs, registers it in lib.rs, and exports persistAgentEffortLevel in tauri.ts. Called by the TS observer on positive ACP session/set_config_option ack. The persisted value seeds effortLevel in the projected settings.json overlay at the next spawn (B7). #3493 — config_bridge no longer hardcodes ~/.claude.json. config_bridge/claude.rs::read_config_file() and config_bridge/reader.rs::mcp_config_file_path_for_runtime() now call owner_mcp_config_path() and owner_settings_path() from claude_config::mod, removing the three independent home-dir concatenations. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-acp/src/config.rs | 20 ++++++ .../src-tauri/src/commands/agent_config.rs | 29 ++++++++ desktop/src-tauri/src/lib.rs | 2 +- .../src/managed_agents/claude_config/mod.rs | 49 +++++++++++++ .../managed_agents/config_bridge/claude.rs | 5 +- .../managed_agents/config_bridge/reader.rs | 14 +++- .../config_bridge/reader_tests_ext.rs | 71 +++++++++++++++++++ .../src-tauri/src/managed_agents/runtime.rs | 60 +++------------- desktop/src/shared/api/tauri.ts | 10 +-- 9 files changed, 200 insertions(+), 60 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 35aaec188d..6d3669e1a8 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -124,6 +124,11 @@ pub enum PermissionMode { /// Agent default — permission requests per tool call. #[value(alias = "default")] Default, + /// Auto mode — fully autonomous execution; model-gated (requires a model + /// that supports `supportsAutoMode`). Degrades gracefully to `default` + /// when the session's active model does not support it. + #[value(alias = "auto")] + Auto, /// Auto-approve file edits, still ask for other tools. #[value(alias = "acceptEdits")] AcceptEdits, @@ -144,6 +149,7 @@ impl PermissionMode { pub fn as_wire_str(&self) -> &'static str { match self { Self::Default => "default", + Self::Auto => "auto", Self::AcceptEdits => "acceptEdits", Self::BypassPermissions => "bypassPermissions", Self::DontAsk => "dontAsk", @@ -2269,6 +2275,7 @@ channels = "ALL" #[test] fn test_permission_mode_wire_strings() { assert_eq!(PermissionMode::Default.as_wire_str(), "default"); + assert_eq!(PermissionMode::Auto.as_wire_str(), "auto"); assert_eq!(PermissionMode::AcceptEdits.as_wire_str(), "acceptEdits"); assert_eq!( PermissionMode::BypassPermissions.as_wire_str(), @@ -2281,12 +2288,24 @@ channels = "ALL" #[test] fn test_permission_mode_is_default() { assert!(PermissionMode::Default.is_default()); + assert!(!PermissionMode::Auto.is_default()); assert!(!PermissionMode::BypassPermissions.is_default()); assert!(!PermissionMode::AcceptEdits.is_default()); assert!(!PermissionMode::DontAsk.is_default()); assert!(!PermissionMode::Plan.is_default()); } + #[test] + fn test_permission_mode_auto_degrades_to_default_when_unsupported() { + // The wire string is "auto" — the adapter handles graceful downgrade + // to "default" when the active model does not support Auto mode. + // Verify only that the wire string is correct and distinct from "default". + let auto = PermissionMode::Auto; + assert_eq!(auto.as_wire_str(), "auto"); + assert_ne!(auto.as_wire_str(), "default"); + assert!(!auto.is_default()); + } + #[test] fn test_permission_mode_display() { assert_eq!( @@ -2294,6 +2313,7 @@ channels = "ALL" "bypassPermissions" ); assert_eq!(format!("{}", PermissionMode::Default), "default"); + assert_eq!(format!("{}", PermissionMode::Auto), "auto"); } #[test] diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 7aded79599..79587ebacd 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -525,6 +525,35 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option< (models, current_model) } +/// Persist the canonical effort level for a managed agent after a positive ACP ack. +/// +/// B5: called by the TypeScript observer when `session/set_config_option` for +/// the "effort" config option receives a positive acknowledgement. The record +/// is updated in-place and persisted; the next spawn will seed this value into +/// the projected `settings.json` overlay (B7). +/// +/// `effort_level` is the acknowledged value. Pass `None` to clear the +/// canonical effort (reverts to owner-file passthrough on next spawn). +#[tauri::command] +pub fn persist_agent_effort_level( + pubkey: String, + effort_level: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let record = records + .iter_mut() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + record.effort_level = effort_level; + save_managed_agents(&app, &records) +} + #[cfg(test)] #[path = "agent_config_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c4b733e3e0..9a037af574 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -353,7 +353,6 @@ pub fn run() { #[cfg(not(buzz_updater_enabled))] let builder = builder; - let app = builder .register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| { let app = ctx.app_handle().clone(); @@ -819,6 +818,7 @@ pub fn run() { get_baked_build_env_keys, get_baked_build_env, put_agent_session_config, + persist_agent_effort_level, get_global_agent_config, set_global_agent_config, mesh_start_node, diff --git a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs index b1a72e7c3e..e06835e392 100644 --- a/desktop/src-tauri/src/managed_agents/claude_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/claude_config/mod.rs @@ -389,5 +389,54 @@ pub fn owner_settings_path() -> Option { dirs::home_dir().map(|h| h.join(".claude").join("settings.json")) } +/// Default owner MCP config path: `~/.claude.json`. +/// +/// Claude Code stores MCP server definitions here. Exposed so callers use the +/// same path as `config_bridge::claude::read_config_file` and `config_bridge::reader` +/// rather than each hard-coding `~/.claude.json` independently. +pub fn owner_mcp_config_path() -> Option { + dirs::home_dir().map(|h| h.join(".claude.json")) +} + +/// Apply the Claude-specific spawn policy to `command` (A1 + B1 + B7). +/// +/// 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`). +/// +/// `effective_model` is `None` when no model is resolved; the env key is +/// removed so Claude Code uses its own default. +pub fn apply_claude_spawn_policy( + command: &mut std::process::Command, + pubkey: &str, + managed_root: &Path, + effort_level: Option, + effective_model: Option<&str>, +) -> Result<(), String> { + let policy = ClaudeLaunchPolicy::build(pubkey, managed_root, effort_level)?; + let owner_path = owner_settings_path() + .unwrap_or_else(|| PathBuf::from("/nonexistent/no-home-dir/.claude/settings.json")); + let (projected, base_status) = project_settings_json(&owner_path, &policy); + if matches!(base_status, OwnerBaseStatus::Unreadable { .. }) { + eprintln!( + "buzz-desktop: owner ~/.claude/settings.json unreadable — \ + launching with overlay-only settings (non-fatal): {base_status:?}" + ); + } + write_projected_settings(&policy, &projected)?; + // A1: single startup model authority. + match effective_model { + Some(m) => command.env("ANTHROPIC_MODEL", m), + None => command.env_remove("ANTHROPIC_MODEL"), + }; + // B1: paired isolation atom. + command.env("CLAUDE_CONFIG_DIR", &policy.config_dir); + command.env( + "CLAUDE_SECURESTORAGE_CONFIG_DIR", + &policy.secure_storage_config_dir, + ); + Ok(()) +} + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs index 449197a3b3..55e43941ec 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/claude.rs @@ -2,9 +2,8 @@ use super::types::{ExtensionEntry, RuntimeFileConfig}; /// Read Claude Code config from `~/.claude/settings.json` and `~/.claude.json`. pub(super) fn read_config_file() -> Option { - let home = dirs::home_dir()?; - let settings_path = home.join(".claude").join("settings.json"); - let mcp_path = home.join(".claude.json"); + let settings_path = crate::managed_agents::claude_config::owner_settings_path()?; + let mcp_path = crate::managed_agents::claude_config::owner_mcp_config_path()?; let settings = read_json_file(&settings_path); let mcp_config = read_json_file(&mcp_path); diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index c51f325cf3..23a795081f 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -197,7 +197,8 @@ fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option "goose" => { super::goose::goose_config_path().map(|path| path.to_string_lossy().into_owned()) } - "claude" => Some(resolve_tilde("~/.claude.json")), + "claude" => crate::managed_agents::claude_config::owner_mcp_config_path() + .map(|p| p.to_string_lossy().into_owned()), "codex" => { super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned()) } @@ -491,7 +492,13 @@ fn build_thinking_field( session_cache: Option<&SessionConfigCache>, tiers: &InheritedConfigTiers, ) -> Option { - // Tier ordering: record env > ACP > persona env > global env > definition env > config file. + // Tier ordering: + // record env > record.effort_level (canonical Buzz-seeded) > ACP > + // persona env > global env > definition env > config file. + // + // record.effort_level is the B5 canonical value persisted from a positive + // ACP ack — it is seeded into the projected settings.json at spawn and + // represents the "configured" value in the B4 status contract. let [rec_env, pers_env, glob_env, def_env] = thinking_env_var .map(|k| { env_candidates( @@ -504,8 +511,11 @@ fn build_thinking_field( }) .unwrap_or([None, None, None, None]); + let canonical_effort = record.effort_level.as_deref(); + let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ (rec_env, ConfigOrigin::BuzzExplicit), + (canonical_effort, ConfigOrigin::BuzzExplicit), (acp_effort.as_deref(), ConfigOrigin::AcpConfigOption), (pers_env, ConfigOrigin::PersonaDefault), (glob_env, ConfigOrigin::GlobalDefault), diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs index 8613124f25..0b4beb2003 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs @@ -256,3 +256,74 @@ fn reserved_key_absent_from_definition_env_falls_through() { assert_eq!(model.value.as_deref(), Some("persona-struct-model")); assert_eq!(model.origin, ConfigOrigin::PersonaDefault); } + +// ── B4/B5 canonical effort_level tier tests ──────────────────────────────── +// +// record.effort_level is the Buzz-canonical seeded value (B5 persisted from +// a positive ACP ack). It must surface as BuzzExplicit and take precedence +// over the config-file tier (but not over record env vars). + +/// B4: record.effort_level surfaces as BuzzExplicit when no env var is set. +#[test] +fn b4_canonical_effort_level_surfaces_as_buzz_explicit() { + let mut record = test_record(); + record.effort_level = Some("high".to_string()); + let runtime = buzz_agent_runtime(); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let effort = surface + .normalized + .thinking_effort + .expect("effort must surface from canonical record tier"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); +} + +/// B4: record.effort_level shadows the config-file tier. +#[test] +fn b4_canonical_effort_level_shadows_file_tier() { + let mut record = test_record(); + // canonical effort takes precedence over file tier + record.effort_level = Some("medium".to_string()); + // no env var set — config-file would otherwise win if canonical absent + let runtime = buzz_agent_runtime(); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let effort = surface + .normalized + .thinking_effort + .expect("canonical effort must shadow file tier"); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); +} + +/// B4: a record env var override still wins over record.effort_level. +#[test] +fn b4_record_env_var_wins_over_canonical_effort_level() { + let mut record = test_record(); + record.effort_level = Some("low".to_string()); + record + .env_vars + .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()); + let runtime = buzz_agent_runtime(); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + let effort = surface + .normalized + .thinking_effort + .expect("env var must win over canonical effort"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); + // canonical is the overridden baseline + assert_eq!(effort.overridden_value.as_deref(), Some("low")); +} + +/// B4: None effort_level does not introduce a spurious tier. +#[test] +fn b4_none_canonical_effort_does_not_surface() { + let record = test_record(); // effort_level defaults to None + let runtime = buzz_agent_runtime(); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + // No env var, no session cache, no file config → effort_level field absent. + assert!( + surface.normalized.thinking_effort.is_none(), + "effort field must be absent when no tier has a value" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 1cbef32cf4..130eb7be07 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -14,10 +14,7 @@ use crate::{ util::now_iso, }; -use super::claude_config::{ - owner_settings_path, project_settings_json, write_projected_settings, ClaudeLaunchPolicy, -}; - +use super::claude_config::apply_claude_spawn_policy; mod path; pub(in crate::managed_agents) use path::build_augmented_path; pub(crate) use path::compose_path_entries; @@ -864,54 +861,19 @@ pub fn spawn_agent_child( command.env(key, value); } - // ── B1 + B7: Claude config isolation (local Claude agents only) ────────── - // - // For local agents running the Claude Code harness (runtime id = "claude"): - // B1 paired atom — inject CLAUDE_CONFIG_DIR + CLAUDE_SECURESTORAGE_CONFIG_DIR="" - // so the agent has an isolated per-agent config root while sharing the - // owner's default Keychain credential namespace (confirmed by Phase 1.5 - // binary analysis of claude 2.1.220). - // B7 layered projection — write settings.json atomically into the per-agent - // root as: owner ~/.claude/settings.json (base, read-only) + Buzz canonical - // overlay (effortLevel etc.), with protected env keys stripped from the base. - // Spawn FAILS if the projection write fails (B7.5). - // - // Written AFTER descriptor.env so the policy values override any user-provided - // CLAUDE_CONFIG_DIR or CLAUDE_SECURESTORAGE_CONFIG_DIR (protected keys were - // already stripped from descriptor.env by is_launch_policy_protected_key, so - // this is belt-and-suspenders final authority). - // - // Remote agents do NOT receive this policy — they get ANTHROPIC_MODEL in - // policy_env via a separate path and must never receive Desktop filesystem paths. + // ── 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). 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)?; - let policy = - ClaudeLaunchPolicy::build(&record.pubkey, &managed_root, record.effort_level.clone())?; - - // B7: project and write settings.json. - let owner_path = owner_settings_path().unwrap_or_else(|| { - std::path::PathBuf::from("/nonexistent/no-home-dir/.claude/settings.json") - }); - let (projected, base_status) = project_settings_json(&owner_path, &policy); - if matches!( - base_status, - super::claude_config::OwnerBaseStatus::Unreadable { .. } - ) { - eprintln!( - "buzz-desktop: owner ~/.claude/settings.json unreadable for agent {} — \ - launching with overlay-only settings (non-fatal, personal settings skipped): {:?}", - record.name, base_status - ); - } - write_projected_settings(&policy, &projected)?; - - // B1: inject the paired atom. - command.env("CLAUDE_CONFIG_DIR", &policy.config_dir); - command.env( - "CLAUDE_SECURESTORAGE_CONFIG_DIR", - &policy.secure_storage_config_dir, - ); + apply_claude_spawn_policy( + &mut command, + &record.pubkey, + &managed_root, + record.effort_level.clone(), + effective_model.as_deref(), + )?; } configure_runtime_cli(&mut command, runtime_meta); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 69e2e455ec..2aa544e0da 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -217,18 +217,15 @@ type RawGitBashPrerequisite = { install_instructions_url: string; install_hint: string; }; - type RawCommandAvailability = { command: string; resolved_path: string | null; available: boolean; }; - type RawManagedAgentPrereqs = { acp: RawCommandAvailability; mcp: RawCommandAvailability; }; - type RawRelayMember = { pubkey: string; role: string; @@ -239,7 +236,6 @@ type RawRelayMember = { type RawListRelayMembersResponse = { members: RawRelayMember[]; }; - type RawCanvasResponse = { content: string | null; updated_at: number | null; @@ -1012,7 +1008,11 @@ export async function putAgentSessionConfig( ): Promise { return invokeTauri("put_agent_session_config", { pubkey, payload }); } - +export const persistAgentEffortLevel = (p: string, l: string | null) => + invokeTauri("persist_agent_effort_level", { + pubkey: p, + effortLevel: l, + }); /** File-layer config for a runtime (e.g. `~/.config/goose/config.yaml`). */ export type RuntimeFileConfigSubset = { /** Provider set in the harness config file. */