mirror of
https://github.com/block/buzz.git
synced 2026-11-08 18:31:56 +00:00
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:
parent
4868e1b63c
commit
662c398d26
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user