diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8460372abab..8b7cf8062ff 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -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::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 { use std::process::Stdio; @@ -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); } @@ -2903,8 +2923,9 @@ mod tests { /// `hermes-acp`) and return the value of `var` as the child observed it. /// `` 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 { @@ -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}:-}}\"\n"), @@ -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, @@ -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::>(); + 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). @@ -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", diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 35aaec188db..e47f125f84d 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -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, + /// Arguments passed to the agent binary. #[arg( long, @@ -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, + #[arg( long, env = "BUZZ_ACP_AGENT_ARGS", @@ -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, pub mcp_command: String, pub idle_timeout_secs: u64, @@ -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 { @@ -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 { @@ -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, @@ -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, @@ -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::(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 diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index fa348eeb3cf..35bc2efec8c 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1841,7 +1841,7 @@ async fn tokio_main() -> Result<()> { .as_deref() .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), memory_enabled: config.memory_enabled, - harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), + harness_name: crate::config::normalize_agent_command_identity(&config.agent_identity), relay_url: config.relay_url.clone(), }); @@ -2056,13 +2056,16 @@ async fn tokio_main() -> Result<()> { slot.respawn_in_flight = true; tracing::info!(agent = idx, "slot refill: spawning background respawn"); let cmd = config.agent_command.clone(); + let identity = config.agent_identity.clone(); let args = config.agent_args.clone(); let env = config.persona_env_vars.clone(); let has_codex = config.has_generated_codex_config; let observer = observer.clone(); let guard = RespawnGuard::new(idx, respawn_tx.clone()); respawn_tasks.spawn(async move { - let result = spawn_and_init(&cmd, &args, &env, has_codex, idx, observer).await; + let result = + spawn_and_init(&cmd, &identity, &args, &env, has_codex, idx, observer) + .await; guard.send(result); }); } @@ -3870,6 +3873,7 @@ fn recover_panicked_agent( // Spawn respawn work off the main loop. slot.respawn_in_flight = true; let cmd = config.agent_command.clone(); + let identity = config.agent_identity.clone(); let args = config.agent_args.clone(); let env = config.persona_env_vars.clone(); let has_codex = config.has_generated_codex_config; @@ -3878,7 +3882,7 @@ fn recover_panicked_agent( if !delay.is_zero() { tokio::time::sleep(delay).await; } - let result = spawn_and_init(&cmd, &args, &env, has_codex, i, observer).await; + let result = spawn_and_init(&cmd, &identity, &args, &env, has_codex, i, observer).await; guard.send(result); }); } @@ -4065,6 +4069,7 @@ fn spawn_respawn_task( // Spawn the actual work (shutdown + sleep + spawn + init) off the main loop. let cmd = config.agent_command.clone(); + let identity = config.agent_identity.clone(); let args = config.agent_args.clone(); let env = config.persona_env_vars.clone(); let has_codex = config.has_generated_codex_config; @@ -4079,7 +4084,7 @@ fn spawn_respawn_task( tokio::time::sleep(delay).await; } - let result = spawn_and_init(&cmd, &args, &env, has_codex, index, observer).await; + let result = spawn_and_init(&cmd, &identity, &args, &env, has_codex, index, observer).await; guard.send(result); }); @@ -4120,6 +4125,7 @@ async fn shutdown_agent_pool(pool: &mut AgentPool) { struct PoolStartup { agents: u32, command: String, + identity: String, args: Vec, extra_env: Vec<(String, String)>, has_generated_codex_config: bool, @@ -4132,6 +4138,7 @@ impl PoolStartup { Self { agents: config.agents, command: config.agent_command.clone(), + identity: config.agent_identity.clone(), args: config.agent_args.clone(), extra_env: config.persona_env_vars.clone(), has_generated_codex_config: config.has_generated_codex_config, @@ -4149,8 +4156,9 @@ async fn initialize_agent_pool( // Attempt each spawn under a 60-second timeout; a partial pool is valid. let mut agent_slots: Vec> = Vec::with_capacity(startup.agents as usize); for i in 0..startup.agents as usize { - let spawn_result = AcpClient::spawn( + let spawn_result = AcpClient::spawn_with_identity( &startup.command, + &startup.identity, &startup.args, &startup.extra_env, startup.has_generated_codex_config, @@ -4251,15 +4259,22 @@ async fn initialize_agent_pool( /// borrowing `Config`. All respawn/refill paths use this. async fn spawn_and_init( command: &str, + identity: &str, args: &[String], extra_env: &[(String, String)], has_generated_codex_config: bool, agent_index: usize, observer: Option, ) -> Result<(AcpClient, u32, String)> { - let mut acp = AcpClient::spawn(command, args, extra_env, has_generated_codex_config) - .await - .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?; + let mut acp = AcpClient::spawn_with_identity( + command, + identity, + args, + extra_env, + has_generated_codex_config, + ) + .await + .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?; acp.set_observer(observer, agent_index); match acp.initialize().await { @@ -4287,8 +4302,12 @@ async fn spawn_and_init( } async fn spawn_auth_client(agent: &AuthAgentArgs) -> Result { - let agent_args = config::normalize_agent_args(&agent.agent_command, agent.agent_args.clone()); - AcpClient::spawn(&agent.agent_command, &agent_args, &[], false).await + let identity = agent + .agent_identity + .as_deref() + .unwrap_or(&agent.agent_command); + let agent_args = config::normalize_agent_args(identity, agent.agent_args.clone()); + AcpClient::spawn_with_identity(&agent.agent_command, identity, &agent_args, &[], false).await } fn extract_auth_methods(init_result: &serde_json::Value) -> Vec { @@ -4409,7 +4428,12 @@ async fn run_authenticate(args: AuthenticateArgs) -> Result<()> { async fn run_models(args: ModelsArgs) -> Result<()> { use acp::{extract_model_config_options, extract_model_state}; - let agent_args = config::normalize_agent_args(&args.agent.agent_command, args.agent.agent_args); + let identity = args + .agent + .agent_identity + .as_deref() + .unwrap_or(&args.agent.agent_command); + let agent_args = config::normalize_agent_args(identity, args.agent.agent_args); let cwd = std::env::current_dir() .unwrap_or_else(|_| std::path::PathBuf::from("/")) .to_string_lossy() @@ -4417,14 +4441,21 @@ async fn run_models(args: ModelsArgs) -> Result<()> { // Spawn outside the timeout so we always own the child for cleanup. // `models` subcommand doesn't use persona packs — no extra env, no codex config. - let mut client = - match AcpClient::spawn(&args.agent.agent_command, &agent_args, &[], false).await { - Ok(c) => c, - Err(e) => { - eprintln!("error: failed to spawn agent: {e}"); - std::process::exit(1); - } - }; + let mut client = match AcpClient::spawn_with_identity( + &args.agent.agent_command, + identity, + &agent_args, + &[], + false, + ) + .await + { + Ok(c) => c, + Err(e) => { + eprintln!("error: failed to spawn agent: {e}"); + std::process::exit(1); + } + }; // Initialize + session/new under a timeout. Client is owned above, // so shutdown() runs on all paths (success, error, timeout). @@ -6214,6 +6245,7 @@ mod build_mcp_servers_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: "test-mcp-server".into(), idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, @@ -6436,6 +6468,7 @@ mod error_outcome_emission_tests { // harmlessly off the JoinSet — irrelevant to the synchronous // feed emission under test. agent_command: "true".into(), + agent_identity: "true".into(), agent_args: vec![], mcp_command: "test-mcp-server".into(), idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, diff --git a/desktop/src-tauri/src/commands/agent_auth.rs b/desktop/src-tauri/src/commands/agent_auth.rs index be0ea44c31b..4daf0065938 100644 --- a/desktop/src-tauri/src/commands/agent_auth.rs +++ b/desktop/src-tauri/src/commands/agent_auth.rs @@ -209,6 +209,7 @@ fn run_buzz_acp_auth_command_with_paths( let mut command = Command::new(acp_path); command .args(args) + .env_remove(crate::managed_agents::runtime_plan::AGENT_IDENTITY_ENV) .env("BUZZ_ACP_AGENT_COMMAND", adapter_path.as_os_str()) .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")) .stdout(Stdio::piped()) diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index 4c1a8f2313a..259840fb397 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -45,6 +45,7 @@ pub(super) async fn run_agent_models_command( } cmd.arg("models") .arg("--json") + .env_remove(crate::managed_agents::runtime_plan::AGENT_IDENTITY_ENV) .env("BUZZ_ACP_AGENT_COMMAND", &agent_command) .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); if let Some(meta) = known_acp_runtime(&agent_command) { diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index 34cdfede2c2..87981d852f4 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -177,6 +177,7 @@ fn reserved_keys_include_code_execution_surface() { // Overriding lets the user run arbitrary code as the agent. for key in [ "BUZZ_ACP_AGENT_COMMAND", + "BUZZ_ACP_AGENT_IDENTITY", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", ] { diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index b4fddd37384..97fe562cdf2 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -39,6 +39,8 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // Code-execution surface: overriding would let the user run arbitrary // binaries/args as the agent process. "BUZZ_ACP_AGENT_COMMAND", + // Logical builtin identity is projected only by an immutable runtime plan. + "BUZZ_ACP_AGENT_IDENTITY", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", // Provider executable selection belongs exclusively to an immutable diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 3c291c635f8..88cb7445ee0 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -538,6 +538,7 @@ pub fn spawn_agent_child( command.env("BUZZ_RELAY_URL", &effective_relay_url); command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); + command.env_remove(super::runtime_plan::AGENT_IDENTITY_ENV); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); match &resolved_mcp_command { Some(mcp_cmd) => { diff --git a/desktop/src-tauri/src/managed_agents/runtime_plan.rs b/desktop/src-tauri/src/managed_agents/runtime_plan.rs index 71311bd8cf5..c8147cdfb4f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_plan.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_plan.rs @@ -18,6 +18,9 @@ use std::{ use super::{known_acp_runtime, resolve_command}; +/// Logical runtime identity trusted only when projected by a verified plan. +pub(crate) const AGENT_IDENTITY_ENV: &str = "BUZZ_ACP_AGENT_IDENTITY"; + /// Environment variables that may redirect a known provider executable. pub(crate) const DENIED_EXECUTABLE_ENV: &[&str] = &[ "CLAUDE_CODE_EXECUTABLE", @@ -173,6 +176,7 @@ impl RuntimeExecutionPlan { for (key, value) in &self.generated_env { command.env(key, value); } + command.env(AGENT_IDENTITY_ENV, &self.provider_family); } } @@ -644,7 +648,8 @@ fn plan_identity( mod tests { use super::{ collect_package_files, component_identity, package_inventory, plan_identity, - RuntimeComponentRole, RuntimeExecutionPlan, RuntimePlanSource, DENIED_EXECUTABLE_ENV, + resolve_runtime_execution_plan, RuntimeComponentRole, RuntimeExecutionPlan, + RuntimePlanSource, AGENT_IDENTITY_ENV, DENIED_EXECUTABLE_ENV, }; use std::{collections::BTreeMap, fs}; @@ -714,6 +719,52 @@ mod tests { assert!(plan.verify().is_err()); } + #[cfg(unix)] + #[test] + fn resolved_codex_plan_projects_catalog_identity_with_canonical_executable() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let dir = tempfile::tempdir().expect("temp dir"); + let package = dir.path().join("codex-acp"); + let dist = package.join("dist"); + let bin = dir.path().join("bin"); + fs::create_dir_all(&dist).expect("create package dist directory"); + fs::create_dir_all(&bin).expect("create launcher directory"); + fs::write(package.join("package.json"), b"{}").expect("write package manifest"); + let executable = dist.join("index.js"); + fs::write(&executable, b"#!/usr/bin/env node\n").expect("write generic executable"); + let mut permissions = fs::metadata(&executable) + .expect("stat generic executable") + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&executable, permissions).expect("chmod generic executable"); + let launcher = bin.join("codex-acp"); + symlink(&executable, &launcher).expect("link logical Codex launcher"); + + let plan = resolve_runtime_execution_plan(launcher.to_str().expect("UTF-8 launcher")) + .expect("resolve Codex runtime plan") + .expect("Codex has a verified runtime plan"); + let canonical_executable = executable.canonicalize().expect("canonical executable"); + assert_eq!(plan.provider_family, "codex"); + assert_eq!( + plan.harness_path().expect("planned harness"), + canonical_executable + ); + + let mut command = std::process::Command::new("buzz-acp"); + command + .env("BUZZ_ACP_AGENT_COMMAND", &canonical_executable) + .env(AGENT_IDENTITY_ENV, "forged-runtime"); + plan.apply_environment(&mut command); + + assert!(command.get_envs().any(|(key, value)| { + key == AGENT_IDENTITY_ENV && value == Some(std::ffi::OsStr::new("codex")) + })); + assert!(command.get_envs().any(|(key, value)| { + key == "BUZZ_ACP_AGENT_COMMAND" && value == Some(canonical_executable.as_os_str()) + })); + } + #[test] fn package_inventory_includes_payload_and_skips_launcher_aliases() { let dir = tempfile::tempdir().expect("temp dir");