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
103 changes: 100 additions & 3 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,11 +448,31 @@ impl AcpClient {
/// `build_codex_config_env`. Pass `false` for test spawns and non-Codex agents.
///
/// After spawning, call [`initialize`](Self::initialize) before any other method.
#[cfg(test)]
pub async fn spawn(
command: &str,
args: &[String],
extra_env: &[(String, String)],
has_generated_codex_config: bool,
) -> Result<Self, AcpError> {
Self::spawn_with_identity(
command,
command,
args,
extra_env,
has_generated_codex_config,
)
.await
}

/// Spawn an agent executable while deriving runtime-specific defaults from
/// a separately supplied logical identity. Callers must authenticate it.
pub async fn spawn_with_identity(
command: &str,
agent_identity: &str,
args: &[String],
extra_env: &[(String, String)],
has_generated_codex_config: bool,
) -> Result<Self, AcpError> {
use std::process::Stdio;

Expand Down Expand Up @@ -494,7 +514,7 @@ impl AcpClient {
// Applied first so both persona `extra_env` (below, via `Command::env`
// key replacement) and inherited parent env (via the parent-presence
// check) override them.
for &(key, value) in crate::config::default_agent_env(command) {
for &(key, value) in crate::config::default_agent_env(agent_identity) {
if std::env::var_os(key).is_none() {
cmd.env(key, value);
}
Expand Down Expand Up @@ -2903,8 +2923,9 @@ mod tests {
/// `hermes-acp`) and return the value of `var` as the child observed it.
/// `<unset>` means the child did not receive the var.
#[cfg(unix)]
async fn spawn_named_and_read_child_env(
async fn spawn_named_with_identity_and_read_child_env(
file_name: &str,
runtime_identity: &str,
var: &str,
extra_env: &[(String, String)],
) -> String {
Expand All @@ -2913,6 +2934,9 @@ mod tests {
let dir = std::env::temp_dir().join(format!("buzz-acp-env-probe-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("create env probe dir");
let path = dir.join(file_name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create nested env probe dir");
}
std::fs::write(
&path,
format!("#!/bin/sh\nprintf '%s\\n' \"${{{var}:-<unset>}}\"\n"),
Expand All @@ -2922,8 +2946,9 @@ mod tests {
permissions.set_mode(0o700);
std::fs::set_permissions(&path, permissions).expect("chmod probe");

let mut client = AcpClient::spawn(
let mut client = AcpClient::spawn_with_identity(
path.to_str().expect("probe path is UTF-8"),
runtime_identity,
&[],
extra_env,
false,
Expand All @@ -2941,6 +2966,67 @@ mod tests {
observed
}

#[cfg(unix)]
async fn spawn_named_and_read_child_env(
file_name: &str,
var: &str,
extra_env: &[(String, String)],
) -> String {
spawn_named_with_identity_and_read_child_env(file_name, file_name, var, extra_env).await
}

#[cfg(unix)]
#[tokio::test]
async fn spawn_uses_production_codex_identity_for_generic_verified_executable() {
use std::os::unix::fs::PermissionsExt;

let dir = std::env::temp_dir().join(format!(
"buzz-acp-codex-identity-probe-{}",
uuid::Uuid::new_v4()
));
let dist = dir.join("codex-acp/dist");
std::fs::create_dir_all(&dist).expect("create Codex probe dist directory");
let executable = dist.join("index.js");
std::fs::write(
&executable,
"#!/bin/sh\ncase \"${CODEX_CONFIG:-}\" in *'\"network_access\":true'*) printf 'true\\n' ;; *) printf 'missing\\n' ;; esac\n",
)
.expect("write Codex identity probe");
let mut permissions = std::fs::metadata(&executable)
.expect("stat Codex identity probe")
.permissions();
permissions.set_mode(0o700);
std::fs::set_permissions(&executable, permissions).expect("chmod Codex identity probe");

let runtime_identity = "codex";
let network_env =
crate::config::codex_network_env(runtime_identity, "wss://relay.example.com")
.into_iter()
.collect::<Vec<_>>();
let mut client = AcpClient::spawn_with_identity(
executable.to_str().expect("probe path is UTF-8"),
runtime_identity,
&[],
&network_env,
true,
)
.await
.expect("spawn Codex identity probe");
let observed = client
.reader
.next()
.await
.expect("Codex identity probe produced output")
.expect("Codex identity probe stdout was readable");
client.shutdown().await;
std::fs::remove_dir_all(&dir).expect("remove Codex identity probe directory");

assert_eq!(
observed, "true",
"logical Codex identity must enable network access even when the verified executable basename is index.js"
);
}

/// Buzz-owned Hermes processes get the configured-MCP isolation default,
/// and an explicit persona entry still overrides it (defaults are applied
/// before `extra_env`, so the later `Command::env` write wins).
Expand All @@ -2959,6 +3045,17 @@ mod tests {
"1",
"Hermes spawns must default {VAR}=1"
);
assert_eq!(
spawn_named_with_identity_and_read_child_env(
"hermes-acp/dist/index.js",
"hermes-acp",
VAR,
&[],
)
.await,
"1",
"logical Hermes identity must survive executable canonicalization"
);
assert_eq!(
spawn_named_and_read_child_env("hermes-acp", VAR, &[(VAR.into(), "0".into())]).await,
"0",
Expand Down
53 changes: 51 additions & 2 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ pub struct AuthAgentArgs {
#[arg(long, env = "BUZZ_ACP_AGENT_COMMAND", default_value = "goose")]
pub agent_command: String,

/// Trusted logical runtime identity when the executable has a generic name.
#[arg(long, env = "BUZZ_ACP_AGENT_IDENTITY", hide = true)]
pub agent_identity: Option<String>,

/// Arguments passed to the agent binary.
#[arg(
long,
Expand Down Expand Up @@ -250,6 +254,10 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_AGENT_COMMAND", default_value = "goose")]
pub agent_command: String,

/// Trusted logical runtime identity when the executable has a generic name.
#[arg(long, env = "BUZZ_ACP_AGENT_IDENTITY", hide = true)]
pub agent_identity: Option<String>,

#[arg(
long,
env = "BUZZ_ACP_AGENT_ARGS",
Expand Down Expand Up @@ -498,6 +506,7 @@ pub struct Config {
pub keys: Keys,
pub relay_url: String,
pub agent_command: String,
pub agent_identity: String,
pub agent_args: Vec<String>,
pub mcp_command: String,
pub idle_timeout_secs: u64,
Expand Down Expand Up @@ -912,7 +921,14 @@ impl Config {
));
}

let agent_args = normalize_agent_args(&agent_command, args.agent_args);
let agent_identity = args.agent_identity.unwrap_or_else(|| agent_command.clone());
if agent_identity.trim().is_empty() {
return Err(ConfigError::ConfigFile(
"agent_identity must not be empty".into(),
));
}

let agent_args = normalize_agent_args(&agent_identity, args.agent_args);

if let Some(ref channels) = args.channels {
for ch in channels {
Expand Down Expand Up @@ -1051,7 +1067,7 @@ impl Config {
// opens the Seatbelt network sandbox for buzz-cli (an MCP subprocess). No-op
// for non-Codex agents or unparseable relay URLs.
let has_generated_codex_config =
if let Some(network_env) = codex_network_env(&agent_command, &args.relay_url) {
if let Some(network_env) = codex_network_env(&agent_identity, &args.relay_url) {
persona_env_vars.push(network_env);
true
} else {
Expand All @@ -1064,6 +1080,7 @@ impl Config {
keys,
relay_url: args.relay_url,
agent_command,
agent_identity,
agent_args,
mcp_command: args.mcp_command,
idle_timeout_secs,
Expand Down Expand Up @@ -1443,6 +1460,7 @@ mod tests {
keys: nostr::Keys::generate(),
relay_url: "ws://localhost:3000".into(),
agent_command: "goose".into(),
agent_identity: "goose".into(),
agent_args: vec!["acp".into()],
mcp_command: "".into(),
idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS,
Expand Down Expand Up @@ -2742,6 +2760,37 @@ channels = "ALL"
const TEST_PRIVATE_KEY: &str =
"0000000000000000000000000000000000000000000000000000000000000001";

#[test]
fn production_codex_identity_controls_config_for_generic_executable() {
let args = CliArgs::try_parse_from([
"buzz-acp",
"--private-key",
TEST_PRIVATE_KEY,
"--relay-url",
"wss://relay.example.com",
"--agent-command",
"/opt/codex-acp/dist/index.js",
"--agent-identity",
"codex",
])
.expect("clap should parse separate command and identity");
let config = Config::from_args(args).expect("logical Codex identity should configure");

assert_eq!(config.agent_identity, "codex");
assert!(config.has_generated_codex_config);
let network_access = config
.persona_env_vars
.iter()
.find(|(key, _)| key == "CODEX_CONFIG")
.and_then(|(_, value)| serde_json::from_str::<serde_json::Value>(value).ok())
.and_then(|value| {
value
.pointer("/sandbox_workspace_write/network_access")
.and_then(serde_json::Value::as_bool)
});
assert_eq!(network_access, Some(true));
}

#[test]
fn allowed_respond_to_full_path_rejects_disallowed_mode() {
// --allowed-respond-to=owner-only,allowlist + --respond-to=anyone → ConfigError
Expand Down
Loading