Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
92 changes: 92 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
) -> Vec<EnvVar> {
let mut forwarded: Vec<EnvVar> = 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<McpServer> {
if config.mcp_command.is_empty() {
return vec![];
Expand Down Expand Up @@ -6736,6 +6773,12 @@ fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
});
}
}
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
},
}]
Expand Down Expand Up @@ -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::<Vec<_>>()
};
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();
Expand Down
2 changes: 1 addition & 1 deletion voltage/images/buzz-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions voltage/images/buzz-agent/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion voltage/images/buzz-agent/test-profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -106,14 +109,19 @@ 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()
for line in sys.stdin:
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)
Expand Down
Loading