From 994ace62ea4042b03dfc8e73e45aba94da866a0f Mon Sep 17 00:00:00 2001 From: Tee8z Date: Thu, 5 Nov 2026 11:01:25 -0500 Subject: [PATCH] fix(agents): let tools the agent runs reach its model credentials buzz-agent-review failed for every agent whose profile uses a Claude subscription or a Codex access token: both runtimes reported "not logged in". The image's contract is that model credentials stay env-only and that tools the agent runs inherit them, but the agent's tool shell (the MCP server) starts from a cleared environment that never included them. buzz-acp now forwards the variables named in BUZZ_ACP_MCP_FORWARD_ENV into the MCP server. Unset forwards nothing, so the default is unchanged; invalid names and the harness's own variables are ignored. The Voltage bootstrap sets it to exactly the model credentials it configured. Nothing is written to disk: the profile test's no-token-in-files check still holds and now also checks the forwarded list. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tee8z --- crates/buzz-acp/README.md | 1 + crates/buzz-acp/src/lib.rs | 92 +++++++++++++++++++++++ voltage/images/buzz-agent/README.md | 2 +- voltage/images/buzz-agent/profile.py | 5 ++ voltage/images/buzz-agent/test-profile.py | 10 ++- 5 files changed, 108 insertions(+), 2 deletions(-) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 853117ea895..99f4076d7a4 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -116,6 +116,7 @@ All configuration is via environment variables (or CLI flags — every env var h | `BUZZ_ACP_AGENT_COMMAND` | no | `goose` | Agent binary to spawn. | | `BUZZ_ACP_AGENT_ARGS` | no | `acp` | Agent arguments (comma-separated). | | `BUZZ_ACP_MCP_COMMAND` | no | `""` (empty) | Path to an optional MCP server binary to provide to the agent subprocess. | +| `BUZZ_ACP_MCP_FORWARD_ENV` | no | unset | Comma-separated harness variables to forward into the MCP server, which otherwise starts from a cleared environment. Invalid names and the harness's own variables are ignored. | | `BUZZ_ACP_IDLE_TIMEOUT` | no | `620` | Idle timeout: max seconds of silence before cancelling a turn. Resets on any agent stdout activity. | | `BUZZ_ACP_MAX_TURN_DURATION` | no | `7200` | Absolute wall-clock cap per turn (safety valve). | | `BUZZ_API_TOKEN` | no | — | API token (required if relay enforces token auth). | diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 1aab0e25ea3..368e72e7f00 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -6673,6 +6673,43 @@ async fn run_models(args: ModelsArgs) -> Result<()> { Ok(()) } +/// Operator-listed variables the agent's tool shell inherits from the harness. +/// +/// MCP children start from a cleared environment. `BUZZ_ACP_MCP_FORWARD_ENV` +/// names, comma-separated, the harness variables to pass through, such as an +/// image's model credentials so tools the agent runs can reach its runtime. +/// Unset forwards nothing. Invalid names, and names in `reserved`, which the +/// harness sets itself, are ignored; unset or empty values are skipped. +fn forwarded_mcp_env( + list: Option<&str>, + reserved: &[EnvVar], + lookup: impl Fn(&str) -> Option, +) -> Vec { + let mut forwarded: Vec = Vec::new(); + for name in list.unwrap_or("").split(',').map(str::trim) { + let valid = !name.is_empty() + && name + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_'); + if !valid + || git::is_managed_env(name) + || reserved + .iter() + .chain(&forwarded) + .any(|entry| entry.name == name) + { + continue; + } + if let Some(value) = lookup(name).filter(|value| !value.is_empty()) { + forwarded.push(EnvVar { + name: name.to_string(), + value, + }); + } + } + forwarded +} + fn build_mcp_servers(config: &Config) -> Vec { if config.mcp_command.is_empty() { return vec![]; @@ -6736,6 +6773,12 @@ fn build_mcp_servers(config: &Config) -> Vec { }); } } + let forwarded = forwarded_mcp_env( + std::env::var("BUZZ_ACP_MCP_FORWARD_ENV").ok().as_deref(), + &env, + |name| std::env::var(name).ok(), + ); + env.extend(forwarded); env }, }] @@ -10859,6 +10902,55 @@ mod build_mcp_servers_tests { assert!(!std::path::Path::new(&keyfile).exists()); } + #[test] + fn listed_harness_variables_reach_the_tool_shell_and_nothing_else() { + let _guard = ENV_LOCK.lock().unwrap(); + let config = test_config(); + let names = [ + "BUZZ_ACP_MCP_FORWARD_ENV", + "BUZZ_TEST_FORWARD_CLAUDE", + "BUZZ_TEST_FORWARD_CODEX", + "BUZZ_TEST_FORWARD_UNLISTED", + ]; + let saved: Vec<_> = names.iter().map(|n| (*n, std::env::var(n).ok())).collect(); + let shell = |list: Option<&str>| { + match list { + Some(list) => std::env::set_var("BUZZ_ACP_MCP_FORWARD_ENV", list), + None => std::env::remove_var("BUZZ_ACP_MCP_FORWARD_ENV"), + } + build_mcp_servers(&config).remove(0).env + }; + std::env::set_var("BUZZ_TEST_FORWARD_CLAUDE", "synthetic-claude"); + std::env::set_var("BUZZ_TEST_FORWARD_CODEX", "synthetic-codex"); + std::env::set_var("BUZZ_TEST_FORWARD_UNLISTED", "synthetic-unlisted"); + let has = |env: &[EnvVar], name: &str| env.iter().any(|entry| entry.name == name); + // Default: the shell stays credential-free. + let env = shell(None); + assert!(!has(&env, "BUZZ_TEST_FORWARD_CLAUDE") && !has(&env, "BUZZ_TEST_FORWARD_CODEX")); + // Listed names pass through; unlisted, unset, invalid and the + // harness's own variables do not. + let env = shell(Some( + " BUZZ_TEST_FORWARD_CLAUDE,BUZZ_TEST_FORWARD_CODEX,BUZZ_TEST_FORWARD_UNSET,bad-name,BUZZ_PRIVATE_KEY,BUZZ_TEST_FORWARD_CODEX", + )); + let value = |name: &str| { + env.iter() + .filter(|entry| entry.name == name) + .map(|entry| entry.value.as_str()) + .collect::>() + }; + assert_eq!(value("BUZZ_TEST_FORWARD_CLAUDE"), ["synthetic-claude"]); + assert_eq!(value("BUZZ_TEST_FORWARD_CODEX"), ["synthetic-codex"]); + assert!(!has(&env, "BUZZ_TEST_FORWARD_UNLISTED") && !has(&env, "BUZZ_TEST_FORWARD_UNSET")); + assert_eq!(value("BUZZ_PRIVATE_KEY").len(), 1, "never overridden"); + assert_ne!(value("BUZZ_PRIVATE_KEY"), ["synthetic-unlisted"]); + for (name, value) in saved { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + #[test] fn session_new_mcp_server_has_required_fields() { let config = test_config(); diff --git a/voltage/images/buzz-agent/README.md b/voltage/images/buzz-agent/README.md index bb6ca2bd563..35c75870e09 100644 --- a/voltage/images/buzz-agent/README.md +++ b/voltage/images/buzz-agent/README.md @@ -161,7 +161,7 @@ A profile can contain `openai_api_key` or `codex_access_token`, not both: Codex A profile can contain `anthropic_api_key` or `claude_oauth_token`, not both. Claude Code prefers an API key, so mixing them would bill the API silently. `openrouter_api_key` serves the Buzz agent runtime (`buzz-agent`), which can run any OpenRouter model: set the agent's model to an OpenRouter model ID such as `qwen/qwen3-coder`. The bootstrap refuses an inherited `OPENAI_API_KEY`, `CODEX_API_KEY`, `CODEX_ACCESS_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, or `OPENROUTER_API_KEY`. -Model credentials stay in the harness environment and are never written to workspace files. Tools that the agent runs inherit them. +Model credentials stay in process environments and are never written to workspace files. Tools that the agent runs inherit them: the agent's tool shell starts from a cleared environment, so the bootstrap lists them in `BUZZ_ACP_MCP_FORWARD_ENV`, which `buzz-acp` forwards into that shell. `buzz-agent-review` uses them to run its reviewer. The checkpoint secret scan also refuses Claude subscription tokens. ### Commit signing diff --git a/voltage/images/buzz-agent/profile.py b/voltage/images/buzz-agent/profile.py index cd1b433ff60..7c4d634f611 100644 --- a/voltage/images/buzz-agent/profile.py +++ b/voltage/images/buzz-agent/profile.py @@ -277,6 +277,11 @@ def bootstrap(): env.setdefault('BUZZ_AGENT_PROVIDER', 'openrouter') if 'claude_oauth_token' in profile['models']: env['CLAUDE_CODE_OAUTH_TOKEN'] = profile['models']['claude_oauth_token'] + # The agent's tool shell starts from a cleared environment. Forward the model + # credentials so tools it runs, such as buzz-agent-review, reach the runtime; + # they stay in process environments and are never written to files. + env['BUZZ_ACP_MCP_FORWARD_ENV'] = ','.join( + name for name in (*INHERITED_MODEL_VARIABLES, 'CODEX_HOME') if env.get(name)) step('configuring Git commit signing') command('/usr/local/bin/buzz-agent-account', 'init', env=env) return env diff --git a/voltage/images/buzz-agent/test-profile.py b/voltage/images/buzz-agent/test-profile.py index d00435f9710..1ecab3423f3 100644 --- a/voltage/images/buzz-agent/test-profile.py +++ b/voltage/images/buzz-agent/test-profile.py @@ -98,6 +98,9 @@ def rotate(models, git=None): if mode == 'symlink': Path('/home/agent/.config').symlink_to('/tmp') if mode == 'missing': env['BUZZ_DEVELOPER_PROFILE_ARN'] = env['BUZZ_DEVELOPER_PROFILE_ARN'].replace('/profile-', '/profile-missing-') +INHERITED_NAMES = {'OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_ACCESS_TOKEN', 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'OPENROUTER_API_KEY', + 'CODEX_REFRESH_TOKEN_URL_OVERRIDE', 'CODEX_CA_CERTIFICATE'} probe = Path('/tmp/profile-adapter') probe.write_text(r"""#!/usr/bin/env python3 import json, os, subprocess, sys @@ -106,6 +109,11 @@ def rotate(models, git=None): if home.joinpath('github-token').read_text() != 'synthetic-profile-github': raise RuntimeError('GitHub token missing') for key, expected in EXPECTED.items(): if os.environ.get(key) != expected: raise RuntimeError('runtime environment differs from profile') +# Exactly the configured model credentials reach the agent's tool shell. +forwarded = set(filter(None, os.environ.get('BUZZ_ACP_MCP_FORWARD_ENV', '').split(','))) +models = {k for k, v in EXPECTED.items() if v and k in INHERITED} +if not models <= forwarded or forwarded - models - {'CODEX_HOME'}: + raise RuntimeError('forwarded tool-shell variables differ from the profile credentials') if subprocess.check_output(['git','config','--global','user.email'], text=True).strip() != 'alice@example.invalid': raise RuntimeError('Git identity missing') Path('/tmp/profile-adapter.ran').touch() @@ -113,7 +121,7 @@ def rotate(models, git=None): value = json.loads(line) if value.get('method') == 'initialize': print(json.dumps({'jsonrpc':'2.0','id':value['id'],'result':{'protocolVersion':2,'agentCapabilities':{},'authMethods':[]}}), flush=True) -""".replace('EXPECTED', repr(expected))) +""".replace('EXPECTED', repr(expected)).replace('INHERITED', repr(INHERITED_NAMES))) probe.chmod(0o700) result = subprocess.run(['sprig-entrypoint', 'auth-methods', '--agent-command', str(probe), '--agent-args', '', '--json'], env=env, capture_output=True, text=True, timeout=45)