Skip to content
Draft
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
17 changes: 17 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2894,6 +2894,23 @@ mod tests {
observed
}

#[cfg(unix)]
#[tokio::test]
async fn spawn_inherits_parent_buzz_context() {
const VAR: &str = "BUZZ_ACP_PARENT_CONTEXT_TEST";
const VALUE: &str = "inherited";
let previous = std::env::var_os(VAR);
std::env::set_var(VAR, VALUE);

let observed = spawn_named_and_read_child_env("test-runtime", VAR, &[]).await;

match previous {
Some(value) => std::env::set_var(VAR, value),
None => std::env::remove_var(VAR),
}
assert_eq!(observed, VALUE);
}

/// 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 Down
84 changes: 74 additions & 10 deletions crates/buzz-acp/src/setup_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ pub(crate) enum AcpAvailabilityStatus {
AdapterMissing,
/// ACP adapter binary is from the deprecated package (< 1.0). Reinstall required.
AdapterOutdated,
/// Runtime CLI is installed but does not implement the required ACP host contract.
CliOutdated,
/// Runtime contract verification could not complete; retry without mutating the install.
CompatibilityUnknown,
/// CLI binary missing; ACP adapter may be present.
CliMissing,
/// Neither adapter nor CLI found.
Expand Down Expand Up @@ -101,8 +105,8 @@ pub(crate) enum RequirementPayload {
setup_copy: String,
/// Granular install/auth state — determines copy and CTA routing on
/// the desktop card. `Available` means tooling is present but login
/// is needed; the other three variants mean the tooling itself is
/// missing and the probe was skipped.
/// is needed; other variants describe install or compatibility gates
/// that prevented the auth probe from running.
availability: AcpAvailabilityStatus,
},
/// The CLI is installed but its config file could not be parsed.
Expand All @@ -115,6 +119,8 @@ pub(crate) enum RequirementPayload {
},
/// Git for Windows is missing; open Agent runtimes for the installation guide.
GitBash,
/// A custom harness command cannot be resolved on PATH.
MissingBinary { command: String },
}

impl RequirementPayload {
Expand Down Expand Up @@ -153,6 +159,26 @@ impl RequirementPayload {
harness
)
}
AcpAvailabilityStatus::CliOutdated => {
let harness = probe_args
.first()
.map(String::as_str)
.unwrap_or("the agent");
format!(
"update the {} CLI — the installed version lacks Buzz's required ACP runtime contract (open Agent runtimes in Settings to diagnose)",
harness
)
}
AcpAvailabilityStatus::CompatibilityUnknown => {
let harness = probe_args
.first()
.map(String::as_str)
.unwrap_or("the agent");
format!(
"check the {} runtime again — Buzz could not verify its ACP runtime contract",
harness
)
}
AcpAvailabilityStatus::CliMissing => {
let harness = probe_args
.first()
Expand Down Expand Up @@ -189,6 +215,9 @@ impl RequirementPayload {
RequirementPayload::GitBash => {
"install Git for Windows (open Agent runtimes in Settings to diagnose)".to_string()
}
RequirementPayload::MissingBinary { command } => {
format!("install `{command}` or add it to PATH")
}
}
}
}
Expand Down Expand Up @@ -257,14 +286,20 @@ impl SetupPayload {
.requirements
.iter()
.any(|r| matches!(r, RequirementPayload::GitBash));
let all_external = self
.requirements
.iter()
.all(|r| matches!(r, RequirementPayload::CliConfigInvalid { .. }));
let any_external = self
.requirements
.iter()
.any(|r| matches!(r, RequirementPayload::CliConfigInvalid { .. }));
let all_external = self.requirements.iter().all(|r| {
matches!(
r,
RequirementPayload::CliConfigInvalid { .. }
| RequirementPayload::MissingBinary { .. }
)
});
let any_external = self.requirements.iter().any(|r| {
matches!(
r,
RequirementPayload::CliConfigInvalid { .. }
| RequirementPayload::MissingBinary { .. }
)
});

let footer = if has_doctor_requirement {
"Open Agent runtimes in Settings, install Git for Windows, then re-check and restart the agent.".to_string()
Expand Down Expand Up @@ -756,6 +791,7 @@ mod tests {
for availability in [
AcpAvailabilityStatus::AdapterMissing,
AcpAvailabilityStatus::AdapterOutdated,
AcpAvailabilityStatus::CliOutdated,
AcpAvailabilityStatus::CliMissing,
AcpAvailabilityStatus::NotInstalled,
] {
Expand Down Expand Up @@ -1105,6 +1141,34 @@ mod tests {
);
}

#[test]
fn cli_login_availability_contract_states_survive_sentinel_round_trip() {
for availability in ["cli_outdated", "compatibility_unknown"] {
let raw = make_desktop_cli_login_json(availability);
let payload = SetupPayload::from_raw_env_value(Some(raw))
.unwrap()
.expect("must parse");
let body = payload.nudge_body();
let sentinel_json = extract_sentinel_json(&body);
assert!(
sentinel_json.contains(&format!(r#""availability":"{availability}""#)),
"sentinel must carry availability={availability}; got: {sentinel_json:?}"
);
}
}

#[test]
fn setup_payload_deserializes_missing_binary_requirement() {
let payload: SetupPayload = serde_json::from_str(
r#"{"agent_name":"Custom","agent_pubkey":"test","requirements":[{"surface":"missing_binary","command":"my-agent"}]}"#,
)
.unwrap();
assert!(matches!(
payload.requirements.as_slice(),
[RequirementPayload::MissingBinary { command }] if command == "my-agent"
));
}

#[test]
fn cli_login_availability_cli_missing_survives_sentinel_round_trip() {
let raw = make_desktop_cli_login_json("cli_missing");
Expand Down
5 changes: 2 additions & 3 deletions desktop/src-tauri/src/commands/agent_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ use crate::{
};

/// Subset of the goose file config exposed to the frontend for gate evaluation.
///
/// Only the fields the dialog gate needs. This tracks which requirements are already satisfied in the
/// harness config file, so it can show "Set in goose config" rather than
/// surfacing a false missing-key marker.
Expand Down Expand Up @@ -636,10 +635,10 @@ mod tests {
context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"),
required_normalized_fields: &["model", "provider"],
login_hint: None,
auth_probe_args: None,
auth_probe: None,
compatibility_probe_args: None,
}
}

fn agent_record() -> ManagedAgentRecord {
ManagedAgentRecord {
pubkey: "agent".to_string(),
Expand Down
97 changes: 41 additions & 56 deletions desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,63 +11,18 @@ use crate::{
relay::query_relay,
};

mod install_planning;
mod post_install_verification;

use install_planning::{plan_adapter_install, should_install_cli};

fn active_installs() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
static ACTIVE: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
ACTIVE.get_or_init(|| Mutex::new(HashSet::new()))
}

/// Returns the adapter install commands that `install_acp_runtime_blocking` would
/// run for `runtime_id` given a resolved adapter binary at `adapter_path` (or
/// `None` if none was found).
///
/// Returns `None` when no install is needed (adapter is present and current).
/// Returns `Some(cmds)` when the adapter is missing or (for codex) below its
/// minimum supported version.
///
/// For the codex **outdated** case the returned sequence is a two-step
/// reinstall: first uninstall the old `@zed-industries/codex-acp` package
/// (idempotent — exit 0 when absent), then install the new
/// `@agentclientprotocol/codex-acp`. This is required because both packages
/// install a global binary named `codex-acp`, and npm ≥7 refuses to overwrite
/// a bin file owned by a different package with `EEXIST`.
///
/// For the **missing** case the catalog's `adapter_install_commands` are used
/// as-is (no prior package to remove).
///
/// This is a pure planning function: it never spawns a process. Tests use it to
/// assert the correct install command is selected without touching real npm.
pub(crate) fn plan_adapter_install<'c>(
runtime_id: &str,
adapter_path: Option<&std::path::Path>,
adapter_install_commands: &'c [&'c str],
adapter_probe_path: Option<&str>,
) -> Option<Vec<&'c str>> {
match adapter_path {
// Adapter present and current — no install needed.
Some(_) if runtime_id != "codex" => None,
Some(path)
if !crate::managed_agents::codex_adapter_is_outdated_with_path(
path,
adapter_probe_path,
) =>
{
None
}
// Codex adapter is outdated: uninstall the old package first so npm
// doesn't hit EEXIST on the shared `codex-acp` bin-link, then install.
Some(_) => Some(vec![
"npm uninstall -g @zed-industries/codex-acp",
"npm install -g @agentclientprotocol/codex-acp",
]),
// Adapter missing: use the catalog's install commands directly.
None => Some(adapter_install_commands.to_vec()),
}
}

#[tauri::command]
pub async fn discover_acp_providers(
app: tauri::AppHandle,
Expand Down Expand Up @@ -312,14 +267,37 @@ fn install_acp_runtime_blocking(

let mut steps = Vec::new();

// Phase 1: Install CLI if missing and commands are available.
// Today every entry in `cli_install_commands` is a curl-pipe; npm-backed
// adapter installs live in Phase 2 below where they are rewritten to a
// Buzz-private prefix before execution.
// Phase 1: Install or update the CLI when missing or incompatible.
if let Some(cli) = runtime.underlying_cli {
if crate::managed_agents::resolve_command(cli).is_none() {
for cmd in runtime.cli_install_commands_for_os() {
let result = run_install_command_with_retry("cli", cmd, &reporter);
let availability = crate::managed_agents::discover_acp_runtime_availability(runtime_id);
if should_install_cli(
crate::managed_agents::resolve_command(cli).is_some(),
availability,
) {
let commands = runtime.cli_install_commands_for_os();
let use_managed_npm = commands.iter().any(|cmd| is_npm_global_install(cmd))
&& managed_node_runtime_supported();
if use_managed_npm {
if let Err(step) = ensure_managed_node_runtime_blocking() {
reporter.record_step(&mut steps, *step);
return Ok(reporter.failed(steps));
}
}

for cmd in commands {
let planned = match if use_managed_npm {
managed_npm_command("cli", cmd)
} else {
Ok(None)
} {
Ok(Some(command)) => command,
Ok(None) => cmd.to_string(),
Err(step) => {
reporter.record_step(&mut steps, *step);
return Ok(reporter.failed(steps));
}
};
let result = run_install_command_with_retry("cli", &planned, &reporter);
let success = result.success;
steps.push(result);
if !success {
Expand Down Expand Up @@ -355,7 +333,7 @@ fn install_acp_runtime_blocking(

for cmd in cmds {
let planned = match if use_managed_npm {
managed_npm_command(cmd)
managed_npm_command("adapter", cmd)
} else {
Ok(None)
} {
Expand Down Expand Up @@ -1091,6 +1069,13 @@ mod tests {
));
}

#[test]
fn test_is_npm_global_install_accepts_cli_package() {
assert!(is_npm_global_install(
"npm install -g example-acp-runtime@latest"
));
}

#[test]
fn test_is_npm_global_install_accepts_short_flag() {
assert!(is_npm_global_install("npm i -g some-package"));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
use std::path::Path;

use crate::managed_agents::AcpAvailabilityStatus;

/// Plans adapter installation without spawning npm.
pub(super) fn plan_adapter_install<'c>(
runtime_id: &str,
adapter_path: Option<&Path>,
adapter_install_commands: &'c [&'c str],
adapter_probe_path: Option<&str>,
) -> Option<Vec<&'c str>> {
match adapter_path {
Some(_) if runtime_id != "codex" => None,
Some(path)
if !crate::managed_agents::codex_adapter_is_outdated_with_path(
path,
adapter_probe_path,
) =>
{
None
}
Some(_) => Some(vec![
"npm uninstall -g @zed-industries/codex-acp",
"npm install -g @agentclientprotocol/codex-acp",
]),
None => Some(adapter_install_commands.to_vec()),
}
}

pub(super) fn should_install_cli(
cli_found: bool,
availability: Option<AcpAvailabilityStatus>,
) -> bool {
!cli_found || availability == Some(AcpAvailabilityStatus::CliOutdated)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn incompatible_cli_requires_update() {
assert!(should_install_cli(
true,
Some(AcpAvailabilityStatus::CliOutdated),
));
assert!(should_install_cli(
false,
Some(AcpAvailabilityStatus::NotInstalled),
));
assert!(!should_install_cli(
true,
Some(AcpAvailabilityStatus::Available),
));
assert!(!should_install_cli(
true,
Some(AcpAvailabilityStatus::CompatibilityUnknown),
));
}
}
Loading