Skip to content
82 changes: 77 additions & 5 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -661,20 +661,26 @@ impl AcpClient {
extra_env: &[(String, String)],
has_generated_codex_config: bool,
) -> Result<Self, AcpError> {
Self::spawn_with_env(command, args, extra_env, has_generated_codex_config, &[]).await
let none = Ok(Vec::new());
Self::spawn_with_env(command, args, extra_env, has_generated_codex_config, &none).await
}

/// Spawn with authoritative launch environment overrides. Unlike persona
/// defaults, these values take precedence over the inherited environment.
/// An `Err` is a pool with no login left to start on: nothing is spawned.
pub(crate) async fn spawn_with_env(
command: &str,
args: &[String],
extra_env: &[(String, String)],
has_generated_codex_config: bool,
launch_env: &[(String, String)],
launch_env: &Result<Vec<(String, String)>, String>,
) -> Result<Self, AcpError> {
use std::process::Stdio;

let launch_env = launch_env
.as_ref()
.map_err(|why| AcpError::Protocol(format!("no login to start on: {why}")))?;

// A launch prefix may change the CLI or environment the adapter sees,
// so the version check below could inspect the wrong binary.
let launch_prefixed = std::env::var_os(launch::PREFIX_ENV).is_some();
Expand Down Expand Up @@ -756,6 +762,8 @@ impl AcpClient {
// over the harness-owned agent identity in native shells.
cmd.env_remove("GIT_CONFIG_PARAMETERS");
cmd.env_remove("NOSTR_PRIVATE_KEY");
// It says where the primary's token file is: not for the agent's shell.
cmd.env_remove(crate::factory::ACCOUNT_POOL_ENV);
if extra_env.iter().any(|(name, _)| name == "GIT_CONFIG_COUNT") {
// Native shells inherit these overrides, while buzz-agent clears
// them for MCP. Let both paths use the harness's agent identity.
Expand Down Expand Up @@ -787,7 +795,7 @@ impl AcpClient {
"codex" | "codex-acp" => Some(StandardAdapterKind::Codex),
_ => None,
};
cmd.envs(launch_env.iter().cloned());
crate::factory::apply_launch_env(&mut cmd, launch_env);
cmd.env_remove(launch::PREFIX_ENV);
let claude_thinking_summaries = standard_adapter == Some(StandardAdapterKind::Claude)
&& !launch_prefixed
Expand Down Expand Up @@ -5986,6 +5994,70 @@ mod tests {
);
}

/// The launch `login` gives a real child, as the child sees `var`. It says
/// only set or unset, so a failure cannot print a value.
async fn child_sees(
var: &str,
persona: &[(String, String)],
login: Result<Vec<(String, String)>, String>,
) -> String {
let (shell, flag, show) = if cfg!(windows) {
let cmd = format!("if defined {var} (echo set) else (echo unset)");
("cmd", "/c", cmd)
} else {
let sh = format!("[ \"${{{var}+x}}\" ] && echo set || echo unset");
("sh", "-c", sh)
};
let args = [flag.to_string(), show];
let mut client = AcpClient::spawn_with_env(shell, &args, persona, false, &login)
.await
.expect("spawn");
let seen = client.reader.next().await.expect("a line").expect("read");
client.shutdown().await;
seen.trim().to_string()
}

/// A folder login's child has no token, even one the persona (or the PC)
/// would hand it. The agent's own token and a `dpapi:` login keep theirs.
#[tokio::test]
async fn the_real_spawn_gives_each_kind_of_login_its_token() {
const TOKEN: &str = "CLAUDE_CODE_OAUTH_TOKEN";
let persona = [(TOKEN.to_string(), "from-persona".to_string())];
let own = Ok(Vec::new());
assert_eq!(child_sees(TOKEN, &persona, own).await, "set", "env entry");
let file = Ok(vec![(TOKEN.to_string(), "from-file".to_string())]);
assert_eq!(child_sees(TOKEN, &[], file).await, "set", "dpapi entry");
let folder = Ok(vec![("CLAUDE_CONFIG_DIR".into(), "/logins/one".into())]);
assert_eq!(child_sees(TOKEN, &persona, folder).await, "unset", "folder");
}

/// The pool string says where the primary's token file is, so the agent's
/// tool shell is not handed it.
#[tokio::test]
async fn the_real_spawn_keeps_the_pool_string_from_the_agent() {
let var = crate::factory::ACCOUNT_POOL_ENV;
let persona = [(var.to_string(), "primary=dpapi:C:/x".to_string())];
assert_eq!(child_sees(var, &persona, Ok(Vec::new())).await, "unset");
}

/// With no login left nothing starts, not even on the agent's own
/// environment, which is what a loose refusal would fall through to.
#[tokio::test]
async fn no_login_left_starts_nothing() {
let (shell, flag, show) = if cfg!(windows) {
("cmd", "/c", "echo started")
} else {
("sh", "-c", "echo started")
};
let args = [flag.to_string(), show.to_string()];
let dead = Err("login primary: dead".to_string());
let refused = AcpClient::spawn_with_env(shell, &args, &[], false, &dead).await;
assert!(
matches!(&refused, Err(AcpError::Protocol(why)) if why.contains("login primary: dead")),
"nothing may start with no login left"
);
}

#[tokio::test]
async fn idle_timeout_fires_on_silent_process() {
let mut client = spawn_script("sleep 10").await;
Expand Down Expand Up @@ -6661,10 +6733,10 @@ sleep 1"#,
&[],
&[],
false,
&[(
&Ok(vec![(
"CLAUDE_CODE_EXECUTABLE".into(),
cli.to_str().expect("utf8 path").into(),
)],
)]),
)
.await
.expect("spawn fake claude adapter");
Expand Down
5 changes: 4 additions & 1 deletion crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,14 +413,17 @@ pub struct CliArgs {
///
/// One entry may be `label=env` instead of a folder, meaning "this login
/// is the agent's own environment, unchanged": no `CLAUDE_CONFIG_DIR`
/// override, so its settings folder stays as it is, and no blanked
/// override, so its settings folder stays as it is, and no removed
/// `CLAUDE_CODE_OAUTH_TOKEN`, so its own long-lived `claude setup-token`
/// token is the sign-in. An agent with its own token and its folder
/// login as the fallback is written:
///
/// ```text
/// token=env;fallback=C:\Users\PC owner\.buzz-agents\factory\claude
/// ```
///
/// An entry may also be `label=dpapi:<path>`: a long-lived token in a
/// file encrypted for this Windows user.
#[arg(long, env = crate::factory::ACCOUNT_POOL_ENV)]
pub claude_account_pool: Option<String>,

Expand Down
Loading
Loading