Skip to content

fix(managed-agents): apply Claude Code agent config via ACP/env; respect CLAUDE_CONFIG_DIR - #4557

Open
wpfleger96 wants to merge 8 commits into
mainfrom
duncan/claude-config-gaps
Open

fix(managed-agents): apply Claude Code agent config via ACP/env; respect CLAUDE_CONFIG_DIR#4557
wpfleger96 wants to merge 8 commits into
mainfrom
duncan/claude-config-gaps

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 3, 2026

Copy link
Copy Markdown
Member

Fixes five agent-config gaps for Claude Code managed agents in Buzz Desktop.

What this changes

A1 — Model authority for local and remote Claude spawns

apply_claude_model_env in runtime.rs sets ANTHROPIC_MODEL from the agent's effective model and removes BUZZ_ACP_MODEL from the child env (Desktop owns model selection; ACP is the live-switch channel only). Remote Claude deploys add ANTHROPIC_MODEL to policy_env via agents_deploy.rs; BUZZ_ACP_MODEL is never injected remotely. Contract tests cover both the local inject-and-remove path and the remote Claude/non-Claude cases.

B5 — Effort end-to-end via ACP

Pool-level desired effort. A single AgentPool::desired_effort is the sole authority. set_pool_effort stores the value and invalidates all idle sessions; clear_pool_effort resets to None and also invalidates. Every try_claim copies the pool's current value onto the checked-out agent so all workers converge immediately — busy workers apply it at their next session.

Provisional picks with commit/rollback. AgentPool::committed_effort tracks the last adapter-confirmed value. A pick is provisional until create_session_and_apply_model resolves: on ok → commit committed_effort; on failure → roll back desired_effort to committed_effort so the failed candidate is never recopied by try_claim or retried. A monotonic effort_generation counter lets return_agent distinguish the current pick from superseded ones — stale-generation results are discarded without touching committed state.

Startup default. record.effort_level is injected as BUZZ_ACP_EFFORT_LEVEL at local spawn (runtime.rs) and into policy_env at remote deploy (agents_deploy.rs). The harness reads it into PoolStartup.startup_effort; resolve_startup_effort() arms desired_effort after capabilities arrive at the first session creation, using the capabilities-derived thought_level_config_id. return_agent propagates a startup-resolved effort back to pool level only when no live pick or clear has ever been made (effort_ever_picked = false), preventing resurrection of a cleared value.

Two-phase acks for both pick and clear. handle_set_config_option_control:

  • Non-empty valid value → pending_session immediate ack (pool stored); final ok/failure arrives from create_session_and_apply_model after the real ACP call.
  • Empty value (Auto/clear) → pending_session immediate ack (pool cleared to None); final cleared ack arrives from create_session_and_apply_model when the session runs without an effort override.
  • Value not in adapter-advertised options → invalid_value ack; pool unchanged.

Per-request nonce correlation. The Desktop generates a crypto.randomUUID() nonce per pick/clear and passes it in the control frame. The harness echoes it in all acks (immediate and final). awaitEffortOutcome uses the nonce as the primary correlation key, rejecting acks where frame.nonce !== nonce; this prevents a stale ack from a same-value pick arriving late or from a superseded request settling the wrong promise. The observer persistence gate also checks the registered nonce, so late acks (e.g. after the 8s timeout) cannot overwrite a newer persisted value.

Observer persistence. dispatchControlResult persists on ok + thought_level (final applied) or cleared + thought_level (final clear confirmed); all other statuses (including pending_session and failure) do not persist. The nonce gate adds a second layer: mismatched nonces are silently dropped.

EffortPicker. Subscribes to control_result before sending (no dropped acks), awaits the correlated final result via awaitEffortOutcome with an 8-second timeout, surfaces pending_session / failure / invalid_value status messages, and invalidates both managedAgentsQueryKey and agentConfigSurfaceQueryKey on ok/cleared. Options come from the adapter-advertised effortOptions (exported through RuntimeConfigSurface; extract_agent_config_options retains both "model" and "thought_level" entries so the pool-level capability cache and the harness invalid_value guard work in production); falls back to low/medium/high for older adapters.

PermissionMode Auto

config.rs adds Auto to the PermissionMode enum and tests coverage in crates/buzz-acp.

#3493 — Respect user-set CLAUDE_CONFIG_DIR

config_bridge resolves both settings.json and .claude.json panel paths from the agent's effective env via resolve_effective_agent_env (full tier chain: baked floor → definition env → global → persona → record). mcp_config_file_path_for_runtime honors the resolved custom dir; empty/blank values are treated as unset (matching Claude's CLAUDE_CONFIG_DIR || homedir() semantics). agent_config.rs routes through the same effective-env path so the panel and the process cannot diverge on which dir is active. AgentConfigPanel shows a Keychain-logout caveat when a custom dir is in effect (Claude behavior, not Buzz's).

Scope explicitly excluded

Per-agent config dir provisioning, CLAUDE_SECURESTORAGE_CONFIG_DIR sentinel injection, settings.json projection, protected-key stripping, B8 MCP inheritance, spawn serialization, and last_spawn_warnings surface are absent from this diff. Silent-fallback machinery for non-Claude runtimes (#2265/#4004) is a tracked follow-up.

Closes #2692, #2884, #3493

@wpfleger96
wpfleger96 requested a review from a team as a code owner August 3, 2026 16:25
@wpfleger96 wpfleger96 changed the title feat(managed-agents): isolate Claude Code agent config per-agent root feat(managed-agents): claude config gaps — isolation, model authority, PermissionMode Auto, effort persistence, bridge path fix Aug 3, 2026
@wpfleger96 wpfleger96 changed the title feat(managed-agents): claude config gaps — isolation, model authority, PermissionMode Auto, effort persistence, bridge path fix feat(managed-agents): Claude Code agent config isolation (B1–B8 + A1/A7 + Thufir invariants) Aug 3, 2026
@wpfleger96
wpfleger96 force-pushed the duncan/claude-config-gaps branch from d3662b8 to 952e5c4 Compare August 4, 2026 23:38
…ect CLAUDE_CONFIG_DIR

Fix five gaps in Claude Code agent configuration in Buzz Desktop.
Buzz sets config via env vars at spawn and ACP messages at runtime;
file layout on disk stays the owner's.

Model (fixes #2692): ANTHROPIC_MODEL is injected at local claude spawn
as the single startup model authority. BUZZ_ACP_MODEL is removed from
the spawned env to prevent two simultaneous model authorities. Remote
claude deploys receive ANTHROPIC_MODEL in policy_env, never BUZZ_ACP_MODEL.

PermissionMode Auto (fixes #2884): adds the Auto variant to PermissionMode
with wire string "auto" and tests. The adapter handles graceful downgrade
when the active model does not support it.

Effort end-to-end via ACP (B5): EffortPicker in the config panel discovers
the thought_level configId from the session cache (never hardcoded) and
calls set_config_option. The harness verifies the configId, forwards to
the adapter, and emits an ack carrying category: "thought_level" only on
a real forward. The observer persists the canonical value only on ok+category.
At next session creation, desired_effort is applied via session_set_config_option
so the persisted default takes effect on first turn after a restart.

Honest acks: no fabricated ok anywhere. Synthetic acks (unknown configIds)
carry no category so the observer cannot persist them.

the agent's effective CLAUDE_CONFIG_DIR env var (record > persona > global),
falling back to ~/.claude/ when unset. MCP config stays at ~/.claude.json
regardless (CLAUDE_CONFIG_DIR does not remap the global MCP config file).
The panel shows a Keychain caveat note when a custom dir is active: Claude
keys its login to the config-dir path, so a custom dir creates a fresh
Keychain namespace and the agent needs re-authentication unless the user
also manages CLAUDE_SECURESTORAGE_CONFIG_DIR.

Closes #2692, #2884, #3493

Co-authored-by: Will Pfleger <[email protected]>
Signed-off-by: Will Pfleger <[email protected]>
@wpfleger96
wpfleger96 force-pushed the duncan/claude-config-gaps branch from 952e5c4 to c1397bf Compare August 4, 2026 23:39
@wpfleger96 wpfleger96 changed the title feat(managed-agents): Claude Code agent config isolation (B1–B8 + A1/A7 + Thufir invariants) fix(managed-agents): apply Claude Code agent config via ACP/env; respect CLAUDE_CONFIG_DIR Aug 4, 2026
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 7 commits August 4, 2026 20:26
…aude.json

Finding 1 — effort startup-default glue:
- Add BUZZ_ACP_EFFORT_LEVEL CLI arg + env var to buzz-acp config
- Add startup_effort to PoolStartup and OwnedAgent structs
- Extract OwnedAgent::resolve_startup_effort() method that arms
  desired_effort from startup_effort + capabilities-derived configId
- Call resolve_startup_effort() at first session creation after
  capabilities are populated (replaces inline block)
- Inject BUZZ_ACP_EFFORT_LEVEL at spawn in runtime.rs from record.effort_level
- Fix stale doc comment in types.rs (was settings.json seeding language)

Finding 2 — .claude.json path honors CLAUDE_CONFIG_DIR:
- Fix claude.rs read_config_file: resolve .claude.json relative to
  config_dir when set, same as settings.json (binary does the same)
- Fix reader.rs mcp_config_file_path_for_runtime to accept and use
  claude_config_dir for the claude case
- Fix agent_config.rs CLAUDE_CONFIG_DIR lookup to use
  resolve_effective_agent_env instead of hand-rolled record chain
  that skipped definition-env tier and baked floor

Tests added:
- resolve_startup_effort arms desired_effort from startup_effort + configId
- resolve_startup_effort does not override live pick
- resolve_startup_effort is no-op when startup_effort absent
- resolve_startup_effort is no-op when model lacks thought_level

Co-authored-by: Will Pfleger <[email protected]>
Signed-off-by: Will Pfleger <[email protected]>
Three test-mod Config{} literals in buzz-acp were missing the new
effort_level field, causing compilation failures under --all-targets
(Windows CI caught this; desktop gates run a separate workspace).
Add effort_level: None to all three.

Run cargo fmt --all and cargo fmt --manifest-path desktop/src-tauri/Cargo.toml
to fix the rustfmt diffs caught by CI Rust Lint and desktop-tauri-fmt-check.

Co-authored-by: Will Pfleger <[email protected]>
Signed-off-by: Will Pfleger <[email protected]>
…emote parity, option validation

Replace per-worker desired_effort/startup_effort with ONE pool-level
desired_effort (AgentPool::desired_effort). Closes I-2 (queued-as-ok),
I-3 (first-idle-only), and I-5 (spawn-drift) together.

Rust (crates/buzz-acp):
- AgentPool gains desired_effort field; set_idle_agent_effort replaced
  by set_pool_effort (stores pool-level, invalidates all idle sessions,
  returns Stored { invalidated }) and clear_pool_effort (sets None,
  invalidates all idle).
- try_claim always copies pool.desired_effort onto the claimed agent.
- return_agent propagates startup-resolved effort back to pool level.
- create_session_and_apply_model emits honest final control_result ack
  (ok/failure) after the real ACP call; no pre-persist on queued state.
- handle_set_config_option_control: clear path emits "cleared"; non-empty
  path emits "pending_session"; I-7 validates value against adapter-
  advertised options and emits "invalid_value" on mismatch.
- M-1: restore damaged PoolStartup doc comment.
- Tests: set_pool_effort_stores_and_invalidates, multi-worker convergence,
  clear_pool_effort, test_b5_empty_value_emits_cleared_ack,
  test_b5_invalid_value_emits_invalid_value_ack_and_does_not_update_pool.

TypeScript (desktop/src):
- effortOutcome.ts: awaitEffortOutcome helper — subscribes before send,
  awaits correlated final result (ok/failure/invalid_value/cleared), falls
  back to pending_session on timeout.
- effortOutcome.test.mjs: 13 tests covering all statuses, correlation,
  cleanup, and deferred-path (pending_session → final ok).
- EffortPicker: uses awaitEffortOutcome; empty value = clear (I-1);
  effortOptions from adapter (I-7); surfaces pending_session / failure /
  invalid_value status messages; invalidates queries on ok/cleared.
- observerRelayStore: persist on ok+thought_level (final applied ack) OR
  cleared+thought_level (Auto clear); skip all other statuses.
- types.ts: SetConfigOptionResult named type; effortOptions field on
  RuntimeConfigSurface.

agents_deploy.rs (I-4): project record.effort_level → BUZZ_ACP_EFFORT_LEVEL
into remote policy_env, mirroring local spawn; positive/negative tests.

config_bridge (M-2/M-3): direct test for mcp_config_file_path_for_runtime
with custom CLAUDE_CONFIG_DIR; treat empty/blank CLAUDE_CONFIG_DIR as unset
in agent_config.rs (matches Claude's || homedir() semantics).

reader.rs: effort_options populated from session cache for claude runtime.

Co-authored-by: Will Pfleger <[email protected]>
Signed-off-by: Will Pfleger <[email protected]>
V-1 (clear resurrection prevention): add effort_ever_picked flag to
AgentPool. return_agent propagates a startup-resolved desired_effort to
pool level ONLY when no live pick/clear has ever been made. A user clear
sets effort_ever_picked=true; subsequent worker returns can no longer
resurrect the cleared value.

V-2 (all-busy capability loss): add PoolEffortCapabilities cache and
capabilities_ever_discovered flag to AgentPool. Pool-level cache is
written at return_agent (refreshed from the returning worker's
capabilities) and via notify_capabilities_discovered. The
handle_set_config_option_control path reads from the cache instead of
scanning idle agent slots, so picks and clears are never silently dropped
when all workers are checked out.

V-3 (busy-worker session convergence): return_agent compares the worker's
checkout snapshot (desired_effort) against the current pool value. If they
differ (a pick or clear arrived while the worker was busy), the worker's
sessions are invalidated so the next try_claim creates a fresh session
under the current pool value.

Tests added (pool.rs effort_tests):
- test_v1_clear_while_busy_return_does_not_resurrect_cleared_effort
- test_v2_pick_while_all_busy_is_stored_not_dropped
- test_v3_busy_worker_sessions_invalidated_on_return_after_pick
- test_startup_effort_propagates_to_pool_on_first_return_when_no_live_pick

Existing tests updated (lib.rs control_result_tests, pool.rs effort_tests):
four tests that construct AgentPool::from_slots with agents carrying
model_capabilities now also call notify_capabilities_discovered to
populate the pool-level cache, matching production behavior where the
cache is written at return_agent time.

Co-authored-by: Will Pfleger <[email protected]>
Signed-off-by: Will Pfleger <[email protected]>
…scovery window

Before this fix, a pick or clear sent during the first turn (before any
worker returns and populates the pool capability cache) fell through to
the synthetic-ok branch. `capabilities_ever_discovered` was false,
`effort_capabilities.config_id` was None, and `is_thought_level` was
false: the harness emitted a fabricated ok with no category, the value
was never stored, and the EffortPicker reported success with nothing
applied or persisted.

Fix:

- Desktop sends `category: "thought_level"` on all effort frames
  (sendSetConfigOption gains an optional category param; EffortPicker
  passes it). The harness uses this as the trust signal in the
  pre-discovery window (case D).

- Harness (lib.rs): adds case D — `!capabilities_ever_discovered &&
  frame_category == "thought_level" && configId != "unknown"` — to
  the `is_thought_level` check. Picks and clears in this window are
  stored and acked `pending_session`/`cleared` rather than synthetic ok.

- pool.rs: removes the `NoCatalog` guard from `set_pool_effort` and the
  `NoCatalog` variant entirely. The caller already gates on
  `is_thought_level`; `set_pool_effort` always stores. Removes the
  unreachable `NoCatalog => pending_session` match arm from the handler.

- `notify_capabilities_discovered` moved to `#[cfg(test)]` with an
  honest doc. In production the cache is written only at `return_agent`.
  All false doc claims (pool.rs:264, 306; lib.rs:1020-21) corrected.

- Tests: two new case-D tests in lib.rs
  (`test_b5_pre_discovery_pick_with_category_stores_and_emits_pending_session`,
  `test_b5_pre_discovery_clear_with_category_emits_cleared_not_synthetic_ok`);
  existing NoCatalog tests rewritten to match new semantics (always stores).

Co-authored-by: Will Pfleger <[email protected]>
Signed-off-by: Will Pfleger <[email protected]>
…ation (IMPORTANT 1/2/3)

Addresses all three IMPORTANT findings from Thufir Pass 2 plus the extraction
MINOR that feeds harness-side invalid_value validation in production.

IMPORTANT 1 — provisional pick + rollback:
- Added committed_effort field to AgentPool as the last adapter-confirmed
  baseline. Pending picks are provisional until the ACP call resolves.
- return_agent: generation-matched Applied → commit; Failed → rollback to
  committed_effort so the failed candidate is never recopied by try_claim.
- Stale-generation results (superseded by newer pick/clear) are discarded
  entirely — no commit, no rollback, pool unchanged.

IMPORTANT 2 — per-request nonce correlation:
- AgentPool.effort_generation incremented on every pick/clear; carried on
  checked-out agents as desired_effort_gen. Pool echoes it as a nonce field
  (from pending_effort_nonce, set from the Desktop's crypto.randomUUID() nonce)
  in all immediate and final acks.
- Desktop: sendSetConfigOption gains optional nonce param; AgentConfigPanel
  generates crypto.randomUUID() per request and registers it before awaiting.
- awaitEffortOutcome: nonce is the primary correlation key; rejects acks where
  frame.nonce !== nonce even if configId and value match (stale same-value picks).
- observerRelayStore: persistence gate checks ackNonce === registered before
  persisting ok/cleared; backwards-compat — acks without nonce always pass.
- Four new nonce correlation tests in effortOutcome.test.mjs.

IMPORTANT 3 — two-phase clear:
- handle_set_config_option_control: empty value now emits pending_session
  (non-terminal) instead of terminal cleared. Pool is cleared immediately so
  future sessions run without effort, but confirmation waits for adapter.
- create_session_and_apply_model: new else-if branch — desired_effort=None but
  desired_effort_gen set → pending clear; emits final cleared ack with nonce+
  category after the session creates without effort override. Observer persists
  null only on this final cleared.
- Tests updated: test_b5_empty_value_emits_pending_session_ack and
  test_b5_pre_discovery_clear_with_category_emits_pending_session_not_synthetic_ok.

MINOR — extract_agent_config_options (feeds harness invalid_value production):
- New function in acp.rs that retains both category=="model" and
  category=="thought_level" entries from session/new configOptions.
- AgentModelCapabilities.config_options_raw now populated via this function so
  the pool-level capability cache includes thought_level valid_values in
  production (not just tests). The harness invalid_value guard now runs for
  real adapter picks.

MINOR — picker query invalidation:
- AgentConfigPanel: on ok/cleared outcome, invalidates agentConfigSurfaceQueryKey
  (source of currentEffort) in addition to managedAgentsQueryKey so the panel
  reflects the new committed effort immediately.

MINOR — restore damaged doc comment:
- lib.rs test_b5_real_forward_ack_includes_thought_level_category: restored the
  middle line of the three-line doc comment that was dropped in a prior commit.

Pool tests: 4 new (test_failure_rolls_back_desired_effort_to_committed,
test_applied_commits_desired_effort_to_committed,
test_stale_gen_failure_does_not_rollback_pending_pick,
test_cleared_commits_none_to_committed_effort).

Co-authored-by: Will Pfleger <[email protected]>
Signed-off-by: Will Pfleger <[email protected]>
F-1: gate resolve_startup_effort on desired_effort_gen.is_none()

resolve_startup_effort re-armed desired_effort from startup_effort
whenever it was None, which could not distinguish 'never picked' from
'user just cleared'. A post-clear checkout carries desired_effort=None
and desired_effort_gen=Some(N); the old guard would re-arm the startup
value, fire the apply branch instead of the cleared branch, and emit an
ok ack carrying the clear's nonce — causing the observer to persist the
old value over the user's explicit clear, with perpetual V-3 churn.

Fix: add && self.desired_effort_gen.is_none() so the startup-seeding
path is only taken when no live pick/clear has ever been stored (gen
never set). Startup seeding (gen None) still works; post-clear (gen
Some) falls through to the cleared branch as intended.

New test: test_resolve_startup_effort_noop_after_live_clear_gen_is_some

F-2: emit real configId in final cleared ack (pool.rs:1465)

The cleared ack hardcoded "effort" as the configId. awaitEffortOutcome
checks frame.configId !== configId before the nonce, so any adapter
whose thought_level configId differs would leave the clear promise
unsettled and fall to the 8s timeout.

Fix: read the configId from agent.model_capabilities (populated just
above at line 1336), falling back to "effort" when capabilities are
not yet populated (pre-discovery clear path).

F-3: box PoolEvent::Wake large variant (lib.rs:1910)

AgentPool grew past clippy's large_enum_variant threshold after the
committed_effort, nonce, and capability-cache fields were added.
CI Rust Lint and Windows Rust both failed on this branch at 333362a.

Fix: Box<Result<AgentPool, String>> in the Wake variant; box on
construction, unbox on match.

Co-authored-by: Will Pfleger <[email protected]>
Signed-off-by: Will Pfleger <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Claude Code agents ignore the model set in Buzz Desktop — model picker persists but nothing applies it at spawn

1 participant