Files
buzz/crates/buzz-backend-kubernetes/src/env.rs
Will Pfleger 50a71137e6 feat(managed-agents): close five Claude Code agent-config gaps (#4557)
Closes five Claude Code agent-config gaps in Buzz Desktop, split into
three commits that share the spawn-time and live-switch surfaces.

## Config isolation, model authority, and Auto mode (#2692, #2884,
#3493)

- **`CLAUDE_CONFIG_DIR` isolation (#3493).** `config_bridge` resolves
both `settings.json` and `.claude.json` panel paths from the agent's
effective env (`resolve_effective_agent_env` — baked floor → definition
→ global → persona → record), so the panel reads the same directory the
agent runs against. `mcp_config_file_path_for_runtime` honors a custom
dir; empty/blank is treated as unset, matching Claude's
`CLAUDE_CONFIG_DIR || homedir()` semantics. `AgentConfigPanel` shows a
Keychain caveat when a custom dir is active (a custom dir maps to a
fresh Keychain namespace unless `CLAUDE_SECURESTORAGE_CONFIG_DIR` is
also set).
- **Single startup model authority (#2692).** `ANTHROPIC_MODEL` is the
sole startup model authority for Claude. Local spawns write
`ANTHROPIC_MODEL` and strip `BUZZ_ACP_MODEL` so the harness never sees
two authorities; remote deploys send `ANTHROPIC_MODEL` in `policy_env`
instead of `BUZZ_ACP_MODEL`. Non-Claude runtimes are unchanged.
- **`PermissionMode::Auto` (#2884).** Wire string `"auto"`, model-gated,
degrades to the agent default when the active model doesn't advertise
it.

## Thinking effort end-to-end for local Claude agents

Effort flows from the running session's discovered `thought_level`
config option through the config surface to a local-only write control
and a read-only two-facts display.

- **Reader.** Discovers the `thought_level` config option from the
session cache (never hardcoded) and populates `effort_config_id` /
`effort_options` on `RuntimeConfigSurface`. The canonical effort tier
orders record env > `record.effort_level` (`BuzzExplicit`) > ACP >
persona > global > definition > file, so the panel shows the effort the
next spawn will launch with while `resolve_with_override` surfaces the
running ACP value as the struck-through override — neither masks the
other silently.
- **Write control.** `persist_agent_effort_level` is a direct-write
Tauri setter (writes `record.effort_level` + `updated_at`,
`save_managed_agents`) that rejects non-local backends — remote effort
is set at deploy time via `policy_env`. `EffortPickerField` mounts in
`AgentInstanceEditDialog` beside the Model block, gated on a local
backend **AND** a discovered `effortConfigId`. It persists directly and
invalidates the config surface, mirroring the
`setManagedAgentAutoRestart` standalone-setter precedent, so the frozen
`UpdateManagedAgentInput` shape stays frozen.
- **Display.** The read-only configured-vs-current two-facts display is
the `thinkingEffort` normalized field in `AgentConfigPanel`, fed by the
reader's canonical tier ordering.
- `buzz-acp` applies the startup effort env at session start.

## Distinguish a rejected model switch from silent success

A live model switch collapsed adapter rejection into success:
`apply_model_switch` returned `Ok` on both an accepted switch and an
application-level refusal, so the caller cached pre-switch capabilities
as if they described the target model and Desktop reported the pick as
landed.

- `ModelSwitchOutcome::{Applied(Value),Rejected}`. Transport-class
errors still propagate as `Err` (respawn the poisoned stdio); an
application-level refusal is now `Rejected`.
- The caller drives everything off `post_switch_snapshot`: `Applied`
refreshes `model_capabilities` from the target model's echoed
`configOptions` (or drops to `None` when none are echoed, so a
pre-switch snapshot is never mistaken for the target's); `Rejected`
preserves pre-switch caps and emits a `failure` `control_result`. Effort
resolution and the session-config capture read the post-switch snapshot
so they converge on the model the session actually runs;
`modelOverridden` is gated on `switch_succeeded`.
- `liveSwitchOutcome.ts` gains a distinct `"failed"` outcome for the
adapter `failure` frame and treats the busy-path `"sent"` ack as
provisional — it never counts toward success. Success is confirmed only
by a real positive terminal frame (the busy-path deferred apply emits a
correlated `switched` `control_result` when it lands), and the fallback
timeout resolves an honest `"pending"` (accepted, apply deferred), never
a false `"ok"`. `ModelPicker` surfaces a distinct toast per outcome —
failed, unsupported, and pending.

## Scope explicitly excluded

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

## Sanctioned follow-ups

- **Live mid-conversation effort switching.** Effort is spawn-scoped
only in this PR: the worker reads `BUZZ_ACP_EFFORT_LEVEL` once and
applies it at session creation. The live effort-switch machinery
(mid-conversation effort RPC + ack frame) was deliberately removed and
is archived on `archive/claude-config-gaps-live-effort` for a future
plan-gated revival.
- **Idle-path late model-switch rejection is unobservable.** An idle
switch acks `switched` immediately after catalog validation, but the
real `set_config_option` runs at the next session creation — potentially
much later — so a rejection there is not surfaced back to the picker
(holding a subscription that long is not sensible). Pre-existing,
catalog-gated behavior; a durable fix is a tracked follow-up.

Closes #2692, #2884, #3493

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-18 16:29:28 -04:00

818 lines
35 KiB
Rust

//! Building the pod environment (spec §Launch data, §Entrypoint mapping table).
//!
//! The three tiers are resolved *here*, before serialization, because a
//! Kubernetes Secret's `data` is a flat map with no precedence of its own: if
//! two tiers supplied the same key, whichever entry landed in the map would
//! win silently. Resolving in-provider makes later-wins explicit and testable.
use crate::wire::{AgentPayload, LaunchBlock};
use std::collections::BTreeMap;
/// Keys the authoritative tier owns.
///
/// Load-bearing, not documentation: tier 3 *clears* every key on this list
/// before writing its own values, so a key the authoritative tier has no value
/// for is **removed** rather than left holding a lower-tier value. Plain
/// overwrite is not enough — most of these are written conditionally
/// (`BUZZ_ACP_AGENT_ARGS` only when `launch.args` is non-empty,
/// `BUZZ_ACP_RESPOND_TO` only when set), and without the clear, a lower tier
/// could supply the value for exactly the cases the authoritative tier stays
/// silent on. Clearing is also what the local spawn does: the desktop strips
/// reserved keys from user env before the authoritative layer is written
/// (`env_vars.rs:54-57`), so absent-means-absent in both paths.
const AUTHORITATIVE_KEYS: &[&str] = &[
"BUZZ_RELAY_URL",
"BUZZ_PRIVATE_KEY",
"NOSTR_PRIVATE_KEY",
"BUZZ_AUTH_TAG",
"BUZZ_ACP_AGENT_OWNER",
"BUZZ_ACP_AGENT_COMMAND",
"BUZZ_ACP_AGENT_ARGS",
"BUZZ_ACP_RESPOND_TO",
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
"BUZZ_ACP_MCP_COMMAND",
"BUZZ_ACP_EXIT_AFTER_INACTIVITY",
START_NONCE_KEY,
];
/// The attempt's generation, as the harness sees it. Also the Secret's name
/// suffix — one generation, one identity — so the reconciler restamps this on
/// every create attempt rather than letting the caller's value persist across
/// a retry.
pub const START_NONCE_KEY: &str = "BUZZ_MANAGED_AGENT_START_NONCE";
/// Presence is the only remote liveness signal (I3), so a launch that
/// suppresses it is non-conforming (L1 item 2) — and unlike a reserved-key
/// collision, there is no "authoritative value" to overwrite it with. Refuse.
const FORBIDDEN_KEY: &str = "BUZZ_ACP_NO_PRESENCE";
/// Kubernetes' own cap on the summed value bytes of a Secret
/// (`MaxSecretSize`, `pkg/apis/core/types.go`). Enforced here so an oversized
/// env surfaces as a named provider error rather than an apiserver rejection
/// partway through a deploy.
const MAX_SECRET_BYTES: usize = 1024 * 1024;
/// A POSIX-shaped env var name: `[A-Za-z_][A-Za-z0-9_]*`.
///
/// Kubernetes validates Secret *keys* as `IsConfigMapKey`
/// (`[-._a-zA-Z0-9]+`), which is looser — `foo.bar` is a legal Secret key.
/// What the kubelet then does with such a key **changed between versions**:
/// through 1.29 it filtered invalid env names out of `envFrom` and emitted an
/// `InvalidEnvironmentVariableNames` warning event
/// (`pkg/kubelet/kubelet_pods.go:646,654` at v1.29.0); from 1.30 that filter
/// is gone (KEP-4369) and the key is injected verbatim. The same manifest
/// would silently drop a variable on one cluster and set it on another, so we
/// fail closed on the provider side and get one deterministic behavior.
fn is_posix_env_key(key: &str) -> bool {
let mut chars = key.chars();
match chars.next() {
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}
/// An identity component (L1 item 1) is present only if it is nonempty after
/// trimming — and the **trimmed form is what gets stored**. The validator and
/// the writer must never disagree about the value: a guard that accepts
/// `" wss://relay "` and then writes it with the padding intact has only
/// moved the failure from a loud refusal to a connect error in the harness.
fn identity_component(value: &str) -> Option<&str> {
let trimmed = value.trim();
(!trimmed.is_empty()).then_some(trimmed)
}
/// The harness's `allowlist` gate mode, spelled as the desktop serializes
/// `RespondTo` (kebab-case) and as `buzz-acp`'s CLI parses it.
const RESPOND_TO_ALLOWLIST: &str = "allowlist";
/// Every gate mode `buzz-acp` accepts, spelled as its `clap::ValueEnum` parses
/// them (`config.rs:95-101`, kebab-case via `RespondTo`'s `Display`).
///
/// Deliberately the **harness's** four and not the desktop's three: the desktop
/// rejects `nobody` on purpose (`managed_agents/types.rs:871-880`), but the
/// harness starts fine with it. This guard exists to cover non-desktop callers,
/// so inheriting a desktop-only narrowing would refuse a launch that works.
const RESPOND_TO_MODES: [&str; 4] = ["owner-only", RESPOND_TO_ALLOWLIST, "anyone", "nobody"];
/// Refuse a respond-to gate the harness will reject at config parse.
///
/// The local spawn path re-validates this before spawning — "doing it here
/// means we never spawn a doomed process" (`runtime.rs:378`) — but the deploy
/// path projects the record's fields straight through. Without this, a gate
/// the harness refuses becomes a pod that exits 1 at startup; `restartPolicy:
/// Never` turns that into `Terminated` → `Delete` → recreate, and each cycle
/// leaves a Secret the in-call path never reaps (only a later deploy's orphan
/// sweep does, at `ORPHAN_SECRET_MIN_AGE_SECS`). The user-visible ending is
/// "startup not confirmed", indistinguishable from a slow cluster.
///
/// Mirrors `buzz-acp`'s own rules exactly (`config.rs:95-101,996-1004,629-641`),
/// deliberately including their asymmetry: the allowlist is validated **only**
/// in allowlist mode, and merely warned about otherwise. Validating it in
/// every mode would refuse a deploy whose identical local spawn succeeds —
/// and a stale list is already harmless here, since
/// `BUZZ_ACP_RESPOND_TO_ALLOWLIST` is an authoritative key that tier 3 clears.
fn validate_respond_to_gate(respond_to: &str, allowlist: Option<&[String]>) -> Result<(), String> {
// Exact, untrimmed: `clap` does not trim, so `" allowlist "` is `rc=2` at
// the harness — a parse failure even earlier than the config errors below.
if !RESPOND_TO_MODES.contains(&respond_to) {
return Err(format!(
"deploy refused: respond_to {respond_to:?} is not a mode the \
harness accepts (expected one of {}) — the pod would fail to \
parse its arguments, be replaced, and leave a Secret behind on \
every attempt",
RESPOND_TO_MODES.join(", ")
));
}
if respond_to != RESPOND_TO_ALLOWLIST {
return Ok(());
}
let entries = allowlist.unwrap_or_default();
if entries.is_empty() {
return Err(format!(
"deploy refused: respond_to is {RESPOND_TO_ALLOWLIST:?} but the \
allowlist is empty — the harness refuses this at startup, so the \
pod would fail, be replaced, and leave a Secret behind on every \
attempt"
));
}
for entry in entries {
let trimmed = entry.trim();
if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!(
"deploy refused: invalid pubkey in respond_to_allowlist: \
{entry:?} (must be exactly 64 hex characters)"
));
}
}
Ok(())
}
/// Inputs the provider itself supplies to the authoritative tier.
pub struct AuthoritativeInputs<'a> {
/// The attempt's generation token — also the Secret's name suffix, so the
/// lifecycle correlator and the Secret generation are one identity.
pub generation: &'a str,
/// Resolved from `provider_config.inactivity_seconds`; `None` when the
/// indefinite opt-in was chosen (which this version refuses elsewhere).
pub inactivity_seconds: Option<u64>,
}
/// Resolve the full pod environment.
///
/// Order is the spec's, and the function body is deliberately three writes in
/// that order — tier 1, tier 2, tier 3 — so "later wins" is visible rather
/// than argued.
pub fn build_env(
agent: &AgentPayload,
auth: AuthoritativeInputs<'_>,
) -> Result<BTreeMap<String, String>, String> {
let default_launch = LaunchBlock::default();
let launch = agent.launch.as_ref().unwrap_or(&default_launch);
let mut env: BTreeMap<String, String> = BTreeMap::new();
// Tier 1 — overridable behavior defaults.
env.extend(launch.policy_env.clone());
// Tier 2 — user/layered env. The descriptor already merged
// global < persona < agent, so `agent.env_vars` is NOT re-merged on top
// (§Launch data tier 2) — doing so would resurrect a layer the desktop
// already resolved. When the desktop predates the `launch` block we fall
// back to the legacy field, which is the only case it is the truth.
if agent.launch.is_some() {
env.extend(launch.env.clone());
} else {
env.extend(agent.env_vars.clone());
}
// Validate what the lower tiers contributed, before the authoritative
// tier overwrites any of it. A reserved-key collision is NOT fatal: the
// spec's precedence is later-wins, so tier 3 simply overwrites it, which
// is exactly what a local spawn does. Only a key that has no
// authoritative counterpart to overwrite it — presence suppression — is
// a refusal.
for key in env.keys() {
if !is_posix_env_key(key) {
return Err(format!(
"env key {key:?} is not a POSIX environment variable name \
([A-Za-z_][A-Za-z0-9_]*); Kubernetes would treat it \
inconsistently across cluster versions"
));
}
if key.eq_ignore_ascii_case(FORBIDDEN_KEY) {
return Err(format!(
"{FORBIDDEN_KEY} must not be set on a remote agent: presence \
is the only signal that a remote agent is alive"
));
}
}
// Tier 3 — authoritative. Every key it owns is cleared first, then the
// values it has are written, so it wins at a key whether or not it has a
// value there (see [`AUTHORITATIVE_KEYS`]).
for key in AUTHORITATIVE_KEYS {
env.remove(*key);
}
// Identity comes from top-level payload fields, never from `env_vars`
// (§Reserved-key rule). All three components must be nonempty: an agent
// that cannot reach a relay is the identityless launch L1 item 1 exists to
// prevent, and a blank field would otherwise sail through into the Secret
// and produce a pod that starts, fails to connect, and looks like a
// network problem.
let Some(relay_url) = identity_component(&agent.relay_url) else {
return Err("deploy refused: relay_url is empty — the agent would have \
no relay to connect to"
.to_string());
};
env.insert("BUZZ_RELAY_URL".into(), relay_url.to_string());
env.insert("BUZZ_PRIVATE_KEY".into(), agent.private_key_nsec.clone());
// The git credential/signing helpers read NOSTR_PRIVATE_KEY.
env.insert("NOSTR_PRIVATE_KEY".into(), agent.private_key_nsec.clone());
// Owner: at least one of these must resolve, or the harness cannot match
// `!shutdown` and §Stop describes a mechanism that does not work.
let auth_tag = agent.auth_tag.as_deref().and_then(identity_component);
let owner = launch.owner_pubkey.as_deref().and_then(identity_component);
match (auth_tag, owner) {
(None, None) => {
return Err("deploy refused: neither auth_tag nor launch.owner_pubkey \
resolved — without an owner the agent cannot honor \
!shutdown"
.to_string())
}
(tag, own) => {
if let Some(t) = tag {
env.insert("BUZZ_AUTH_TAG".into(), t.to_string());
}
if let Some(o) = own {
env.insert("BUZZ_ACP_AGENT_OWNER".into(), o.to_string());
}
}
}
// The harness and MCP binaries are resolved against the *image's* PATH.
// A host path forwarded from the desktop is guaranteed absent in the
// container (§Launch data, host-resolved values).
if let Some(command) = launch.command.as_deref().filter(|c| !c.is_empty()) {
env.insert("BUZZ_ACP_AGENT_COMMAND".into(), command.to_string());
}
if !launch.args.is_empty() {
// Comma-joined because that is what the harness's CLI parser decodes,
// and what the desktop's local spawn does. An argument containing a
// comma is unrepresentable in both paths; inventing an escaping
// scheme here would produce args the harness cannot decode.
env.insert("BUZZ_ACP_AGENT_ARGS".into(), launch.args.join(","));
}
env.insert("BUZZ_ACP_MCP_COMMAND".into(), "buzz-dev-mcp".into());
if let Some(respond_to) = agent.respond_to.as_deref().filter(|s| !s.is_empty()) {
validate_respond_to_gate(respond_to, agent.respond_to_allowlist.as_deref())?;
env.insert("BUZZ_ACP_RESPOND_TO".into(), respond_to.to_string());
}
if let Some(list) = agent
.respond_to_allowlist
.as_ref()
.filter(|l| !l.is_empty())
{
env.insert("BUZZ_ACP_RESPOND_TO_ALLOWLIST".into(), list.join(","));
}
if let Some(secs) = auth.inactivity_seconds {
env.insert("BUZZ_ACP_EXIT_AFTER_INACTIVITY".into(), secs.to_string());
}
// The generation token doubles as the lifecycle-frame correlator, so pod
// logs and observer frames share one identity (§K8s Secrets).
env.insert(START_NONCE_KEY.into(), auth.generation.to_string());
let total: usize = env.values().map(String::len).sum();
if total > MAX_SECRET_BYTES {
return Err(format!(
"agent environment is {total} bytes; Kubernetes caps Secret data \
at {MAX_SECRET_BYTES}"
));
}
Ok(env)
}
#[cfg(test)]
mod tests {
use super::*;
fn payload_json(extra_agent: serde_json::Value) -> AgentPayload {
let mut agent = serde_json::json!({
"name": "a",
"relay_url": "wss://relay.example",
"private_key_nsec": "nsec1example",
"auth_tag": "tag-1",
});
let (serde_json::Value::Object(base), serde_json::Value::Object(extra)) =
(&mut agent, extra_agent)
else {
panic!("expected objects")
};
base.extend(extra);
serde_json::from_value(agent).unwrap()
}
fn build(agent: &AgentPayload) -> Result<BTreeMap<String, String>, String> {
build_env(
agent,
AuthoritativeInputs {
generation: "gen0001",
inactivity_seconds: Some(7200),
},
)
}
#[test]
fn identity_comes_from_top_level_fields() {
let env = build(&payload_json(serde_json::json!({}))).unwrap();
assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example");
assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example");
assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
}
/// Wren's amendment, and the spec's later-wins rule: a lower tier that
/// spoofs an authoritative key is *overwritten*, not refused. Refusing
/// would diverge from the local spawn, where the same env is written
/// before the authoritative layer and simply loses.
#[test]
fn lower_tiers_cannot_spoof_authoritative_values() {
let agent = payload_json(serde_json::json!({
"launch": {
"command": "goose",
"policy_env": {
"BUZZ_PRIVATE_KEY": "nsec1attacker",
"BUZZ_MANAGED_AGENT_START_NONCE": "forged",
},
"env": {
"BUZZ_RELAY_URL": "wss://attacker.example",
"NOSTR_PRIVATE_KEY": "nsec1attacker",
"BUZZ_AUTH_TAG": "forged-tag",
"BUZZ_ACP_AGENT_OWNER": "cafe",
"BUZZ_ACP_AGENT_COMMAND": "/bin/sh",
"BUZZ_ACP_MCP_COMMAND": "/bin/sh",
"BUZZ_ACP_EXIT_AFTER_INACTIVITY": "0",
},
"owner_pubkey": "beef"
}
}));
let env = build(&agent).unwrap();
assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example");
assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example");
assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beef");
assert_eq!(env["BUZZ_ACP_AGENT_COMMAND"], "goose");
assert_eq!(env["BUZZ_ACP_MCP_COMMAND"], "buzz-dev-mcp");
assert_eq!(env["BUZZ_ACP_EXIT_AFTER_INACTIVITY"], "7200");
assert_eq!(env["BUZZ_MANAGED_AGENT_START_NONCE"], "gen0001");
}
/// Tier 1 is *overridable* — user env beats policy defaults, matching the
/// local spawn, where the user layer is written after them. Getting this
/// backwards would make remote agents ignore overrides local agents honor.
#[test]
fn user_env_overrides_policy_defaults() {
let agent = payload_json(serde_json::json!({
"launch": {
"policy_env": {"GOOSE_MODE": "auto", "BUZZ_ACP_MODEL": "sonnet"},
"env": {"GOOSE_MODE": "chat"},
"owner_pubkey": "beef"
}
}));
let env = build(&agent).unwrap();
assert_eq!(env["GOOSE_MODE"], "chat");
assert_eq!(env["BUZZ_ACP_MODEL"], "sonnet");
}
/// F2 provider seam: the desktop strips both model keys from a Claude
/// launch.env and rides the canonical model on policy_env alone. This test
/// pins the final `build_env` output for that shape: the canonical
/// ANTHROPIC_MODEL survives (tier 1, no tier-2 key to overwrite it) and
/// BUZZ_ACP_MODEL is absent — so the remote process has exactly one model
/// authority. Same-value and conflicting-value collisions are both moot
/// because the desktop already removed the launch.env keys.
#[test]
fn claude_launch_yields_single_model_authority_through_build_env() {
let agent = payload_json(serde_json::json!({
"launch": {
"command": "claude",
"policy_env": {"ANTHROPIC_MODEL": "claude-opus-4"},
// Desktop stripped both model keys from launch.env for claude.
"env": {"KEEP_ME": "yes"},
"owner_pubkey": "beef"
}
}));
let env = build(&agent).unwrap();
assert_eq!(
env["ANTHROPIC_MODEL"], "claude-opus-4",
"canonical model must survive as the single authority"
);
assert!(
!env.contains_key("BUZZ_ACP_MODEL"),
"no second model authority may reach the remote process"
);
assert_eq!(env["KEEP_ME"], "yes");
}
/// F2 provider seam, adversarial: even if a launch.env somehow still carries
/// model keys (older desktop, tampering), tier 2 later-wins over tier 1 —
/// which is exactly why the desktop must strip them. This documents the
/// hazard the desktop fix prevents: a launch.env ANTHROPIC_MODEL overrides
/// the canonical, and a launch.env BUZZ_ACP_MODEL introduces a second
/// authority. Neither key is authoritative in k8s, so the provider cannot
/// defend against it — the desktop strip is the only guard.
#[test]
fn launch_env_model_keys_would_win_over_policy_env_documenting_the_hazard() {
let agent = payload_json(serde_json::json!({
"launch": {
"command": "claude",
"policy_env": {"ANTHROPIC_MODEL": "claude-opus-4"},
"env": {"ANTHROPIC_MODEL": "user-haiku", "BUZZ_ACP_MODEL": "user-sonnet"},
"owner_pubkey": "beef"
}
}));
let env = build(&agent).unwrap();
assert_eq!(
env["ANTHROPIC_MODEL"], "user-haiku",
"launch.env later-wins — proving the desktop must strip it"
);
assert_eq!(
env["BUZZ_ACP_MODEL"], "user-sonnet",
"a leftover BUZZ_ACP_MODEL would be a second authority — desktop strips it"
);
}
/// F2 provider seam, same-value collision: a leftover launch.env
/// ANTHROPIC_MODEL that happens to match the canonical policy_env value is
/// still a second authority structurally — tier 2 later-wins, so the value
/// the remote process sees comes from launch.env, not the canonical tier.
/// It is only benign because the strings coincide; the desktop strip is what
/// guarantees the canonical tier is authoritative regardless of the leftover
/// value. Pinning the same-value case proves `build_env` cannot itself
/// distinguish a matching leftover from a conflicting one.
#[test]
fn launch_env_same_value_model_key_still_rides_tier_two_through_build_env() {
let agent = payload_json(serde_json::json!({
"launch": {
"command": "claude",
"policy_env": {"ANTHROPIC_MODEL": "claude-opus-4"},
// Same value as the canonical policy_env entry.
"env": {"ANTHROPIC_MODEL": "claude-opus-4"},
"owner_pubkey": "beef"
}
}));
let env = build(&agent).unwrap();
assert_eq!(
env["ANTHROPIC_MODEL"], "claude-opus-4",
"value coincides, but it is tier 2 (launch.env) that wins — the \
provider cannot tell a matching leftover from a conflicting one"
);
}
/// `launch.env` already contains the merged user env, so re-merging the
/// legacy field would undo a layering the desktop already resolved.
#[test]
fn legacy_env_vars_are_not_remerged_when_launch_present() {
let agent = payload_json(serde_json::json!({
"env_vars": {"STALE": "yes", "SHARED": "legacy"},
"launch": {"env": {"SHARED": "resolved"}, "owner_pubkey": "beef"}
}));
let env = build(&agent).unwrap();
assert_eq!(env["SHARED"], "resolved");
assert!(!env.contains_key("STALE"), "legacy env_vars re-merged");
}
/// ...but a desktop predating the `launch` block has nothing else to
/// offer, so the legacy field is the truth in exactly that case.
#[test]
fn legacy_env_vars_used_when_launch_absent() {
let agent = payload_json(serde_json::json!({"env_vars": {"API": "v"}}));
let env = build(&agent).unwrap();
assert_eq!(env["API"], "v");
}
#[test]
fn refuses_when_no_owner_resolves() {
let agent = payload_json(serde_json::json!({"auth_tag": null}));
let err = build(&agent).unwrap_err();
assert!(err.contains("!shutdown"), "unhelpful error: {err}");
}
/// An empty string is not an owner. Without this the refusal is
/// bypassable by a blank field and the pod launches unable to be stopped.
#[test]
fn empty_owner_fields_count_as_absent() {
for blank in ["", " "] {
let agent = payload_json(serde_json::json!({
"auth_tag": blank,
"launch": {"owner_pubkey": blank}
}));
assert!(
build(&agent).is_err(),
"whitespace resolved as an owner: {blank:?}"
);
}
}
/// The other half of every identity guard: what is *stored*. A validator
/// that trims and a writer that doesn't disagree about the value, and the
/// padding reaches the harness inside the Secret. Assert on the stored
/// string — asserting only that the deploy was accepted passes either way.
#[test]
fn identity_components_are_stored_trimmed() {
let agent = payload_json(serde_json::json!({
"relay_url": " wss://relay.example ",
"auth_tag": " tag-1 ",
"launch": {"owner_pubkey": " beefcafe "}
}));
let env = build(&agent).unwrap();
assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example");
assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1");
assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe");
}
/// L1 item 1's third identity component. The nsec arm is enforced in
/// `naming.rs` and the owner arm above; without this one an agent
/// deploys with nothing to connect to — a pod that starts, fails at the
/// relay, and reads as a network fault rather than a refused launch.
#[test]
fn refuses_empty_relay_url() {
for blank in ["", " "] {
let agent = payload_json(serde_json::json!({"relay_url": blank}));
let err = build(&agent).unwrap_err();
assert!(err.contains("relay_url"), "unhelpful error: {err}");
}
}
#[test]
fn owner_pubkey_alone_is_sufficient() {
let agent = payload_json(serde_json::json!({
"auth_tag": null,
"launch": {"owner_pubkey": "beefcafe"}
}));
let env = build(&agent).unwrap();
assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe");
assert!(!env.contains_key("BUZZ_AUTH_TAG"));
}
#[test]
fn refuses_presence_suppression() {
let agent = payload_json(serde_json::json!({
"launch": {"env": {"BUZZ_ACP_NO_PRESENCE": "1"}, "owner_pubkey": "beef"}
}));
let err = build(&agent).unwrap_err();
assert!(err.contains("BUZZ_ACP_NO_PRESENCE"), "got: {err}");
}
/// `foo.bar` is a legal Secret key but not a legal env name: pre-1.30
/// kubelets drop it, 1.30+ inject it. Refuse rather than behave
/// differently depending on the cluster.
#[test]
fn refuses_non_posix_env_keys() {
for bad in ["foo.bar", "foo-bar", "1LEADING", "", "has space"] {
let agent = payload_json(serde_json::json!({
"launch": {"env": {bad: "v"}, "owner_pubkey": "beef"}
}));
assert!(build(&agent).is_err(), "accepted non-POSIX key {bad:?}");
}
}
#[test]
fn args_are_comma_joined_and_omitted_when_empty() {
let agent = payload_json(serde_json::json!({
"launch": {"command": "goose", "args": ["run", "--no-session"], "owner_pubkey": "b"}
}));
let env = build(&agent).unwrap();
assert_eq!(env["BUZZ_ACP_AGENT_ARGS"], "run,--no-session");
let agent = payload_json(serde_json::json!({
"launch": {"command": "goose", "args": [], "owner_pubkey": "b"}
}));
assert!(!build(&agent).unwrap().contains_key("BUZZ_ACP_AGENT_ARGS"));
}
/// The top-level `model`/`provider` fields are display inputs; their
/// environment consequence is per-runtime and arrives already resolved
/// inside `launch`. A provider-side mapping is wrong for three of the
/// four built-in runtimes.
#[test]
fn provider_never_maps_model_or_provider_itself() {
let agent = payload_json(serde_json::json!({
"model": "claude-opus", "provider": "anthropic",
"launch": {"owner_pubkey": "beef"}
}));
let env = build(&agent).unwrap();
for key in [
"BUZZ_AGENT_PROVIDER",
"BUZZ_AGENT_MODEL",
"GOOSE_PROVIDER",
"GOOSE_MODEL",
] {
assert!(!env.contains_key(key), "provider mapped {key} itself");
}
}
/// `turn_timeout_seconds` is deprecated and ignored upstream; the local
/// spawn does not emit it either.
#[test]
fn turn_timeout_is_not_mapped() {
let agent = payload_json(serde_json::json!({
"turn_timeout_seconds": 30, "launch": {"owner_pubkey": "b"}
}));
let env = build(&agent).unwrap();
assert!(!env.keys().any(|k| k.contains("TURN_TIMEOUT")));
}
#[test]
fn inactivity_omitted_when_unset() {
let agent = payload_json(serde_json::json!({"launch": {"owner_pubkey": "b"}}));
let env = build_env(
&agent,
AuthoritativeInputs {
generation: "g",
inactivity_seconds: None,
},
)
.unwrap();
assert!(!env.contains_key("BUZZ_ACP_EXIT_AFTER_INACTIVITY"));
}
/// Structural guard over the whole authoritative list at once: whatever
/// the lower tiers contain, no authoritative key holds a lower-tier value
/// — including the conditionally-written ones the authoritative tier has
/// nothing to say about, which must be **absent** rather than spoofed.
/// This test caught exactly that: `BUZZ_ACP_AGENT_ARGS` is only written
/// when `launch.args` is non-empty, so plain later-wins overwrite left the
/// spoofed value in place.
#[test]
fn no_authoritative_key_retains_a_lower_tier_value() {
let spoofed: serde_json::Map<String, serde_json::Value> = AUTHORITATIVE_KEYS
.iter()
.map(|k| ((*k).to_string(), serde_json::json!("SPOOFED")))
.collect();
// Split across both lower tiers: policy_env and env are separate
// insertion points, and a fix that only cleared one would pass a
// single-tier test.
let agent = payload_json(serde_json::json!({
"launch": {
"command": "goose",
"policy_env": spoofed.clone(),
"env": spoofed,
"owner_pubkey": "beef"
}
}));
let env = build(&agent).unwrap();
for key in AUTHORITATIVE_KEYS {
assert_ne!(
env.get(*key).map(String::as_str),
Some("SPOOFED"),
"{key} kept its lower-tier value"
);
}
// The keys the authoritative tier had no value for are gone, not
// merely different.
for absent in [
"BUZZ_ACP_AGENT_ARGS",
"BUZZ_ACP_RESPOND_TO",
"BUZZ_ACP_RESPOND_TO_ALLOWLIST",
] {
assert!(!env.contains_key(absent), "{absent} survived the clear");
}
}
/// A 64-hex pubkey, the only allowlist entry shape the harness accepts.
fn pubkey(fill: char) -> String {
std::iter::repeat_n(fill, 64).collect()
}
/// The gate the harness refuses first (`config.rs:996-1004`). Refusing it
/// here is the difference between one error message and an unbounded
/// fail-replace loop that leaves a Secret per attempt.
#[test]
fn allowlist_mode_with_an_empty_list_is_refused() {
for empty in [serde_json::json!([]), serde_json::Value::Null] {
let agent = payload_json(serde_json::json!({
"respond_to": "allowlist",
"respond_to_allowlist": empty,
}));
let err = build(&agent).unwrap_err();
assert!(
err.contains("the allowlist is empty"),
"unexpected error: {err}"
);
}
}
/// `config.rs:629-641` — each entry must be exactly 64 hex characters.
/// The rejects are the distinct ways to miss that: too short, right length
/// but not hex, empty, and one character short of valid.
#[test]
fn an_allowlist_entry_that_is_not_64_hex_is_refused() {
for bad in ["abc1234", &"z".repeat(64), "", &pubkey('a')[..63]] {
let agent = payload_json(serde_json::json!({
"respond_to": "allowlist",
"respond_to_allowlist": [pubkey('a'), bad],
}));
let err = build(&agent).unwrap_err();
assert!(
err.contains("must be exactly 64 hex characters"),
"{bad:?} was accepted; error was: {err}"
);
}
}
/// The positive control: the guard refuses bad gates, not every gate.
/// Without this, a validator that refused unconditionally would pass both
/// tests above.
#[test]
fn a_valid_allowlist_gate_is_accepted_and_comma_joined() {
let agent = payload_json(serde_json::json!({
"respond_to": "allowlist",
"respond_to_allowlist": [pubkey('a'), pubkey('b')],
}));
let env = build(&agent).unwrap();
assert_eq!(env["BUZZ_ACP_RESPOND_TO"], "allowlist");
assert_eq!(
env["BUZZ_ACP_RESPOND_TO_ALLOWLIST"],
format!("{},{}", pubkey('a'), pubkey('b'))
);
}
/// The harness validates the allowlist **only** in allowlist mode and
/// merely warns otherwise (`config.rs:1005-1010`). A stricter provider
/// would refuse a deploy whose identical local spawn succeeds, so this
/// pins the asymmetry rather than leaving it to look like an oversight.
#[test]
fn a_junk_allowlist_is_tolerated_outside_allowlist_mode() {
for mode in ["owner-only", "anyone"] {
let agent = payload_json(serde_json::json!({
"respond_to": mode,
"respond_to_allowlist": ["not-a-pubkey"],
}));
let env = build(&agent)
.unwrap_or_else(|e| panic!("{mode} with a stale list must deploy: {e}"));
assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode);
}
}
/// `respond_to` is an opaque `String` on the wire but a `clap::ValueEnum`
/// at the harness, so an unrecognized mode dies at `rc=2` — before config
/// parsing runs at all, earlier than either refusal above. Measured
/// against the built binary: `invalid value 'npub1abc' for '--respond-to'`.
/// This is the shape our own fixture carried until it was corrected.
#[test]
fn a_mode_the_harness_cannot_parse_is_refused() {
for bad in ["npub1abc", "OWNER-ONLY", "owner_only", "allowlistt", "x"] {
let agent = payload_json(serde_json::json!({ "respond_to": bad }));
let err = build(&agent).unwrap_err();
assert!(
err.contains("is not a mode the harness accepts"),
"{bad:?} was accepted; error was: {err}"
);
}
}
/// `clap` does not trim its value-enum input, so a padded mode is `rc=2`
/// even though the same string trimmed is valid. Measured: `invalid value
/// ' allowlist ' for '--respond-to'`. Trimming here would accept a deploy
/// the harness refuses — the exact direction this guard exists to prevent.
#[test]
fn a_padded_mode_is_refused_because_clap_does_not_trim() {
for padded in [" allowlist ", "allowlist ", " owner-only", "\tnobody"] {
let agent = payload_json(serde_json::json!({
"respond_to": padded,
"respond_to_allowlist": [pubkey('a')],
}));
let err = build(&agent).unwrap_err();
assert!(
err.contains("is not a mode the harness accepts"),
"{padded:?} was accepted; error was: {err}"
);
}
}
/// Positive control for the mode check, and the reason it validates the
/// harness's four rather than the desktop's three: `nobody` is rejected by
/// `parse_wire` on purpose (`managed_agents/types.rs:871-880`) but starts
/// fine at the harness. A guard mirroring the desktop enum would refuse a
/// working launch from a non-desktop caller — the callers this guard is
/// for. Without this test, refusing `nobody` would pass everything above.
#[test]
fn every_mode_the_harness_accepts_is_deployable() {
for mode in ["owner-only", "allowlist", "anyone", "nobody"] {
let agent = payload_json(serde_json::json!({
"respond_to": mode,
"respond_to_allowlist": [pubkey('a')],
}));
let env = build(&agent)
.unwrap_or_else(|e| panic!("{mode} is valid at the harness but was refused: {e}"));
assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode);
}
}
}