diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 62ac20565e..74c1d9d132 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -278,13 +278,8 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_AGENT_MODE", default_value = "local")] pub agent_mode: AgentMode, - #[arg( - long, - env = "BUZZ_PRIVATE_KEY", - hide_env_values = true, - default_value = "" - )] - pub private_key: String, + #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] + pub private_key: Option, #[arg(long, env = "BUZZ_BROKER_URL")] pub broker_url: Option, @@ -920,10 +915,13 @@ impl Config { /// tests can construct `CliArgs` via `CliArgs::try_parse_from` and exercise the full /// validation path without going through process args. pub fn from_args(mut args: CliArgs) -> Result { + // Clap preserves an explicitly empty argument or environment variable as + // `Some("")`; normalize it to absence before applying mode-specific rules. + let mut private_key = args.private_key.take().filter(|key| !key.is_empty()); let broker = match args.agent_mode { AgentMode::Local => None, AgentMode::Broker => { - if !args.private_key.is_empty() { + if private_key.is_some() { return Err(ConfigError::ConfigFile( "broker mode is keyless — unset BUZZ_PRIVATE_KEY / --private-key".into(), )); @@ -984,7 +982,14 @@ impl Config { } }; let keys = match args.agent_mode { - AgentMode::Local => Keys::parse(&args.private_key)?, + AgentMode::Local => { + let private_key = private_key.as_deref().ok_or_else(|| { + ConfigError::ConfigFile( + "BUZZ_PRIVATE_KEY / --private-key is required in local mode".into(), + ) + })?; + Keys::parse(private_key)? + } // Never used as the agent identity. A placeholder keeps the local // runtime's concrete types intact while the broker path diverges // before relay setup; it is never exported to subprocesses. @@ -993,9 +998,10 @@ impl Config { // Best-effort zeroize: overwrite the raw private key string to reduce // exposure via core dumps or heap inspection (#41). Without the `zeroize` // crate we can only clear the String — the allocator may retain copies. - args.private_key - .replace_range(.., &"0".repeat(args.private_key.len())); - args.private_key.clear(); + if let Some(private_key) = private_key.as_mut() { + private_key.replace_range(.., &"0".repeat(private_key.len())); + private_key.clear(); + } let system_prompt = if let Some(text) = args.system_prompt { Some(text) @@ -1753,7 +1759,7 @@ mod tests { fn broker_mode_rejects_an_agent_private_key() { let key = "1".repeat(64); let mut args = broker_args(&[]); - args.private_key = key; + args.private_key = Some(key); let result = Config::from_args(args); assert!(result @@ -1762,6 +1768,16 @@ mod tests { .contains("keyless")); } + #[test] + fn local_mode_requires_a_private_key() { + let args = CliArgs::parse_from(["buzz-acp", "--agent-mode", "local"]); + + assert!(Config::from_args(args) + .expect_err("local mode must require a private key") + .to_string() + .contains("required in local mode")); + } + #[test] fn broker_mode_requires_explicit_channels() { const OWNER: &str = "a02c4e0850e5e612b4ddf95dbe2f5c56467cf27c6552203bc833ff438fb31971";