feat(desktop): let preset harnesses carry adapter auto-install

Preset (Tier-2) harnesses were docs-link only by construction:
`preset_catalog_entry` hardcoded `can_auto_install: false`, and the
installer resolved runtimes through the builtins-only
`known_acp_runtime_exact`, so `amp` returned "unknown runtime".

Install commands are now per-entry data on `PresetHarness`, with
`can_auto_install` and `node_required` derived from them — adding a
second preset later is one catalog line. `AcpInstallPlan` projects the
four things the installer consumes from either tier behind one lookup,
so neither static type grows the other's fields. Presets structurally
carry no CLI install commands: their `underlying_cli` is a status probe,
never an install target.

Only Amp is populated (`npm install -g amp-acp`). Tier-3 custom
harnesses resolve to no plan at all — their install hint is user-typed
text, and a one-click button on it would be arbitrary code execution.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-07-26 16:06:20 -04:00
co-authored by Will Pfleger
parent a1ee998c6e
commit 060cfc13fd
4 changed files with 360 additions and 37 deletions
+18 -3
View File
@@ -333,7 +333,13 @@ const overrides = new Map([
// absent, so AdapterMissing replaces the misleading NotInstalled. Includes
// the deliberate-divergence doc comments; net after the inline preset
// entries.push block collapsed into the helper.
["src-tauri/src/managed_agents/discovery.rs", 1835],
// +101: preset auto-install — PresetHarness.adapter_install_commands (per-entry,
// populated for Amp only) drives can_auto_install and node_required, the
// node_required predicate is extracted and shared with the builtin path, and
// AcpInstallPlan / acp_install_plan project the installer's slice of either
// tier behind one lookup so presets resolve without widening KnownAcpRuntime.
// Includes the tier-boundary doc comments.
["src-tauri/src/managed_agents/discovery.rs", 1936],
// BYOH — save_custom_harness_to_dir (backup-swap atomic write) + save_and_warm /
// delete_and_warm (persist-mutex serialization for concurrent-safe registry
// refresh, B-6). Also: id/collision/load/registry tests (from the file base) +
@@ -378,7 +384,12 @@ const overrides = new Map([
// Available both-present AND adapter-present/CLI-absent — the selectability
// regression guard), bound to an injectable resolver so the tests stay
// PATH-independent.
["src-tauri/src/managed_agents/discovery/tests.rs", 1871],
// +155: preset auto-install coverage — can_auto_install derivation (both
// arms), node_required on managed-node-supported AND unsupported platforms,
// the available-adapter and docs-only negatives, acp_install_plan resolution
// for preset/builtin/custom-id, and a scope guard asserting Amp is the only
// preset carrying install commands.
["src-tauri/src/managed_agents/discovery/tests.rs", 2026],
// identity-import-keyring: the identity resolution state machine's behavioral
// matrix (46 tests over FakeIdentityStore — probe × marker × file cells,
// adoption / read-back-corruption / marker-failure arms, recovery-mode
@@ -616,7 +627,11 @@ const overrides = new Map([
// return value so the frontend immediately has the updated env.
// +1: rebase over main (#2680) — requires_external_cli: false added to
// save_custom_harness catalog entry construction (new required field).
["src-tauri/src/commands/agent_discovery.rs", 2167],
// +52: preset auto-install — install_acp_runtime_blocking resolves through
// acp_install_plan (builtins then presets) instead of known_acp_runtime_exact,
// plus tests pinning that Amp takes the plain adapter-missing arm and that
// Phase 1 no-ops for a preset whose vendor CLI is absent.
["src-tauri/src/commands/agent_discovery.rs", 2219],
// draft-persistence predicate: submit-time `loadDraft` check + inline comment
// + deps-array entry in submitMessage closes the never-persisted-boundary
// defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to
@@ -294,7 +294,7 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
}
let _guard = Guard(runtime_id.to_string());
let runtime = crate::managed_agents::known_acp_runtime_exact(runtime_id)
let plan = crate::managed_agents::acp_install_plan(runtime_id)
.ok_or_else(|| format!("unknown runtime: {runtime_id}"))?;
let mut steps = Vec::new();
@@ -303,9 +303,13 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
// 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.
if let Some(cli) = runtime.underlying_cli {
//
// Presets carry no CLI install commands, so an absent vendor CLI is a
// no-op here, not a failure: their adapter phase can still run (Amp's
// `amp-acp` installs and resolves whether or not `amp` is present).
if let Some(cli) = plan.underlying_cli {
if crate::managed_agents::resolve_command(cli).is_none() {
for cmd in runtime.cli_install_commands_for_os() {
for cmd in plan.cli_install_commands {
let result = run_install_command_with_retry("cli", cmd);
let success = result.success;
steps.push(result);
@@ -325,15 +329,15 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
// For the codex runtime, "found" is not enough — the resolved binary must also
// pass the 1.x version gate. An outdated 0.16.x adapter must be overwritten by
// the new npm install so the CODEX_CONFIG spawn contract works correctly.
let adapter_path = runtime
.commands
let adapter_path = plan
.adapter_commands
.iter()
.find_map(|cmd| crate::managed_agents::resolve_command(cmd));
let adapter_probe_path = crate::managed_agents::readiness::cli_probe::augmented_path();
if let Some(cmds) = plan_adapter_install(
runtime_id,
adapter_path.as_deref(),
runtime.adapter_install_commands,
plan.adapter_install_commands,
adapter_probe_path.as_deref(),
) {
let use_managed_npm =
@@ -1481,6 +1485,54 @@ mod tests {
);
}
// ── preset (Tier-2) install resolution ───────────────────────────────────
#[test]
fn test_amp_preset_takes_plain_adapter_missing_arm() {
// `plan_adapter_install`'s only special case is codex. Amp must land on
// the adapter-missing arm and get its catalog command verbatim — no
// uninstall-first sequence.
let plan = crate::managed_agents::acp_install_plan("amp").expect("amp resolves");
let planned = plan_adapter_install(
"amp",
None,
plan.adapter_install_commands,
Some("/usr/bin:/bin"),
);
assert_eq!(planned, Some(vec!["npm install -g amp-acp"]));
}
#[test]
fn test_amp_preset_with_adapter_present_needs_no_install() {
// Non-codex + resolved adapter → nothing to do. Pins that the preset
// does not inherit codex's version-probe arm.
let bin = std::path::Path::new("/usr/local/bin/amp-acp");
assert!(
plan_adapter_install("amp", Some(bin), &["npm install -g amp-acp"], None).is_none(),
"a present preset adapter must not trigger a reinstall"
);
}
#[test]
fn test_preset_phase_one_no_ops_when_vendor_cli_absent() {
// Amp's underlying_cli is Some("amp") but presets carry no CLI install
// commands, so the Phase-1 loop body must never execute — an absent
// `amp` is a no-op, not an error and not an install attempt.
let plan = crate::managed_agents::acp_install_plan("amp").expect("amp resolves");
assert!(plan.underlying_cli.is_some());
assert_eq!(
plan.cli_install_commands.len(),
0,
"iterating cli_install_commands for a preset must yield no steps"
);
}
#[test]
fn test_unknown_runtime_has_no_install_plan() {
// The guard that keeps custom (Tier-3) harnesses out of the installer.
assert!(crate::managed_agents::acp_install_plan("definitely-not-a-runtime").is_none());
}
// ── should_restart_after_install ─────────────────────────────────────────
/// Setup-mode agent on matching runtime that is now Ready → restart.
+119 -18
View File
@@ -992,13 +992,34 @@ 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
/// True when no npm is reachable for an npm-backed install: Buzz cannot
/// provide its private Node runtime on this platform AND the machine has no
/// system `npm`/`node`.
fn npm_unavailable() -> bool {
buzz_managed_node_bin_dir().is_none()
&& resolve_command("npm").is_none()
&& resolve_command("node").is_none()
}
/// `node_required` means Buzz cannot provide npm for this platform, so an
/// npm-backed install cannot possibly succeed and Doctor must send the user
/// to nodejs.org instead of offering a toggle.
///
/// Only adapter commands are considered: CLI install steps are curl-pipe
/// installers, never npm. `npm_unavailable` is passed in rather than probed
/// so the platform branch is testable on any machine.
fn node_required(
availability: &AcpAvailabilityStatus,
adapter_install_commands: &[&str],
npm_unavailable: bool,
) -> bool {
matches!(
availability,
AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled
) && adapter_install_commands
.iter()
.any(|cmd| is_npm_global_install(cmd))
&& npm_unavailable
}
/// Returns `true` when `cmd` is an npm global install/uninstall invocation.
@@ -1363,13 +1384,11 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr
// 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();
let node_required = node_required(
&availability,
runtime.adapter_install_commands,
npm_unavailable(),
);
PartialEntry {
runtime,
@@ -1405,10 +1424,18 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr
///
/// Post-install verification only needs to know whether the requested runtime
/// resolves, so it should not pay the cost of authenticating every catalog entry.
///
/// Covers presets as well as builtins: any runtime the installer can act on
/// must also be verifiable, or a successful preset install reports as failed.
/// The preset arm builds the full catalog entry and keeps only the
/// availability — deliberately, so verification can never disagree with what
/// the catalog will show the user a moment later.
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)
if let Some(runtime) = known_acp_runtime_exact(runtime_id) {
return Some(discover_acp_runtime_phase1(runtime).entry.availability);
}
preset_harness_exact(runtime_id)
.map(|def| preset_catalog_entry(def, find_command, npm_unavailable()).availability)
}
// ── Tier-2 preset harnesses ────────────────────────────────────────────────
@@ -1436,6 +1463,15 @@ struct PresetHarness {
/// the `CliMissing` copy would tell the user to install the adapter
/// they already have. `None` when the command IS the vendor CLI.
underlying_cli: Option<&'static str>,
/// Shell commands that install this preset's ACP adapter, run in order by
/// `install_acp_runtime_blocking`. Empty — the default — means no
/// auto-install: the Doctor toggle stays disabled and the card is
/// docs-link only. npm-global shapes are rewritten to Buzz's private npm
/// prefix before execution, exactly as for the builtins.
///
/// There is deliberately no CLI-install counterpart: a preset's
/// `underlying_cli` is a status probe, never an install target.
adapter_install_commands: &'static [&'static str],
}
/// Build the catalog entry for one preset harness through an injectable
@@ -1448,9 +1484,14 @@ struct PresetHarness {
/// distinguish `AdapterMissing` (vendor CLI present) from `NotInstalled`
/// (neither found). See the `underlying_cli` field doc for why the full
/// `classify_runtime` predicate is deliberately not used here.
///
/// `npm_unavailable` is injected for the same reason as `resolve`: it is a
/// platform probe, and the `node_required` branch it drives has to be
/// exercised on both a managed-node-supported and an unsupported platform.
fn preset_catalog_entry(
def: &PresetHarness,
resolve: impl Fn(&str) -> Option<PathBuf>,
npm_unavailable: bool,
) -> AcpRuntimeCatalogEntry {
let (availability, command, binary_path) = match resolve(def.command) {
Some(path) => (
@@ -1480,6 +1521,8 @@ fn preset_catalog_entry(
def.args.iter().map(|s| s.to_string()).collect(),
);
let node_required = node_required(&availability, def.adapter_install_commands, npm_unavailable);
AcpRuntimeCatalogEntry {
id: def.id.to_string(),
label: def.label.to_string(),
@@ -1495,7 +1538,7 @@ fn preset_catalog_entry(
thinking_env_var: None,
install_hint: def.install_hint.to_string(),
install_instructions_url: def.install_instructions_url.to_string(),
can_auto_install: false,
can_auto_install: !def.adapter_install_commands.is_empty(),
// Kept false even for adapter presets: presets carry one flat
// install_hint (the adapter's), so the requiresExternalCli
// "CLI is missing" wording would pair the wrong noun with it.
@@ -1503,7 +1546,7 @@ fn preset_catalog_entry(
// consumer of the true case.
requires_external_cli: false,
underlying_cli_path,
node_required: false,
node_required,
auth_status: AuthStatus::NotApplicable,
login_hint: None,
source: HarnessSource::Preset,
@@ -1521,6 +1564,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://cursor.com/downloads",
install_hint: "Install Cursor from cursor.com/downloads.",
underlying_cli: None,
adapter_install_commands: &[],
},
PresetHarness {
id: "omp",
@@ -1530,6 +1574,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://github.com/can1357/oh-my-pi",
install_hint: "Install Oh My Pi from github.com/can1357/oh-my-pi.",
underlying_cli: None,
adapter_install_commands: &[],
},
PresetHarness {
id: "grok",
@@ -1539,6 +1584,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://build.x.ai/docs",
install_hint: "Install Grok Build from build.x.ai.",
underlying_cli: None,
adapter_install_commands: &[],
},
PresetHarness {
id: "opencode",
@@ -1548,6 +1594,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://opencode.ai/docs",
install_hint: "Install OpenCode from opencode.ai/docs.",
underlying_cli: None,
adapter_install_commands: &[],
},
PresetHarness {
id: "kimi",
@@ -1557,6 +1604,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://kimi.ai/download",
install_hint: "Install Kimi Code from kimi.ai/download.",
underlying_cli: None,
adapter_install_commands: &[],
},
PresetHarness {
id: "amp",
@@ -1566,6 +1614,11 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://github.com/tao12345666333/amp-acp",
install_hint: "Install the amp-acp npm adapter: npm install -g amp-acp.",
underlying_cli: Some("amp"),
// The only preset with an auto-install today. `amp-acp` is the npm
// package's own bin name, so the installed global shim is exactly
// the `command` probed above. The vendor `amp` CLI is NOT installed
// here — see the field doc.
adapter_install_commands: &["npm install -g amp-acp"],
},
PresetHarness {
id: "hermes",
@@ -1575,6 +1628,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
install_instructions_url: "https://hermes-agent.nousresearch.com",
install_hint: "Install Hermes Agent from hermes-agent.nousresearch.com.",
underlying_cli: None,
adapter_install_commands: &[],
},
PresetHarness {
id: "openclaw",
@@ -1591,6 +1645,7 @@ const PRESET_HARNESSES: &[PresetHarness] = &[
needs BUZZ_* credentials at execution time, set them on the \
Gateway's own environment separately.",
underlying_cli: None,
adapter_install_commands: &[],
},
];
@@ -1616,6 +1671,50 @@ pub(crate) fn preset_harness_definitions(
.collect()
}
fn preset_harness_exact(id: &str) -> Option<&'static PresetHarness> {
PRESET_HARNESSES.iter().find(|p| p.id == id)
}
/// The install-time slice of a runtime, projected from either tier.
///
/// `install_acp_runtime_blocking` consumes exactly these four things, so one
/// lookup can serve builtins and presets without either static type growing
/// the other's fields — they differ deliberately (see
/// `PresetHarness::underlying_cli`). This is the narrow intersection.
pub(crate) struct AcpInstallPlan {
/// Vendor CLI to probe before the adapter phase.
pub underlying_cli: Option<&'static str>,
/// CLI install steps, run only when `underlying_cli` is absent. Always
/// empty for presets, whose vendor CLI is installed out of band.
pub cli_install_commands: &'static [&'static str],
/// Adapter install steps. Empty means no auto-install.
pub adapter_install_commands: &'static [&'static str],
/// Commands to probe for an already-installed adapter.
pub adapter_commands: &'static [&'static str],
}
/// Resolve the install plan for `runtime_id`, checking builtins then presets.
///
/// `None` for an unknown id — including custom (Tier-3) harnesses, whose
/// install hint is user-typed text and never an executable plan.
pub(crate) fn acp_install_plan(runtime_id: &str) -> Option<AcpInstallPlan> {
if let Some(runtime) = known_acp_runtime_exact(runtime_id) {
return Some(AcpInstallPlan {
underlying_cli: runtime.underlying_cli,
cli_install_commands: runtime.cli_install_commands_for_os(),
adapter_install_commands: runtime.adapter_install_commands,
adapter_commands: runtime.commands,
});
}
let preset = preset_harness_exact(runtime_id)?;
Some(AcpInstallPlan {
underlying_cli: preset.underlying_cli,
cli_install_commands: &[],
adapter_install_commands: preset.adapter_install_commands,
adapter_commands: std::slice::from_ref(&preset.command),
})
}
/// Return the static slice of preset harness IDs.
///
/// Used by `check_id_collision` in `custom_harnesses` to derive the reserved-ID
@@ -1712,6 +1811,8 @@ pub fn discover_acp_runtimes_from(
entries.iter().map(|e| e.id.clone()).collect();
// Phase 2.5: insert static preset entries (PATH-probed, not editable/deletable).
// The npm probe is platform state, identical for every preset — resolve once.
let npm_unavailable = npm_unavailable();
for def in PRESET_HARNESSES {
if seen_ids.contains(def.id) {
// Builtin or earlier preset shadowed this id — skip silently.
@@ -1719,7 +1820,7 @@ pub fn discover_acp_runtimes_from(
}
seen_ids.insert(def.id.to_string());
entries.push(preset_catalog_entry(def, find_command));
entries.push(preset_catalog_entry(def, find_command, npm_unavailable));
}
// Phase 3: load and append custom harness definitions.
@@ -2,13 +2,14 @@ use std::path::PathBuf;
use super::overrides::{divergent_agent_command_override, update_time_agent_command_override};
use super::{
apply_agent_command_update, classify_runtime, codex_adapter_availability,
acp_install_plan, apply_agent_command_update, classify_runtime, codex_adapter_availability,
codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command,
effective_agent_command, find_nvm_default_bin, find_via_login_shell,
is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args,
parse_semver_tag, preset_catalog_entry, probe_codex_acp_major_version, record_agent_command,
refresh_login_shell_path, try_record_agent_command, PresetHarness, BUZZ_AGENT_AVATAR_URL,
CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, node_required,
normalize_agent_args, parse_semver_tag, preset_catalog_entry, preset_harness_ids,
probe_codex_acp_major_version, record_agent_command, refresh_login_shell_path,
try_record_agent_command, PresetHarness, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL,
CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
};
use crate::managed_agents::AcpAvailabilityStatus;
@@ -199,13 +200,37 @@ const ADAPTER_PRESET: PresetHarness = PresetHarness {
install_instructions_url: "https://example.com/install",
install_hint: "Install the amp-acp npm adapter.",
underlying_cli: Some("amp"),
adapter_install_commands: &["npm install -g amp-acp"],
};
/// Build a preset entry on a platform where npm IS reachable — the case every
/// availability/wording test below cares about. The `node_required` platform
/// branch has its own dedicated tests.
fn preset_entry(
def: &PresetHarness,
resolve: impl Fn(&str) -> Option<PathBuf>,
) -> crate::managed_agents::AcpRuntimeCatalogEntry {
preset_catalog_entry(def, resolve, false)
}
/// Docs-link-only preset: the command IS the vendor CLI and Buzz installs
/// nothing. The shape of every preset except Amp.
const DOCS_ONLY_PRESET: PresetHarness = PresetHarness {
id: "docs-only-test",
label: "Docs Only Test",
command: "vendor-cli",
args: &["acp"],
install_instructions_url: "https://example.com/install",
install_hint: "Install Vendor CLI from example.com.",
underlying_cli: None,
adapter_install_commands: &[],
};
#[test]
fn preset_entry_adapter_missing_when_underlying_cli_present() {
// Vendor CLI resolves, adapter does not — the state Tyler's Amp
// hand-test hit. Must NOT degrade to the misleading NotInstalled.
let entry = preset_catalog_entry(&ADAPTER_PRESET, |cmd| {
let entry = preset_entry(&ADAPTER_PRESET, |cmd| {
(cmd == "amp").then(|| PathBuf::from("/usr/local/bin/amp"))
});
assert_eq!(entry.availability, AcpAvailabilityStatus::AdapterMissing);
@@ -221,7 +246,7 @@ fn preset_entry_adapter_missing_when_underlying_cli_present() {
#[test]
fn preset_entry_not_installed_when_both_missing() {
let entry = preset_catalog_entry(&ADAPTER_PRESET, |_| None);
let entry = preset_entry(&ADAPTER_PRESET, |_| None);
assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled);
assert!(entry.underlying_cli_path.is_none());
assert!(!entry.requires_external_cli);
@@ -229,7 +254,7 @@ fn preset_entry_not_installed_when_both_missing() {
#[test]
fn preset_entry_available_when_adapter_and_cli_present() {
let entry = preset_catalog_entry(&ADAPTER_PRESET, |cmd| match cmd {
let entry = preset_entry(&ADAPTER_PRESET, |cmd| match cmd {
"amp-acp" => Some(PathBuf::from("/usr/local/bin/amp-acp")),
"amp" => Some(PathBuf::from("/usr/local/bin/amp")),
_ => None,
@@ -250,7 +275,7 @@ fn preset_entry_stays_available_when_adapter_present_but_cli_absent() {
// FULL classify_runtime predicate would flip this to CliMissing
// (unselectable, with backwards install copy) — the adapter-missing
// arm is the only one presets consume.
let entry = preset_catalog_entry(&ADAPTER_PRESET, |cmd| {
let entry = preset_entry(&ADAPTER_PRESET, |cmd| {
(cmd == "amp-acp").then(|| PathBuf::from("/usr/local/bin/amp-acp"))
});
assert_eq!(entry.availability, AcpAvailabilityStatus::Available);
@@ -267,12 +292,142 @@ fn preset_entry_without_underlying_cli_stays_simple() {
underlying_cli: None,
..ADAPTER_PRESET
};
let entry = preset_catalog_entry(&preset, |_| None);
let entry = preset_entry(&preset, |_| None);
assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled);
assert!(!entry.requires_external_cli);
assert!(entry.underlying_cli_path.is_none());
}
// ── Preset auto-install (Tier-2) ────────────────────────────────────────────
#[test]
fn test_preset_with_adapter_install_commands_can_auto_install() {
let entry = preset_entry(&ADAPTER_PRESET, |_| None);
assert!(
entry.can_auto_install,
"a preset carrying install commands must offer the Doctor toggle"
);
}
#[test]
fn test_preset_without_adapter_install_commands_cannot_auto_install() {
// The docs-link-only shape: every preset except Amp. Nothing to run, so
// the toggle must stay disabled rather than invoking a no-op install.
let entry = preset_entry(&DOCS_ONLY_PRESET, |_| None);
assert!(!entry.can_auto_install);
}
#[test]
fn test_installable_preset_node_required_when_npm_unavailable() {
// Buzz cannot provide npm on this platform and none is on PATH: Doctor
// must send the user to nodejs.org instead of offering a toggle that
// cannot possibly succeed.
let entry = preset_catalog_entry(&ADAPTER_PRESET, |_| None, true);
assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled);
assert!(entry.node_required);
}
#[test]
fn test_installable_preset_not_node_required_when_npm_available() {
let entry = preset_catalog_entry(&ADAPTER_PRESET, |_| None, false);
assert!(!entry.node_required);
}
#[test]
fn test_docs_only_preset_never_node_required() {
// No npm-backed install to gate, so the nodejs.org nudge would be noise
// even on a platform with no npm at all.
let entry = preset_catalog_entry(&DOCS_ONLY_PRESET, |_| None, true);
assert!(!entry.node_required);
}
#[test]
fn test_available_preset_not_node_required_despite_missing_npm() {
// The adapter already resolves; nothing will be installed, so the
// platform's npm situation is irrelevant.
let entry = preset_catalog_entry(
&ADAPTER_PRESET,
|cmd| (cmd == "amp-acp").then(|| PathBuf::from("/usr/local/bin/amp-acp")),
true,
);
assert_eq!(entry.availability, AcpAvailabilityStatus::Available);
assert!(!entry.node_required);
}
#[test]
fn test_amp_preset_carries_npm_adapter_install() {
// Pins the one populated entry in the shipped catalog. `amp-acp` is both
// the probed command and the npm package's bin name, so the installed
// global shim is exactly what discovery looks for.
let plan = acp_install_plan("amp").expect("amp must resolve as installable");
assert_eq!(plan.adapter_install_commands, ["npm install -g amp-acp"]);
assert_eq!(plan.adapter_commands, ["amp-acp"]);
assert_eq!(plan.underlying_cli, Some("amp"));
}
#[test]
fn test_every_other_preset_has_no_install_commands() {
// Scope guard: Will's ruling is adapters only, and Amp is the only ACP
// adapter on npm today. This reds if a vendor-CLI preset gains an
// install command without that ruling being revisited.
for id in preset_harness_ids() {
if *id == "amp" {
continue;
}
let plan = acp_install_plan(id).expect("every preset id must resolve");
assert!(
plan.adapter_install_commands.is_empty(),
"preset {id} must stay docs-link only"
);
}
}
#[test]
fn test_preset_install_plan_carries_no_cli_install_commands() {
// A preset's underlying_cli is a status probe, never an install target:
// Phase 1 of the installer must find nothing to run and fall through to
// the adapter phase rather than erroring.
let plan = acp_install_plan("amp").expect("amp must resolve");
assert!(plan.underlying_cli.is_some());
assert!(
plan.cli_install_commands.is_empty(),
"presets never install the vendor CLI"
);
}
#[test]
fn test_builtin_install_plan_still_resolves_through_shared_lookup() {
// Builtins must keep their CLI install steps and full command list.
let plan = acp_install_plan("codex").expect("codex must resolve");
assert_eq!(plan.underlying_cli, Some("codex"));
assert!(!plan.cli_install_commands.is_empty());
assert!(plan
.adapter_install_commands
.iter()
.any(|cmd| cmd.contains("codex-acp")));
assert!(plan.adapter_commands.contains(&"codex-acp"));
}
#[test]
fn test_custom_harness_id_has_no_install_plan() {
// Tier-3 boundary: custom harnesses carry a user-typed install hint.
// Resolving one here would wire a one-click button to a user-supplied
// shell string. `None` is what keeps `can_auto_install` false for them.
assert!(acp_install_plan("my-custom-harness").is_none());
assert!(acp_install_plan("").is_none());
}
#[test]
fn test_node_required_ignores_non_npm_install_commands() {
// CLI installers are curl-pipes; they need no npm, so a missing npm must
// not gate them.
assert!(!node_required(
&AcpAvailabilityStatus::NotInstalled,
&["curl -fsSL https://example.com/install.sh | bash"],
true,
));
}
fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agents::AgentDefinition {
crate::managed_agents::AgentDefinition {
id: id.to_string(),