feat(managed-agents): A1/A7/B4/B5/#3493 — claude model authority, PermissionMode Auto, effort persistence, bridge path fix

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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-08-03 13:21:50 -04:00
parent 4868e1b63c
commit 662c398d26
9 changed files with 200 additions and 60 deletions

View File

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

View File

@@ -525,6 +525,35 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec<AcpModelEntry>, 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<String>,
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;

View File

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

View File

@@ -389,5 +389,54 @@ pub fn owner_settings_path() -> Option<PathBuf> {
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<PathBuf> {
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<String>,
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;

View File

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

View File

@@ -197,7 +197,8 @@ fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option<String>
"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<NormalizedField> {
// 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),

View File

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

View File

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

View File

@@ -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<void> {
return invokeTauri<void>("put_agent_session_config", { pubkey, payload });
}
export const persistAgentEffortLevel = (p: string, l: string | null) =>
invokeTauri<void>("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. */