Files
buzz/desktop/src-tauri/src/managed_agents/discovery.rs
Will Pfleger 985cdcc6ea feat(agents): model-tuning parity in global Agent Defaults editor (#4578)
## Overview

The global Agent Defaults surface (Settings card, defaults modal,
onboarding) exposed structured controls for Effort but left Max Output
Tokens, Context Limit, and Max Rounds as raw env vars. Per-agent dialogs
had structured numeric fields but only for `isBuzzAgentRuntime` —
incorrectly excluding Goose. This PR unifies numeric-tuning capability
across all surfaces, fixes a pre-existing dual-editor defect, and adds
full test coverage.

## What changed

### Phase 1 — Catalog projection

- Add `max_rounds_env_var` to `KnownAcpRuntime` in `runtime_metadata.rs`
(`Some("BUZZ_AGENT_MAX_ROUNDS")` for buzz-agent, `None` elsewhere).
- Project all three numeric env-var fields (`max_tokens_env_var`,
`context_limit_env_var`, `max_rounds_env_var`) end-to-end:
`AcpRuntimeCatalogEntry` Rust struct, TS `types.ts`,
`RawAcpRuntimeCatalogEntry` + `fromRawAcpRuntimeCatalogEntry` in
`tauri.ts`, and the e2e mock bridge (`withMockRuntimeConfigMetadata`).

### Phase 2 — Field model

- `deriveAgentConfigFieldModel` now derives `maxOutputTokens` /
`contextLimit` / `maxRounds` descriptors from catalog-projected fields.
- `structuredEnvKeys(descriptors)` — exported helper that takes the
**rendered** descriptor set (not the whole model). Hidden keys follow
what is actually rendered per surface: global hides effort + all three
numeric keys for buzz-agent / two for Goose; per-agent buzz-agent hides
effort + three numeric keys; per-agent Goose hides only its two numeric
keys. `BUZZ_AGENT_THINKING_EFFORT` stays a visible generic env row
per-agent because no effort control renders there.

### Phase 3 — UI

- Extract `NumericTuningFields` from `buzzAgentModelTuningFields.tsx` as
a shared descriptor-driven component (`descriptors`, `envVars`,
`inheritedEnvVars`, `onEnvVarChange`). Kind-specific minima:
`NUMERIC_KIND_MIN` map (`maxOutputTokens`/`contextLimit`: 1,
`maxRounds`: 0) applied to `<input min>`.
- **Global surface** (`AgentConfigFields.tsx`): deduplicate the
previously duplicated Advanced env-editor block; render
`NumericTuningFields` below the env editor when descriptors exist;
`hiddenKeys` and `bakedGenericRows` exclusions use `structuredEnvKeys`
so structured keys are never double-rendered. Under 1000 lines.
- **Per-agent surfaces** (`EditAgentAdvancedFields`,
`PersonaAdvancedFields`): replace `isBuzzAgentRuntime` as the
numeric-field gate with `deriveNumericDescriptors(selectedRuntime)` from
`agentConfigCore`; hidden keys come from
`structuredEnvKeys(numericDescriptors)` — the same rendered descriptor
set, no local rebuilding (fixes pre-existing dual-editor defect).
Catalog status carried as `RuntimeCatalogStatus` (`loading | ready |
error`); both error and loading withhold structured controls and leave
saved values visible as generic rows, making error distinguishable from
"runtime not capable" (`ready` + no runtime).
- **Dialogs** (`AgentDefinitionDialog`, `AgentInstanceEditDialog`,
callers): `AgentDefinitionDialog` accepts `runtimeCatalogStatus?:
"loading" | "ready" | "error"` (replaces separate
`runtimesLoading`/`runtimesError` booleans); all call sites —
`AgentManagementDialogs`, `AgentsView`, `RequestedAgentCreateDialogs`,
`UserProfilePersonaDialogs` — compute and pass the status.

### Phase 4 — Tests

- `buildRecord` exported from `EnvVarsEditor.tsx` as a pure `(nextRows,
value, requiredKeys, hiddenKeys) => Record<string, string>` helper for
isolation testing.
- **17 new node tests** in `agentConfigCore.test.mjs`:
`deriveNumericDescriptors` (all three fields, partial, undefined
runtime, matches field-model subset); `structuredEnvKeys` per surface
including discriminating Goose per-agent effort-key invariant;
`NUMERIC_KIND_MIN` values.
- **4 new node tests** in `EnvVarsEditor.test.mjs`: hidden tuning key
preserved through generic row edits; runtime-switch then generic edit
(derives both descriptor sets, asserts new-runtime hidden key survives
`buildRecord` via `hiddenKeys` and old-runtime key survives via generic
rows); baked numeric key excluded via `filterBakedGenericRows` with
`numericTuningPlaceholder` assertion; clearing a structured override —
`numericTuningPlaceholder` verifies placeholder text.
- **5 new Playwright tests** in `agent-numeric-tuning.spec.ts` (added to
smoke project `testMatch`): global numeric fields visible for
buzz-agent; global: non-capable runtime hides numeric controls; Goose
per-agent shows `Inherit (16384)` after saving global value through the
UI; delayed catalog: saved values visible as generic rows while loading
then structured controls appear after settle; failed catalog: saved
values remain visible as generic rows (never the "unsupported" empty
state).

## Result

- buzz-agent global defaults: Max output tokens, Context limit, Max
rounds as structured inputs with `Inherit (N)` placeholders from baked
env.
- Goose global defaults: Max output tokens, Context limit as structured
inputs.
- A Goose global value surfaces as `Inherit (<value>)` in the per-agent
Goose edit dialog.
- No structured key is editable in two places on any surface; no
persisted key has zero editors.
- No `runtime.id === "buzz-agent"` comparison decides numeric-field
visibility anywhere — capability flows catalog →
`AcpRuntimeCatalogEntry` → field model → UI.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-03 18:09:24 -04:00

1643 lines
63 KiB
Rust

use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use crate::managed_agents::{
buzz_managed_command_path, buzz_managed_node_bin_dir, buzz_managed_npm_bin_dir,
AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo,
HarnessSource,
};
mod presets;
mod runtime_metadata;
#[macro_use]
mod windows_install;
use presets::{preset_catalog_entry, PRESET_HARNESSES};
pub(crate) use presets::{preset_harness_definitions, preset_harness_ids};
pub(crate) use runtime_metadata::KnownAcpRuntime;
const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png";
const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default";
const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default";
const BUZZ_AGENT_AVATAR_URL: &str =
"https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png";
fn common_binary_paths() -> &'static [PathBuf] {
static PATHS: OnceLock<Vec<PathBuf>> = OnceLock::new();
PATHS.get_or_init(|| {
let mut paths = vec![
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/usr/local/bin"),
PathBuf::from("/usr/bin"),
PathBuf::from("/home/linuxbrew/.linuxbrew/bin"),
];
if let Some(managed_node_bin) = buzz_managed_node_bin_dir() {
paths.insert(0, managed_node_bin);
}
if let Some(managed_bin) = buzz_managed_npm_bin_dir() {
paths.insert(0, managed_bin);
}
if let Some(home) = dirs::home_dir() {
paths.extend([
home.join(".local/share/mise/shims"),
home.join(".local/bin"),
home.join(".volta/bin"),
home.join(".asdf/shims"),
home.join(".bun/bin"),
]);
}
// Windows well-known dirs for npm global shims and standalone installer targets.
#[cfg(windows)]
{
if let Some(appdata) = std::env::var_os("APPDATA") {
paths.push(PathBuf::from(appdata).join("npm"));
}
if let Some(local) = std::env::var_os("LOCALAPPDATA") {
paths.push(
PathBuf::from(local)
.join("Programs")
.join("OpenAI")
.join("Codex")
.join("bin"),
);
}
// Goose's legacy Windows installer (superseded by #2680) unpacked
// to %USERPROFILE%\goose\goose.exe, which is on no standard PATH —
// without this probe those installs stay permanently undiscovered.
if let Some(profile) = std::env::var_os("USERPROFILE") {
paths.push(PathBuf::from(profile).join("goose"));
}
}
paths
})
}
const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
KnownAcpRuntime {
id: "goose",
label: "Goose",
commands: &["goose"],
aliases: &[],
avatar_url: GOOSE_AVATAR_URL,
mcp_command: None,
mcp_hooks: false,
underlying_cli: Some("goose"),
cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"],
// Goose's stable release currently publishes only the Unix installer;
// its official Windows instructions intentionally point at this main-branch script.
cli_install_commands_windows: &[windows_install_command!("goose", "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", "$env:CONFIGURE='false'; ")],
adapter_install_commands: &[],
cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/",
adapter_install_instructions_url: "",
cli_install_hint: "Buzz talks to Goose through the Goose CLI.",
adapter_install_hint: "",
skill_dir: Some(".goose/skills"),
supports_acp_model_switching: false,
model_env_var: Some("GOOSE_MODEL"),
provider_env_var: Some("GOOSE_PROVIDER"),
provider_locked: false,
default_env: &[("GOOSE_MODE", "auto")],
config_file_path: Some("~/.config/goose/config.yaml"),
config_file_format: Some("yaml"),
supports_acp_native_config: true,
thinking_env_var: Some("GOOSE_THINKING_EFFORT"),
max_tokens_env_var: Some("GOOSE_MAX_TOKENS"),
context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"),
max_rounds_env_var: None,
required_normalized_fields: &["model", "provider"],
login_hint: None,
auth_probe_args: None,
},
KnownAcpRuntime {
id: "claude",
label: "Claude Code",
commands: &["claude-agent-acp", "claude-code-acp"],
aliases: &["claude-code", "claudecode"],
avatar_url: CLAUDE_CODE_AVATAR_URL,
mcp_command: None,
mcp_hooks: false,
underlying_cli: Some("claude"),
cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"],
cli_install_commands_windows: &[windows_install_command!("claude", "https://claude.ai/install.ps1")],
adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"],
cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started",
adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp",
cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.",
adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.",
skill_dir: Some(".claude/skills"),
supports_acp_model_switching: false,
model_env_var: None,
provider_env_var: None,
provider_locked: true,
default_env: &[],
config_file_path: Some("~/.claude/settings.json"),
config_file_format: Some("json"),
supports_acp_native_config: false,
thinking_env_var: None,
max_tokens_env_var: None,
context_limit_env_var: None,
max_rounds_env_var: None,
required_normalized_fields: &[],
login_hint: Some("Run the Claude CLI to complete authentication."),
auth_probe_args: Some(&["claude", "auth", "status"]),
},
KnownAcpRuntime {
id: "codex",
label: "Codex",
commands: &["codex-acp"],
aliases: &[],
avatar_url: CODEX_AVATAR_URL,
mcp_command: Some("buzz-dev-mcp"),
mcp_hooks: false,
underlying_cli: Some("codex"),
cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"],
cli_install_commands_windows: &[windows_install_command!("codex", "https://chatgpt.com/codex/install.ps1")],
adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"],
cli_install_instructions_url: "https://developers.openai.com/codex/cli/",
adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp",
cli_install_hint: "Buzz talks to Codex through the Codex CLI.",
adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.",
skill_dir: Some(".codex/skills"),
supports_acp_model_switching: false,
model_env_var: None,
provider_env_var: None,
provider_locked: false,
default_env: &[],
config_file_path: Some("~/.codex/config.toml"),
config_file_format: Some("toml"),
supports_acp_native_config: false,
thinking_env_var: None,
max_tokens_env_var: None,
context_limit_env_var: None,
max_rounds_env_var: None,
required_normalized_fields: &[],
login_hint: Some("Run `codex login` to authenticate."),
// Verified: `codex login status` exits 0 when logged in, non-zero otherwise.
auth_probe_args: Some(&["codex", "login", "status"]),
},
KnownAcpRuntime {
id: "buzz-agent",
label: "Buzz Agent",
commands: &["buzz-agent"],
aliases: &[],
avatar_url: BUZZ_AGENT_AVATAR_URL,
mcp_command: Some("buzz-dev-mcp"),
mcp_hooks: true,
underlying_cli: None,
cli_install_commands: &[],
cli_install_commands_windows: &[],
adapter_install_commands: &[],
cli_install_instructions_url: "https://github.com/block/buzz",
adapter_install_instructions_url: "https://github.com/block/buzz",
cli_install_hint: "Ships with the Buzz desktop app.",
adapter_install_hint: "",
skill_dir: None,
supports_acp_model_switching: true,
model_env_var: Some("BUZZ_AGENT_MODEL"),
provider_env_var: Some("BUZZ_AGENT_PROVIDER"),
provider_locked: false,
default_env: &[],
config_file_path: None,
config_file_format: None,
supports_acp_native_config: false,
thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"),
max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"),
context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"),
max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"),
required_normalized_fields: &["model", "provider"],
login_hint: None,
auth_probe_args: None,
},
];
/// Skill discovery directories declared by known runtimes.
pub(crate) fn known_skill_dirs() -> impl Iterator<Item = &'static str> {
KNOWN_ACP_RUNTIMES.iter().filter_map(|p| p.skill_dir)
}
fn workspace_root_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}
fn command_looks_like_path(command: &str) -> bool {
let path = Path::new(command);
path.is_absolute() || path.components().count() > 1
}
fn executable_basename(command: &str) -> String {
let suffix = std::env::consts::EXE_SUFFIX;
if suffix.is_empty() || command.ends_with(suffix) {
command.to_string()
} else {
format!("{command}{suffix}")
}
}
fn normalize_command_identity(command: &str) -> String {
let normalized = command.trim().replace('\\', "/");
let basename = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
let lower = basename
.chars()
.map(|character| match character {
' ' | '_' => '-',
_ => character.to_ascii_lowercase(),
})
.collect::<String>();
let lower = lower.strip_suffix(".exe").unwrap_or(&lower).to_string();
if let Some(suffix) = std::env::consts::EXE_SUFFIX.strip_prefix('.') {
return lower
.strip_suffix(&format!(".{suffix}"))
.unwrap_or(&lower)
.to_string();
}
if !std::env::consts::EXE_SUFFIX.is_empty() {
return lower
.strip_suffix(std::env::consts::EXE_SUFFIX)
.unwrap_or(&lower)
.to_string();
}
lower
}
pub(crate) fn known_acp_runtime(command: &str) -> Option<&'static KnownAcpRuntime> {
let normalized = normalize_command_identity(command);
KNOWN_ACP_RUNTIMES.iter().find(|runtime| {
normalized == runtime.id
|| runtime
.commands
.iter()
.any(|command| normalized == normalize_command_identity(command))
|| runtime.aliases.iter().any(|alias| normalized == *alias)
})
}
pub(crate) fn known_acp_runtime_exact(id: &str) -> Option<&'static KnownAcpRuntime> {
KNOWN_ACP_RUNTIMES.iter().find(|p| p.id == id)
}
/// The agent command a freshly-created agent defaults to when the create
/// request supplies none. Resolves the bundled `buzz-agent` from the catalog so
/// the default cannot drift from the provider definition. Falls back to the id
/// if the catalog entry is missing. (Previous default was bare `goose`, which
/// is not on PATH on a stock Windows install; buzz-agent ships with the app.)
pub fn default_agent_command() -> String {
known_acp_runtime_exact("buzz-agent")
.and_then(|p| p.commands.first().copied())
.unwrap_or("buzz-agent")
.to_string()
}
/// Record-first harness resolution (unified agent model, Phase 1A).
///
/// Resolution order:
/// 1. explicit override (non-empty) — a deliberate per-instance pin;
/// 2. the record's own `runtime` id mapped to its primary command —
/// records materialize their runtime at create/migration time;
/// checks both static builtins AND the loaded preset/custom registry;
/// 3. legacy fallback: the linked persona's `runtime` (records created
/// before the unified model carry `persona_id` but no `runtime`);
/// 4. `default_agent_command()`.
pub fn record_agent_command(
record: &crate::managed_agents::types::ManagedAgentRecord,
personas: &[crate::managed_agents::types::AgentDefinition],
) -> String {
if let Some(pin) = record
.agent_command_override
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return pin.to_string();
}
if let Some(id) = record.runtime.as_deref() {
// Check static builtins first.
if let Some(command) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied())
{
return command.to_string();
}
// Fall back to loaded registry for preset/custom harnesses.
if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id)
{
return def.command.clone();
}
}
effective_agent_command(record.persona_id.as_deref(), personas, None)
}
/// Resolve the agent command (harness) for a spawn/deploy/summary. The linked
/// persona wins so persona harness edits propagate on the next spawn. An
/// explicit per-instance override (`agent_command_override`) takes precedence.
///
/// Resolution order:
/// 1. explicit override (non-empty) — a deliberate per-instance pin;
/// 2. the linked persona's `runtime` id mapped to its primary command
/// (checks builtins then loaded preset/custom registry);
/// 3. `default_agent_command()` — no persona/runtime, or persona deleted.
pub fn effective_agent_command(
persona_id: Option<&str>,
personas: &[crate::managed_agents::types::AgentDefinition],
agent_command_override: Option<&str>,
) -> String {
if let Some(pin) = agent_command_override
.map(str::trim)
.filter(|value| !value.is_empty())
{
return pin.to_string();
}
let runtime_id = persona_id
.and_then(|pid| personas.iter().find(|p| p.id == pid))
.and_then(|persona| persona.runtime.as_deref());
if let Some(id) = runtime_id {
// Check static builtins first.
if let Some(command) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied())
{
return command.to_string();
}
// Check loaded preset/custom registry.
if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id)
{
return def.command.clone();
}
}
default_agent_command()
}
mod overrides;
pub use overrides::{apply_agent_command_update, create_time_agent_command_override};
/// Prefix of the typed dangling-harness error produced by
/// `try_record_agent_command` / `resolve_effective_harness_descriptor`.
/// Internal Rust contract: surfaces must convert it via [`user_facing_harness_error`] or
/// [`dangling_harness_id`] — never show it raw.
pub(crate) const DANGLING_HARNESS_PREFIX: &str = "DANGLING_HARNESS_ID:";
/// Extract the missing harness id from a `DANGLING_HARNESS_ID:<id>` error.
/// Returns `None` for any other error string.
pub(crate) fn dangling_harness_id(error: &str) -> Option<&str> {
error.strip_prefix(DANGLING_HARNESS_PREFIX)
}
/// Convert a harness-resolution error to a user-facing sentence. Dangling
/// harness ids become an actionable message; other errors pass through.
pub(crate) fn user_facing_harness_error(error: &str) -> String {
match dangling_harness_id(error) {
Some(id) => format!(
"harness \"{id}\" was deleted — pick a new harness for this agent or restore the harness definition"
),
None => error.to_string(),
}
}
/// Summary-row display for a dangling harness id: shows the *missing* id so the agent list
/// tells the same story as spawn rather than silently falling back to the default command.
pub(crate) fn dangling_harness_display(id: &str) -> String {
format!("harness (deleted): {id}")
}
/// Spawn-time variant of `record_agent_command` that returns a typed error when
/// a record's `runtime` id or persona's `runtime` id is set but unresolvable
/// (definition deleted after agent was created). Returns `Err("DANGLING_HARNESS_ID:<id>")`.
/// When there is no runtime id at all, falls through to `default_agent_command()` intentionally.
pub fn try_record_agent_command(
record: &crate::managed_agents::types::ManagedAgentRecord,
personas: &[crate::managed_agents::types::AgentDefinition],
) -> Result<String, String> {
// Explicit pin always wins — if the user set a raw override, honour it.
if let Some(pin) = record
.agent_command_override
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
return Ok(pin.to_string());
}
// Record-level runtime id: if set but unresolvable → typed error.
if let Some(id) = record.runtime.as_deref() {
if let Some(cmd) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) {
return Ok(cmd.to_string());
}
if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id)
{
return Ok(def.command.clone());
}
return Err(format!("DANGLING_HARNESS_ID:{id}"));
}
// Persona-level runtime id.
if let Some(persona_id) = record.persona_id.as_deref() {
if let Some(persona) = personas.iter().find(|p| p.id == persona_id) {
if let Some(id) = persona.runtime.as_deref() {
if let Some(cmd) =
known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied())
{
return Ok(cmd.to_string());
}
if let Some(def) =
crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id)
{
return Ok(def.command.clone());
}
return Err(format!("DANGLING_HARNESS_ID:{id}"));
}
}
}
// No runtime id set — legacy agent; use the safe default.
Ok(default_agent_command())
}
fn default_agent_args(command: &str) -> Option<Vec<String>> {
match normalize_command_identity(command).as_str() {
"goose" => Some(vec!["acp".to_string()]),
"codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code"
| "claudecode" | "buzz-agent" => Some(Vec::new()),
_ => None,
}
}
pub fn normalize_agent_args(command: &str, agent_args: Vec<String>) -> Vec<String> {
let normalized = agent_args
.into_iter()
.map(|arg| arg.trim().to_string())
.filter(|arg| !arg.is_empty())
.collect::<Vec<_>>();
let Some(default_args) = default_agent_args(command) else {
return normalized;
};
if normalized.is_empty() {
return default_args;
}
if normalized.len() == 1 && normalized[0].eq_ignore_ascii_case("acp") && default_args.is_empty()
{
return default_args;
}
normalized
}
fn profile_target_dirs(root: &Path) -> [PathBuf; 2] {
if cfg!(debug_assertions) {
// `just dev` builds fresh debug sidecars; never prefer stale release output.
[root.join("target/debug"), root.join("target/release")]
} else {
[root.join("target/release"), root.join("target/debug")]
}
}
fn command_search_dirs() -> Vec<PathBuf> {
let mut dirs = profile_target_dirs(&workspace_root_dir()).to_vec();
if let Ok(current_dir) = std::env::current_dir() {
dirs.extend(profile_target_dirs(&current_dir));
}
dirs.extend(
std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf)),
);
dirs.into_iter().fold(Vec::new(), |mut unique, dir| {
if !unique.contains(&dir) {
unique.push(dir);
}
unique
})
}
fn is_executable_file(path: &Path) -> bool {
let Ok(metadata) = path.metadata() else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
fn resolve_workspace_command(command: &str) -> Option<PathBuf> {
if command_looks_like_path(command) {
let path = PathBuf::from(command);
return is_executable_file(&path).then_some(path);
}
let file_name = executable_basename(command);
command_search_dirs()
.into_iter()
.map(|dir| dir.join(&file_name))
.find(|candidate| is_executable_file(candidate))
}
fn resolve_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<PathBuf>>>
{
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<HashMap<String, Option<PathBuf>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Resolve a command to an absolute path, caching results for the app lifetime.
/// The cache eliminates redundant login-shell spawns when multiple agents share
/// the same binaries (e.g. `npx`, `uvx`).
pub fn resolve_command(command: &str) -> Option<PathBuf> {
if let Some(managed) = resolve_buzz_managed_command(command) {
return Some(managed);
}
let cache = resolve_cache();
// Fast path: return cached result without allocating a key.
if let Ok(guard) = cache.lock() {
if let Some(result) = guard.get(command) {
return result.clone();
}
}
// Slow path: resolve and cache.
let result = resolve_command_uncached(command);
if result.is_some() {
if let Ok(mut guard) = cache.lock() {
guard.insert(command.to_string(), result.clone());
}
}
result
}
/// Clear the resolve_command cache so that newly-installed binaries are detected.
pub fn clear_resolve_cache() {
let mut guard = resolve_cache().lock().unwrap_or_else(|e| e.into_inner());
guard.clear();
// Also invalidate the adapter-availability cache so a freshly-installed
// adapter is reflected the next time the summary builder checks the badge.
clear_adapter_availability_cache();
}
// ── Adapter availability cache (Phase-2 badge fallback) ─────────────────────
//
// `build_managed_agent_summary` needs to compare the spawn-time adapter
// availability against the *current* availability without triggering a live
// `probe_codex_acp_version` subprocess on every poll cycle. This cache
// stores the last availability status of the codex-acp binary at its resolved
// path. It is warmed by `discover_acp_runtimes` (which already probes), so
// the badge path reads warm data, and is invalidated by `clear_resolve_cache`
// (called on every Doctor install and every `discover_acp_providers` call).
fn adapter_availability_cache() -> &'static std::sync::Mutex<Option<AcpAvailabilityStatus>> {
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<Option<AcpAvailabilityStatus>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(None))
}
fn clear_adapter_availability_cache() {
if let Ok(mut guard) = adapter_availability_cache().lock() {
*guard = None;
}
}
/// Cache the current codex-acp adapter availability status.
///
/// Called by `discover_acp_runtimes` after it probes the codex adapter so the
/// badge path has a warm value without re-probing.
pub(crate) fn cache_adapter_availability(status: AcpAvailabilityStatus) {
if let Ok(mut guard) = adapter_availability_cache().lock() {
*guard = Some(status);
}
}
/// Return the most recently cached codex-acp adapter availability, or
/// `None` if no discovery has run yet.
///
/// This is a **read from cache only** — it never spawns a subprocess. The
/// value is populated by `discover_acp_runtimes` and invalidated by
/// `clear_resolve_cache`. When the cache is cold, returning `None` defers
/// the drift check until discovery has produced a real value, preventing
/// a fabricated `AdapterMissing` stamp from triggering a false restart badge
/// on a newly restarted process.
pub(crate) fn adapter_availability_cached() -> Option<AcpAvailabilityStatus> {
adapter_availability_cache()
.lock()
.ok()
.and_then(|g| g.clone())
}
/// Pure predicate: does the stamped adapter availability differ from the
/// current cached availability?
///
/// Returns `false` whenever either side is `None` (unknown) — "no data" is
/// not evidence of drift. This is extracted for unit testing without global
/// state and used by `build_managed_agent_summary`.
pub(crate) fn availability_drift(
stamped: Option<&AcpAvailabilityStatus>,
current: Option<AcpAvailabilityStatus>,
) -> bool {
match (stamped, current) {
(Some(s), Some(c)) => *s != c,
_ => false,
}
}
/// Return all candidate basenames for `command` on the current platform.
///
/// Always includes `executable_basename(command)` (appends `.exe` on Windows).
/// On Windows also includes `.cmd` and `.bat` variants so npm-generated shims
/// (e.g. `codex-acp.cmd` in `%APPDATA%\npm`) are discoverable.
fn command_basenames(command: &str) -> Vec<String> {
let candidates = vec![executable_basename(command)];
#[cfg(windows)]
{
let mut candidates = candidates;
if !command.contains('.') {
candidates.push(format!("{command}.cmd"));
candidates.push(format!("{command}.bat"));
}
return candidates;
}
#[allow(unreachable_code)]
candidates
}
fn resolve_buzz_managed_command(command: &str) -> Option<PathBuf> {
let basenames = command_basenames(command);
basenames
.iter()
.find_map(|basename| buzz_managed_command_path(command, basename))
}
fn resolve_command_uncached(command: &str) -> Option<PathBuf> {
if let Some(path) = resolve_workspace_command(command) {
return Some(path);
}
let basenames = command_basenames(command);
if command_looks_like_path(command) {
let path = PathBuf::from(command);
return path.exists().then_some(path);
}
if let Some(managed) = resolve_buzz_managed_command(command) {
return Some(managed);
}
for candidate in path_candidates_from_env(command) {
if is_executable_file(&candidate) {
return Some(candidate);
}
}
// On Windows, also scan PATH for .cmd/.bat shims (npm globals).
#[cfg(windows)]
{
for basename in command_basenames(command).iter().skip(1) {
for candidate in path_candidates_from_env_raw(basename) {
if candidate.is_file() {
return Some(candidate);
}
}
}
}
if let Some(path) = find_via_login_shell(command) {
return Some(path);
}
for dir in common_binary_paths() {
for basename in &basenames {
let candidate = dir.join(basename);
if is_executable_file(&candidate) {
return Some(candidate);
}
}
}
// Check nvm's default Node.js bin directory — nvm initializes via
// ~/.zshrc (interactive) which is not loaded by a login shell, so
// `node`, `npm`, and npm-global shims installed there are otherwise
// invisible.
if let Some(home) = dirs::home_dir() {
if let Some(nvm_bin) = find_nvm_default_bin(&home) {
for basename in &basenames {
let candidate = nvm_bin.join(basename);
if is_executable_file(&candidate) {
return Some(candidate);
}
}
}
}
None
}
fn path_candidates_from_env(command: &str) -> Vec<PathBuf> {
std::env::var_os("PATH")
.map(|paths| {
std::env::split_paths(&paths)
.map(|dir| dir.join(executable_basename(command)))
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
/// Like `path_candidates_from_env` but joins `basename` as-is (no `.exe` suffix).
/// Used for `.cmd`/`.bat` shim resolution on Windows.
#[cfg(windows)]
fn path_candidates_from_env_raw(basename: &str) -> Vec<PathBuf> {
std::env::var_os("PATH")
.map(|paths| {
std::env::split_paths(&paths)
.map(|dir| dir.join(basename))
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
/// Collect login shell candidates for the current platform.
///
/// On Unix: `/bin/zsh`, `/bin/bash` (the historical defaults).
/// On Windows: Git Bash via `resolve_bash_path` — skips `BUZZ_SHELL` because
/// login-shell callers use bash-only `-l -c` syntax.
fn login_shell_candidates() -> Vec<PathBuf> {
#[cfg(not(windows))]
{
vec![PathBuf::from("/bin/zsh"), PathBuf::from("/bin/bash")]
}
#[cfg(windows)]
{
super::git_bash::resolve_bash_path().into_iter().collect()
}
}
/// Run a command in a login shell (tries zsh then bash on Unix, Git Bash on Windows).
/// Returns trimmed stdout if the command succeeds with non-empty output.
fn run_in_login_shell(args: &[&str]) -> Option<String> {
for shell in login_shell_candidates() {
let mut cmd = Command::new(&shell);
cmd.args(args);
crate::util::configure_no_window(&mut cmd);
let Ok(output) = cmd.output() else {
continue;
};
if !output.status.success() {
continue;
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !stdout.is_empty() {
return Some(stdout);
}
}
None
}
fn find_via_login_shell(command: &str) -> Option<PathBuf> {
let stdout = run_in_login_shell(&["-l", "-c", r#"command -v -- "$1""#, "_", command])?;
let resolved = stdout.lines().rfind(|line| !line.trim().is_empty())?;
let path = PathBuf::from(resolved.trim());
(path.is_absolute() && is_executable_file(&path)).then_some(path)
}
/// Three-state backing store for the login-shell PATH cache.
#[derive(Clone)]
enum LoginShellPath {
/// Cache has never been populated; the next call will spawn a login shell.
Uninit,
/// A login shell was invoked; the inner value is the PATH it returned
/// (`None` when the shell produced no output).
Probed(Option<String>),
}
fn path_cache() -> &'static std::sync::Mutex<LoginShellPath> {
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<LoginShellPath>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(LoginShellPath::Uninit))
}
fn fetch_login_shell_path_inner() -> Option<String> {
// On Windows, Git Bash's `echo $PATH` returns POSIX colon-delimited paths
// (`/mingw64/bin:/c/Users/...`) which poison native Windows children that
// split on `;`. login_shell_path() feeds agent_models, runtime, and
// cli_probe — all native processes. Return None so they inherit the real
// Windows PATH instead.
#[cfg(windows)]
{
return None;
}
#[cfg(not(windows))]
{
let stdout = run_in_login_shell(&["-l", "-c", "echo $PATH"])?;
let last_line = stdout.lines().rfind(|l| !l.trim().is_empty())?;
Some(last_line.trim().to_string())
}
}
/// Return the user's full PATH from a login shell.
///
/// The result is cached after the first call. Call [`refresh_login_shell_path`]
/// to invalidate the cache so the next call re-fetches — e.g. after the user
/// installs Node.js mid-session and clicks Retry.
///
/// The lock is never held while the login shell spawns: we check for a cached
/// value, release the lock, run the shell, then re-lock to write. Two concurrent
/// callers may both run the shell (last-writer-wins is fine — both produce the
/// same result), but neither blocks a concurrent agent spawn on the Mutex.
pub fn login_shell_path() -> Option<String> {
// Fast path: return cached result without spawning a shell.
{
let guard = path_cache().lock().unwrap_or_else(|e| e.into_inner());
if let LoginShellPath::Probed(ref result) = *guard {
return result.clone();
}
}
// Slow path: spawn shell outside any lock.
let result = fetch_login_shell_path_inner();
// Write back; last-writer-wins is safe here.
{
let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner());
*guard = LoginShellPath::Probed(result.clone());
}
result
}
/// Invalidate the login-shell PATH cache so the next [`login_shell_path`] call
/// re-fetches from a fresh login shell.
///
/// Called before every install/retry operation and on Doctor Re-run so a
/// newly-installed tool becomes visible without restarting the app.
pub(crate) fn refresh_login_shell_path() {
let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner());
*guard = LoginShellPath::Uninit;
}
#[cfg(test)]
fn is_login_shell_path_uninit() -> bool {
matches!(
*path_cache().lock().unwrap_or_else(|e| e.into_inner()),
LoginShellPath::Uninit
)
}
/// Return `true` when `tag` is a safe nvm alias/version tag that can be joined
/// onto a `PathBuf` without escaping the nvm root.
///
/// nvm uses tags like `v22.1.0` or `lts/hydrogen`. We allow ASCII alphanumeric
/// plus `. - / _` and require that no path component is `..` and that the tag
/// does not start with `/` (which would replace the base in `PathBuf::join`).
fn is_safe_nvm_tag(tag: &str) -> bool {
if tag.is_empty() {
return false;
}
// An absolute path in the alias file would let PathBuf::join silently
// replace the nvm root with an attacker-controlled path.
if tag.starts_with('/') {
return false;
}
// Reject any .. component to prevent upward traversal.
for component in tag.split('/') {
if component == ".." {
return false;
}
}
// Allow only the characters nvm uses in real tag names.
tag.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '/' | '_'))
}
/// Locate the `bin` directory for nvm's default Node.js version.
///
/// Reads `~/.nvm/alias/default`; resolves at most one alias hop to handle
/// nvm alias chains; falls back to the highest-semver directory under
/// `~/.nvm/versions/node/`. Returns the `bin` subdirectory only when it exists.
///
/// Cheap: at most two file reads or one `read_dir`. Never cached — computed
/// fresh per call so a mid-session `nvm install` is visible at the next spawn.
pub fn find_nvm_default_bin(home: &Path) -> Option<PathBuf> {
let nvm_root = home.join(".nvm");
let versions_root = nvm_root.join("versions").join("node");
// 1. Try alias/default, with at most one hop.
let default_alias = nvm_root.join("alias").join("default");
if let Ok(content) = std::fs::read_to_string(&default_alias) {
let tag = content.trim().to_string();
if is_safe_nvm_tag(&tag) {
let candidate = versions_root.join(&tag).join("bin");
if candidate.is_dir() {
return Some(candidate);
}
// One alias hop: ~/.nvm/alias/<tag>
let hop_file = nvm_root.join("alias").join(&tag);
if let Ok(hop_content) = std::fs::read_to_string(&hop_file) {
let hop_tag = hop_content.trim().to_string();
if is_safe_nvm_tag(&hop_tag) {
let hop_candidate = versions_root.join(&hop_tag).join("bin");
if hop_candidate.is_dir() {
return Some(hop_candidate);
}
}
}
}
}
// 2. Fall back to highest-semver directory under ~/.nvm/versions/node/.
let entries = std::fs::read_dir(&versions_root).ok()?;
let best = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name();
let s = name.to_string_lossy().into_owned();
parse_semver_tag(&s).map(|v| (v, s))
})
.max_by(|(a, _), (b, _)| a.cmp(b));
let (_, tag) = best?;
let bin = versions_root.join(&tag).join("bin");
bin.is_dir().then_some(bin)
}
/// Parse a `vMAJ.MIN.PATCH` (or `vMAJ.MIN.PATCH-extra`) tag into a numeric
/// triple for semver comparison.
fn parse_semver_tag(s: &str) -> Option<(u64, u64, u64)> {
let s = s.strip_prefix('v')?;
let mut parts = s.splitn(3, '.');
let major = parts.next()?.parse::<u64>().ok()?;
let minor = parts.next()?.parse::<u64>().ok()?;
let patch_str = parts.next()?;
let patch = patch_str.split('-').next()?.parse::<u64>().ok()?;
Some((major, minor, patch))
}
pub(crate) fn find_command(command: &str) -> Option<PathBuf> {
resolve_command(command)
}
/// Returns true when the runtime has at least one adapter install step that
/// is an npm global install. Used to determine whether Node.js is required.
fn runtime_needs_npm(runtime: &KnownAcpRuntime) -> bool {
runtime
.adapter_install_commands
.iter()
.any(|cmd| is_npm_global_install(cmd))
}
/// Returns `true` when `cmd` is an npm global install/uninstall invocation.
///
/// Buzz rewrites these catalog commands to an app-private npm prefix before
/// execution; the global shape remains in the catalog so existing install plans
/// and Doctor's Node.js-required detection stay simple.
pub(crate) fn is_npm_global_install(cmd: &str) -> bool {
let t = cmd.trim_start();
t.starts_with("npm install -g ")
|| t.starts_with("npm i -g ")
|| t.starts_with("npm uninstall -g ")
}
/// Run a CLI auth probe with a 10-second process-level timeout.
///
/// Spawns the probe CLI as a child process. Stdout and stderr are drained on
/// background threads to prevent pipe-buffer deadlock. On timeout the child is
/// killed and `Unknown` is returned; no orphaned threads or processes are left
/// behind. Returns `Unknown` on timeout.
fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus {
use crate::managed_agents::readiness::cli_probe;
let augmented_path = cli_probe::augmented_path();
let mut command = std::process::Command::new(binary_path);
command.args(&probe_args[1..]);
if let Some(ref path) = augmented_path {
command.env("PATH", path);
}
command
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
crate::util::configure_no_window(&mut command);
let mut child = match command.spawn() {
Ok(c) => c,
Err(_) => return AuthStatus::Unknown,
};
// Drain stdout/stderr on background threads to prevent pipe-buffer deadlock.
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let stdout_thread = std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut pipe) = stdout_pipe {
let _ = pipe.read_to_end(&mut buf);
}
});
let stderr_thread = std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut pipe) = stderr_pipe {
let _ = pipe.read_to_end(&mut buf);
}
buf
});
// Save PID for kill-on-timeout before moving child into the wait thread.
let child_pid = child.id();
let (tx, rx) = std::sync::mpsc::channel();
let wait_thread = std::thread::spawn(move || {
let _ = tx.send(child.wait());
});
// 10-second timeout for auth probes.
let deadline = Instant::now() + Duration::from_secs(10);
let exit_status = loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
#[cfg(unix)]
unsafe {
libc::kill(child_pid as i32, libc::SIGTERM);
}
#[cfg(not(unix))]
let _ = child_pid;
drop(rx);
let _ = wait_thread.join();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
return AuthStatus::Unknown;
}
match rx.recv_timeout(Duration::from_millis(100).min(remaining)) {
Ok(Ok(status)) => break status,
Ok(Err(_)) => {
let _ = wait_thread.join();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
return AuthStatus::Unknown;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
let _ = stdout_thread.join();
let _ = stderr_thread.join();
return AuthStatus::Unknown;
}
}
};
let _ = wait_thread.join();
let _ = stdout_thread.join();
let stderr_bytes = stderr_thread.join().unwrap_or_default();
match cli_probe::classify_probe_output(&stderr_bytes, exit_status.success()) {
cli_probe::ProbeOutcome::LoggedIn => AuthStatus::LoggedIn,
cli_probe::ProbeOutcome::LoggedOut => AuthStatus::LoggedOut,
cli_probe::ProbeOutcome::ConfigInvalid { stderr_excerpt } => AuthStatus::ConfigInvalid {
diagnostic: stderr_excerpt,
},
}
}
pub fn command_availability(command: &str) -> CommandAvailabilityInfo {
let resolved_path = resolve_command(command).map(|path| path.display().to_string());
CommandAvailabilityInfo {
command: command.to_string(),
available: resolved_path.is_some(),
resolved_path,
}
}
pub fn missing_command_message(command: &str, role: &str) -> String {
if command_looks_like_path(command) {
return format!("{role} `{command}` does not exist.");
}
format!(
"{role} `{command}` was not found. Build the workspace binaries (`cargo build --release --workspace`) or add `target/release` to PATH as described in TESTING.md."
)
}
pub(crate) fn classify_runtime(
adapter_result: Option<(&str, PathBuf)>,
underlying_cli: Option<&str>,
underlying_cli_found: bool,
) -> (AcpAvailabilityStatus, Option<String>, Option<String>) {
if let Some((cmd, path)) = adapter_result {
if underlying_cli.is_some() && !underlying_cli_found {
(
AcpAvailabilityStatus::CliMissing,
Some(cmd.to_string()),
Some(path.display().to_string()),
)
} else {
(
AcpAvailabilityStatus::Available,
Some(cmd.to_string()),
Some(path.display().to_string()),
)
}
} else if underlying_cli.is_some() && underlying_cli_found {
(AcpAvailabilityStatus::AdapterMissing, None, None)
} else {
(AcpAvailabilityStatus::NotInstalled, None, None)
}
}
/// The oldest `codex-acp` version supported by Buzz managed agents.
///
/// Older 1.x adapters are detected successfully, but can still bundle a Codex runtime
/// that does not reliably give `buzz` CLI subprocesses outbound relay access.
///
/// Bump policy: raise this only when a newer adapter fixes a defect that breaks managed
/// agents, and only to a version already published on npm — every user below the floor is
/// offered a reinstall on their next discovery pass.
pub(crate) const MIN_CODEX_ACP_VERSION: (u64, u64, u64) = (1, 1, 7);
/// Probe the full version of a `codex-acp` binary by running `--version`.
///
/// The 1.x adapter (`@agentclientprotocol/codex-acp`) outputs
/// `@agentclientprotocol/codex-acp <major>.<minor>.<patch>` on stdout and exits 0.
/// The old 0.16.x adapter (`@zed-industries/codex-acp`) is a Rust binary that does
/// not recognise `--version` and exits non-zero.
///
/// Returns the `(major, minor, patch)` triple on success, `None` on any failure
/// (non-zero exit, unparseable output, timeout, or missing binary).
///
/// The parse is deliberately strict: exactly three numeric dot-separated components.
/// Partial versions (`1.2`) and prerelease tags (`1.2.0-rc1`) return `None` and so
/// classify as [`AcpAvailabilityStatus::AdapterOutdated`] — failing closed offers a
/// reinstall rather than running an adapter whose version cannot be compared.
///
/// The probe is bounded by a 5-second deadline. The child is polled with
/// [`std::process::Child::try_wait`] (the repo's standard deadline pattern) and
/// killed if it does not exit in time.
///
/// Stdout is redirected to a temporary file rather than a pipe, so forked
/// descendants cannot hold EOF open. Reads from a regular file return EOF at its
/// current write position regardless of inherited file descriptors, cross-platform.
pub(crate) fn probe_codex_acp_version(binary_path: &Path) -> Option<(u64, u64, u64)> {
probe_codex_acp_version_with_path(
binary_path,
crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(),
)
}
pub(crate) fn probe_codex_acp_version_with_path(
binary_path: &Path,
augmented_path: Option<&str>,
) -> Option<(u64, u64, u64)> {
use std::io::{Read as _, Seek as _, SeekFrom};
use std::time::{Duration, Instant};
const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
// A regular file returns EOF at its current size even when a descendant
// inherits its descriptor, bounding the post-exit read cross-platform.
let mut tmp = tempfile::tempfile().ok()?;
let mut command = Command::new(binary_path);
command.arg("--version");
if let Some(path) = augmented_path {
command.env("PATH", path);
}
crate::util::configure_no_window(&mut command);
let mut child = command
.stdout(tmp.try_clone().ok()?)
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
// Poll until the deadline rather than blocking on stdout EOF.
let deadline = Instant::now() + VERSION_PROBE_TIMEOUT;
let exit_status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(Duration::from_millis(50));
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
};
if !exit_status.success() {
return None;
}
// Read at most 4 KiB from the regular file without blocking.
tmp.seek(SeekFrom::Start(0)).ok()?;
let mut buf = Vec::with_capacity(128);
let _ = (&mut tmp as &mut dyn std::io::Read)
.take(4096)
.read_to_end(&mut buf);
let stdout = String::from_utf8_lossy(&buf);
// Output format: "<package-name> <major>.<minor>.<patch>"
let version_str = stdout.split_whitespace().last()?;
let mut components = version_str.split('.');
let major = components.next()?.parse::<u64>().ok()?;
let minor = components.next()?.parse::<u64>().ok()?;
let patch = components.next()?.parse::<u64>().ok()?;
if components.next().is_some() {
return None;
}
Some((major, minor, patch))
}
/// Classifies a resolved codex-acp binary path as [`AcpAvailabilityStatus::Available`]
/// or [`AcpAvailabilityStatus::AdapterOutdated`].
///
/// The 0.16.x adapter (`@zed-industries/codex-acp`) does not recognise `--version`
/// and exits non-zero — that probe failure yields `AdapterOutdated`. An adapter is
/// available only when its version is at least [`MIN_CODEX_ACP_VERSION`].
///
/// Used by `discover_acp_runtimes`, `cli_login_requirements`, and
/// `install_acp_runtime_blocking` so the version-gate logic is not duplicated.
pub(crate) fn codex_adapter_availability(path: &Path) -> AcpAvailabilityStatus {
match probe_codex_acp_version(path) {
Some(version) if version >= MIN_CODEX_ACP_VERSION => AcpAvailabilityStatus::Available,
_ => AcpAvailabilityStatus::AdapterOutdated,
}
}
/// Returns `true` when the codex-acp binary at `path` is below
/// [`MIN_CODEX_ACP_VERSION`] or cannot be probed using `augmented_path`. Thin wrapper
/// around [`codex_adapter_is_outdated_with_path`].
#[cfg(test)]
pub(crate) fn codex_adapter_is_outdated(path: &Path) -> bool {
codex_adapter_is_outdated_with_path(
path,
crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(),
)
}
/// Returns `true` when the codex-acp binary at `path` is below
/// [`MIN_CODEX_ACP_VERSION`] or cannot be probed with the supplied PATH.
pub(crate) fn codex_adapter_is_outdated_with_path(
path: &Path,
augmented_path: Option<&str>,
) -> bool {
!matches!(
probe_codex_acp_version_with_path(path, augmented_path),
Some(version) if version >= MIN_CODEX_ACP_VERSION
)
}
/// Intermediate struct built before the (potentially slow) auth probe phase.
struct PartialEntry {
runtime: &'static KnownAcpRuntime,
entry: AcpRuntimeCatalogEntry,
}
fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntry {
let adapter_result = runtime
.commands
.iter()
.find_map(|command| find_command(command).map(|path| (*command, path)));
let underlying_cli_found = runtime
.underlying_cli
.map(|cli| find_command(cli).is_some())
.unwrap_or(false);
let (mut availability, command, binary_path) =
classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found);
// For codex-acp: when the adapter resolves as Available, probe its full
// version. An adapter below MIN_CODEX_ACP_VERSION is treated as outdated.
if runtime.id == "codex"
&& availability == AcpAvailabilityStatus::Available
&& command.as_deref() == Some("codex-acp")
{
if let Some(path_str) = &binary_path {
availability = codex_adapter_availability(&PathBuf::from(path_str));
}
}
// Warm the adapter-availability cache for the badge fallback.
// The cache is scoped to the codex runtime; other runtimes leave it
// unchanged. Invalidated by `clear_resolve_cache`.
if runtime.id == "codex" {
cache_adapter_availability(availability.clone());
}
let underlying_cli_path = runtime
.underlying_cli
.and_then(find_command)
.map(|p| p.display().to_string());
let default_args = command
.as_deref()
.map(|cmd| normalize_agent_args(cmd, Vec::new()))
.unwrap_or_default();
let can_auto_install = !runtime.cli_install_commands_for_os().is_empty()
|| !runtime.adapter_install_commands.is_empty();
let cli_hint = runtime.cli_install_hint;
let adapter_hint = runtime.adapter_install_hint;
let install_hint = match availability {
AcpAvailabilityStatus::Available => cli_hint.to_string(),
AcpAvailabilityStatus::CliMissing => cli_hint.to_string(),
AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(),
AcpAvailabilityStatus::AdapterOutdated => adapter_hint.to_string(),
AcpAvailabilityStatus::NotInstalled => {
if !cli_hint.is_empty() && !adapter_hint.is_empty() {
format!("{cli_hint} {adapter_hint}")
} else if !cli_hint.is_empty() {
cli_hint.to_string()
} else {
adapter_hint.to_string()
}
}
};
let install_instructions_url = match availability {
AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::AdapterOutdated => {
runtime.adapter_install_instructions_url
}
AcpAvailabilityStatus::Available
| AcpAvailabilityStatus::CliMissing
| AcpAvailabilityStatus::NotInstalled => runtime.cli_install_instructions_url,
};
// node_required now means Buzz cannot provide npm for this platform.
// On supported desktop platforms, Buzz downloads a private Node/npm
// runtime into app data before running npm-backed adapter installs.
let node_required = matches!(
availability,
AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled
) && runtime_needs_npm(runtime)
&& buzz_managed_node_bin_dir().is_none()
&& resolve_command("npm").is_none()
&& resolve_command("node").is_none();
PartialEntry {
runtime,
entry: AcpRuntimeCatalogEntry {
id: runtime.id.to_string(),
label: runtime.label.to_string(),
avatar_url: runtime.avatar_url.to_string(),
availability,
command,
binary_path,
default_args,
mcp_command: runtime.mcp_command.map(str::to_string),
model_env_var: runtime.model_env_var.map(str::to_string),
provider_env_var: runtime.provider_env_var.map(str::to_string),
thinking_env_var: runtime.thinking_env_var.map(str::to_string),
max_tokens_env_var: runtime.max_tokens_env_var.map(str::to_string),
context_limit_env_var: runtime.context_limit_env_var.map(str::to_string),
max_rounds_env_var: runtime.max_rounds_env_var.map(str::to_string),
install_hint,
install_instructions_url: install_instructions_url.to_string(),
can_auto_install,
requires_external_cli: runtime.underlying_cli.is_some(),
underlying_cli_path,
node_required,
// Filled in by the auth-probe phase in full catalog discovery.
auth_status: AuthStatus::Unknown,
login_hint: None,
source: HarnessSource::Builtin,
// Builtin entries have no user-editable env; definition_env is empty.
definition_env: Default::default(),
},
}
}
/// Discover one runtime's filesystem availability without running any auth probes.
///
/// Post-install verification only needs to know whether the requested runtime
/// resolves, so it should not pay the cost of authenticating every catalog entry.
pub(crate) fn discover_acp_runtime_availability(runtime_id: &str) -> Option<AcpAvailabilityStatus> {
known_acp_runtime_exact(runtime_id)
.map(discover_acp_runtime_phase1)
.map(|partial| partial.entry.availability)
}
/// Discover all ACP runtimes, optionally merging user-defined custom harnesses
/// from `custom_harnesses_dir`.
///
/// This is the primary entry point used by the Tauri command layer. It:
/// 1. Builds entries for all compiled-in (`Builtin`) runtimes.
/// 2. Runs auth probes in parallel.
/// 3. Inserts static `Preset` entries (PATH-probed, `source: Preset`).
/// 4. If `custom_harnesses_dir` is `Some`, loads `*.json` files from that
/// directory and appends `Custom` entries — no auth probe, command resolved
/// via PATH, availability is `Available` or `NotInstalled`.
///
/// The custom dir is re-scanned on every call (goose `refresh_custom_providers`
/// pattern) — no caching, no restart needed to pick up new files.
///
/// After building the catalog, updates the loaded-harness registry so spawn
/// and readiness paths can resolve preset/custom harness commands without
/// re-running discovery.
pub fn discover_acp_runtimes_from(
custom_harnesses_dir: Option<&Path>,
) -> Vec<AcpRuntimeCatalogEntry> {
// Phase 1: build all builtin entries (fast — no probes yet).
let mut partials: Vec<PartialEntry> = KNOWN_ACP_RUNTIMES
.iter()
.map(discover_acp_runtime_phase1)
.collect();
// Phase 2: run auth probes in parallel for entries that need them.
// Spawn one thread per probeable entry; total cost = max(probe latency).
let probe_handles: Vec<(usize, std::thread::JoinHandle<AuthStatus>)> = partials
.iter()
.enumerate()
.filter_map(|(idx, partial)| {
if partial.entry.availability != AcpAvailabilityStatus::Available {
return None;
}
let probe_args = partial.runtime.auth_probe_args?;
// Need the resolved binary path for the CLI (e.g. the actual `claude` binary).
let binary_path = resolve_command(probe_args[0])?;
let probe_args_owned: Vec<String> = probe_args.iter().map(|s| s.to_string()).collect();
let handle = std::thread::spawn(move || {
let refs: Vec<&str> = probe_args_owned.iter().map(String::as_str).collect();
probe_auth_status(&binary_path, &refs)
});
Some((idx, handle))
})
.collect();
// Collect probe results and patch entries.
for (idx, handle) in probe_handles {
let status = handle.join().unwrap_or(AuthStatus::Unknown);
let partial = &mut partials[idx];
partial.entry.login_hint =
if matches!(status, AuthStatus::LoggedIn | AuthStatus::NotApplicable) {
None
} else {
partial.runtime.login_hint.map(str::to_string)
};
partial.entry.auth_status = status;
}
// Fill NotApplicable / Unknown for non-probed entries.
for partial in &mut partials {
if partial.entry.auth_status == AuthStatus::Unknown {
partial.entry.auth_status = if partial.entry.availability
== AcpAvailabilityStatus::Available
&& partial.runtime.auth_probe_args.is_none()
{
AuthStatus::NotApplicable
} else {
AuthStatus::Unknown
};
}
}
let mut entries: Vec<AcpRuntimeCatalogEntry> = partials.into_iter().map(|p| p.entry).collect();
// Track all ids seen so far (builtins) to prevent preset/custom collisions.
let mut seen_ids: std::collections::HashSet<String> =
entries.iter().map(|e| e.id.clone()).collect();
// Phase 2.5: insert static preset entries (PATH-probed, not editable/deletable).
for def in PRESET_HARNESSES {
if seen_ids.contains(def.id) {
// Builtin or earlier preset shadowed this id — skip silently.
continue;
}
seen_ids.insert(def.id.to_string());
entries.push(preset_catalog_entry(def, find_command));
}
// Phase 3: load and append custom harness definitions.
if let Some(dir) = custom_harnesses_dir {
// The loader applies collision + duplicate filtering at the boundary,
// so anything it returns is safe to surface in the catalog. Builtin
// shadowing is impossible here (check_id_collision covers builtins and
// presets); `seen_ids` guards only same-run duplicates.
for def in crate::managed_agents::custom_harnesses::load_custom_harnesses(dir) {
if !seen_ids.insert(def.id.clone()) {
tracing::warn!("custom_harnesses: skipping duplicate id {:?}", def.id);
continue;
}
// Availability: command on PATH → Available, else NotInstalled.
let (availability, command, binary_path) = match find_command(&def.command) {
Some(path) => (
AcpAvailabilityStatus::Available,
Some(def.command.clone()),
Some(path.display().to_string()),
),
None => (AcpAvailabilityStatus::NotInstalled, None, None),
};
let default_args = normalize_agent_args(&def.command, def.args.clone());
entries.push(AcpRuntimeCatalogEntry {
id: def.id.clone(),
label: def.label.clone(),
// F1 security fix: never copy user-supplied avatar URL into the catalog.
// All icons are bundled assets; customs fall back to TerminalSquare in the UI.
avatar_url: String::new(),
availability,
command,
binary_path,
default_args,
// Custom harnesses are plain ACP — no MCP sidecar, no env-var
// model switching, no thinking knobs.
mcp_command: None,
model_env_var: None,
provider_env_var: None,
thinking_env_var: None,
max_tokens_env_var: None,
context_limit_env_var: None,
max_rounds_env_var: None,
install_hint: def.install_hint.clone(),
install_instructions_url: def.install_instructions_url.clone(),
// Security line: custom definitions carry no install scripts.
can_auto_install: false,
requires_external_cli: false,
underlying_cli_path: None,
node_required: false,
// No auth probe for custom harnesses.
auth_status: AuthStatus::NotApplicable,
login_hint: None,
source: HarnessSource::Custom,
// Carry definition env into the catalog so the edit form can
// read it back — prevents silently erasing env on save.
definition_env: def.env.clone(),
});
}
}
// Publish the loaded-harness registry from a FRESH directory read under the
// persist mutex — never from the snapshot taken before the auth probes ran.
// A save/delete landing during Phase 2 already re-warmed the registry; a
// stale-snapshot publish here would clobber it (the just-saved harness
// would become unresolvable at spawn until the next discovery).
//
// This exact line is pinned by `discovery_publish_path_survives_mid_flight_save`
// / `..._drops_mid_flight_delete` (discovery tests), which land a save/delete
// through the pre-publish test hook below and red if this reverts to
// publishing a stale snapshot.
#[cfg(test)]
pre_publish_test_hook::run();
crate::managed_agents::custom_harnesses::warm_harness_registry_locked(custom_harnesses_dir);
entries
}
/// Test-only seam: a callback invoked between discovery's directory scan and
/// its registry publish, so tests can land a `save_and_warm`/`delete_and_warm`
/// in exactly the window the stale-snapshot bug lived in — through the REAL
/// `discover_acp_runtimes_from` call path, not a hand-called seam.
#[cfg(test)]
pub(crate) mod pre_publish_test_hook {
use std::sync::{Mutex, OnceLock};
type Hook = Box<dyn Fn() + Send>;
fn cell() -> &'static Mutex<Option<Hook>> {
static CELL: OnceLock<Mutex<Option<Hook>>> = OnceLock::new();
CELL.get_or_init(|| Mutex::new(None))
}
/// Install (or clear, with `None`) the hook. Callers must serialize via
/// `registry_test_lock` — the hook is process-global.
pub(crate) fn set(hook: Option<Hook>) {
*cell().lock().unwrap_or_else(|e| e.into_inner()) = hook;
}
pub(crate) fn run() {
if let Some(hook) = cell().lock().unwrap_or_else(|e| e.into_inner()).as_ref() {
hook();
}
}
}
pub fn managed_agent_avatar_url(command: &str) -> Option<String> {
let runtime = known_acp_runtime(command)?;
Some(runtime.avatar_url.to_string())
}
#[cfg(test)]
mod tests;