Skip to content
Open
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
29 changes: 27 additions & 2 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ use crate::usage::{TurnUsage, UsageTracker};
/// Lines exceeding this limit are rejected to prevent OOM from rogue agents.
const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB

const BUZZ_CHILD_SECRET_ENV_KEYS: &[&str] = &[
"BUZZ_PRIVATE_KEY",
"BUZZ_ACP_PRIVATE_KEY",
"BUZZ_RELAY_URL",
"BUZZ_AUTH_TAG",
"BUZZ_API_TOKEN",
"BUZZ_ACP_API_TOKEN",
"NOSTR_PRIVATE_KEY",
];

fn scrub_buzz_child_secrets(command: &mut tokio::process::Command) {
for key in BUZZ_CHILD_SECRET_ENV_KEYS {
command.env_remove(key);
}
}

/// An MCP server configuration passed to `session/new`.
///
/// Corresponds to the `McpServerStdio` variant in the ACP schema.
Expand Down Expand Up @@ -417,12 +433,18 @@ impl AcpClient {
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
// Inherit stderr so agent logs are visible in the harness terminal.
.stderr(Stdio::inherit())
// Vendor stderr may contain OAuth URLs, device codes, or account
// diagnostics. Keep it out of Buzz logs; login is launched in a
// visible terminal by the desktop auth flow.
.stderr(Stdio::null())
// Ensure the child is killed when the AcpClient is dropped (best-effort).
// Callers MUST still call shutdown().await for guaranteed cleanup.
.kill_on_drop(true);

// Relay keys and Buzz auth material are delivered only to Buzz's MCP
// server through ACP session/new, never through the vendor child.
scrub_buzz_child_secrets(&mut cmd);

// Per-persona env vars (e.g., GOOSE_PROVIDER, BUZZ_AGENT_PROVIDER).
// For most keys, operator precedence wins: skip injection if already set
// in the parent environment.
Expand All @@ -448,6 +470,9 @@ impl AcpClient {
let codex_merge_active = codex_config_value.is_some();

for (key, value) in extra_env {
if BUZZ_CHILD_SECRET_ENV_KEYS.contains(&key.as_str()) {
continue;
}
if key == "CODEX_CONFIG" && codex_merge_active {
// Handled by build_codex_config_env; skip here to avoid double-setting.
continue;
Expand Down
50 changes: 49 additions & 1 deletion crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,11 @@ pub struct AuthAgentArgs {
value_delimiter = ','
)]
pub agent_args: Vec<String>,

/// Lossless argv transport used by desktop launchers. The legacy comma
/// separated variable remains supported for backwards compatibility.
#[arg(long, env = "BUZZ_ACP_AGENT_ARGS_JSON")]
pub agent_args_json: Option<String>,
}

/// CLI args for `buzz-acp auth-methods` — query adapter-advertised login methods.
Expand Down Expand Up @@ -258,6 +263,10 @@ pub struct CliArgs {
)]
pub agent_args: Vec<String>,

/// Lossless argv transport used when an argument can contain commas.
#[arg(long, env = "BUZZ_ACP_AGENT_ARGS_JSON")]
pub agent_args_json: Option<String>,

#[arg(long, env = "BUZZ_ACP_MCP_COMMAND", default_value = "")]
pub mcp_command: String,

Expand Down Expand Up @@ -702,6 +711,24 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec<String>) -> Vec<Strin
normalized
}

/// Decode the lossless desktop argv transport, falling back to the legacy
/// clap-delimited vector when it is absent or malformed.
pub fn agent_args_from_transport(
legacy_args: Vec<String>,
json_args: Option<String>,
) -> Vec<String> {
let Some(json_args) = json_args else {
return legacy_args;
};
match serde_json::from_str::<Vec<String>>(&json_args) {
Ok(args) => args,
Err(error) => {
tracing::warn!(%error, "ignoring malformed BUZZ_ACP_AGENT_ARGS_JSON");
legacy_args
}
}
}

/// Propagate legacy env-var aliases to their canonical names.
///
/// Must be called **before** the tokio runtime starts — i.e. from the sync
Expand Down Expand Up @@ -810,7 +837,10 @@ impl Config {
));
}

let agent_args = normalize_agent_args(&agent_command, args.agent_args);
let agent_args = normalize_agent_args(
&agent_command,
agent_args_from_transport(args.agent_args, args.agent_args_json),
);

if let Some(ref channels) = args.channels {
for ch in channels {
Expand Down Expand Up @@ -1492,6 +1522,24 @@ mod tests {
);
}

#[test]
fn decodes_lossless_agent_args_transport() {
let encoded = serde_json::to_string(&vec![
"--model".to_string(),
"provider/model,with-comma".to_string(),
"acp".to_string(),
])
.expect("encode args");
assert_eq!(
agent_args_from_transport(vec!["broken".into()], Some(encoded)),
vec!["--model", "provider/model,with-comma", "acp"]
);
assert_eq!(
agent_args_from_transport(vec!["legacy".into()], Some("not-json".into())),
vec!["legacy"]
);
}

#[test]
fn normalize_agent_command_identity_variants() {
assert_eq!(normalize_agent_command_identity("goose"), "goose");
Expand Down
48 changes: 34 additions & 14 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -964,23 +964,28 @@ fn handle_switch_model_control(
.any(|m| m.channel_id == Some(channel_id));

let status = if turn_in_flight {
// Busy path: deliver over the oneshot. `false` means the oneshot was
// already consumed this turn (a prior cancel/interrupt) — the turn is
// already ending, so the switch cannot land on it.
if signal_in_flight_task(
pool,
channel_id,
ControlSignal::SwitchModel(model_id.to_string()),
) {
"sent"
if pool.startup_model_only() {
"unsupported_model"
} else {
"turn_ending"
// Busy path: deliver over the oneshot. `false` means the oneshot was
// already consumed this turn (a prior cancel/interrupt) — the turn is
// already ending, so the switch cannot land on it.
if signal_in_flight_task(
pool,
channel_id,
ControlSignal::SwitchModel(model_id.to_string()),
) {
"sent"
} else {
"turn_ending"
}
}
} else {
// Idle path: validate against the cached catalog before invalidating.
match pool.switch_idle_agent_model(channel_id, model_id) {
IdleSwitchResult::Switched => "switched",
IdleSwitchResult::UnsupportedModel => "unsupported_model",
IdleSwitchResult::UnsupportedRuntime => "unsupported_model",
IdleSwitchResult::NoIdleAgent => "no_active_turn",
}
};
Expand Down Expand Up @@ -1315,7 +1320,9 @@ async fn tokio_main() -> Result<()> {
}

let mut pool = if config.lazy_pool {
AgentPool::from_slots((0..config.agents).map(|_| None).collect())
let mut pool = AgentPool::from_slots((0..config.agents).map(|_| None).collect());
pool.set_startup_model_only(pool::startup_model_only_command(&config.agent_command));
pool
} else {
initialize_agent_pool(&PoolStartup::from_config(&config, observer.clone()), None).await?
};
Expand Down Expand Up @@ -1793,6 +1800,7 @@ async fn tokio_main() -> Result<()> {
model_capabilities: None,
desired_model: config.model.clone(),
model_overridden: false,
startup_model_only: pool::startup_model_only_command(&config.agent_command),
agent_name,
goose_system_prompt_supported: None,
protocol_version,
Expand Down Expand Up @@ -3799,6 +3807,9 @@ async fn initialize_agent_pool(
model_capabilities: None,
desired_model: startup.model.clone(),
model_overridden: false,
startup_model_only: pool::startup_model_only_command(
&startup.agent_command,
),
agent_name,
goose_system_prompt_supported: None,
protocol_version,
Expand Down Expand Up @@ -3837,7 +3848,9 @@ async fn initialize_agent_pool(
);
}
tracing::info!("agent_pool_ready agents={}", live_count);
Ok(AgentPool::from_slots(agent_slots))
let mut pool = AgentPool::from_slots(agent_slots);
pool.set_startup_model_only(pool::startup_model_only_command(&startup.agent_command));
Ok(pool)
}

// ── spawn_and_init ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -3883,7 +3896,10 @@ async fn spawn_and_init(
}

async fn spawn_auth_client(agent: &AuthAgentArgs) -> Result<AcpClient, acp::AcpError> {
let agent_args = config::normalize_agent_args(&agent.agent_command, agent.agent_args.clone());
let agent_args = config::normalize_agent_args(
&agent.agent_command,
config::agent_args_from_transport(agent.agent_args.clone(), agent.agent_args_json.clone()),
);
AcpClient::spawn(&agent.agent_command, &agent_args, &[], false).await
}

Expand Down Expand Up @@ -4005,7 +4021,10 @@ 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 agent_args = config::normalize_agent_args(
&args.agent.agent_command,
config::agent_args_from_transport(args.agent.agent_args, args.agent.agent_args_json),
);
let cwd = std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("/"))
.to_string_lossy()
Expand Down Expand Up @@ -5182,6 +5201,7 @@ mod error_outcome_emission_tests {
model_capabilities: None,
desired_model: None,
model_overridden: false,
startup_model_only: false,
agent_name: "unknown".into(),
goose_system_prompt_supported: None,
// Error branches under test never read this; 1 is the legacy
Expand Down
32 changes: 31 additions & 1 deletion crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ use crate::queue::{
};
use crate::relay::{ChannelInfo, RestClient};

pub(crate) fn startup_model_only_command(command: &str) -> bool {
matches!(
crate::config::normalize_agent_command_identity(command).as_str(),
"agent" | "cursor-agent" | "wsl"
)
}

/// Window within which agent activity before a hard-cap death qualifies
/// the turn as "recently active" (eligible for requeue instead of dead-letter).
const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(60);
Expand Down Expand Up @@ -160,6 +167,9 @@ pub struct OwnedAgent {
/// desktop reader to distinguish a genuine runtime override from a stale
/// session whose persona model was edited. Reset on spawn/restart.
pub model_overridden: bool,
/// Cursor pins the model in its process argv; ACP live switching is not
/// supported for that runtime.
pub startup_model_only: bool,
/// Normalized agent name from initialize (`agentInfo.name`/`serverInfo.name`).
pub agent_name: String,
/// Whether Goose accepted its custom system-prompt method. `None` probes on
Expand Down Expand Up @@ -215,6 +225,7 @@ pub struct AgentPool {
result_rx: mpsc::UnboundedReceiver<PromptResult>,
pub join_set: JoinSet<()>,
task_map: HashMap<tokio::task::Id, TaskMeta>,
startup_model_only: bool,
}

/// Result returned by a completed prompt task.
Expand Down Expand Up @@ -540,15 +551,26 @@ impl AgentPool {
/// the index invariant.
pub fn from_slots(slots: Vec<Option<OwnedAgent>>) -> Self {
let (result_tx, result_rx) = mpsc::unbounded_channel();
let startup_model_only = slots.iter().flatten().any(|agent| agent.startup_model_only);
Self {
agents: slots,
result_tx,
result_rx,
join_set: JoinSet::new(),
task_map: HashMap::new(),
startup_model_only,
}
}

/// True when the vendor runtime can only apply a model at process start.
pub fn startup_model_only(&self) -> bool {
self.startup_model_only
}

pub fn set_startup_model_only(&mut self, enabled: bool) {
self.startup_model_only = enabled;
}

/// Try to claim an idle agent for the given channel (or heartbeat if `None`).
///
/// Pass 1: prefer an agent that already has a session for `channel_id`.
Expand Down Expand Up @@ -743,6 +765,10 @@ impl AgentPool {
return IdleSwitchResult::NoIdleAgent;
};

if agent.startup_model_only {
return IdleSwitchResult::UnsupportedRuntime;
}

// Pre-cancel guard against the cached catalog. None = catalog not yet
// populated (no session ever created); defer validation to apply time.
if let Some(caps) = agent.model_capabilities.as_ref() {
Expand Down Expand Up @@ -770,6 +796,8 @@ pub enum IdleSwitchResult {
/// Desired model is not in the agent's cached catalog — pick rejected,
/// session untouched.
UnsupportedModel,
/// The runtime accepts a model only as a process-start argument.
UnsupportedRuntime,
/// No idle agent available (all checked out / none spawned).
NoIdleAgent,
}
Expand Down Expand Up @@ -869,7 +897,9 @@ async fn create_session_and_apply_model(
// Apply desired_model if set, matching against the fresh session/new response.
// Track whether the switch succeeded so session_config_captured reflects
// the post-switch state (not the pre-switch desired state).
let switch_succeeded = if let Some(ref desired) = agent.desired_model {
let switch_succeeded = if agent.startup_model_only {
false
} else if let Some(ref desired) = agent.desired_model {
match resolve_model_switch_method(&resp.raw, desired) {
Some(method) => {
apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await?;
Expand Down
Loading