From 0458c49c150ec6a6e7a19dd0e330167871124e2b Mon Sep 17 00:00:00 2001 From: spfcraze <108240075+spfcraze@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:44:08 -0400 Subject: [PATCH] feat(desktop): discover and spawn ACP harnesses installed in WSL on Windows When a preset or custom harness command is missing from the Windows PATH, harness discovery now probes the default WSL distribution (~/.local/bin, ~/bin, /usr/local/bin, then the distro PATH) and marks the harness Available with a wsl:// binary path. At spawn, Desktop hands the in-distro path to buzz-acp (BUZZ_ACP_AGENT_WSL_PATH, optional BUZZ_ACP_AGENT_WSL_DISTRO), which wraps the agent launch through wsl.exe -e and forwards all agent-intended env vars across the boundary via a computed WSLENV merge. BUZZ_ACP_AGENT_COMMAND keeps the bare command identity so per-runtime defaults (e.g. HERMES_ACP_SKIP_CONFIGURED_MCP) apply unchanged. This unblocks running Hermes Agent (Linux-first install, hermes-acp) from Buzz Desktop on Windows without a hand-authored custom harness. Docs: docs/windows-wsl-harnesses.md Signed-off-by: Frank Saunders --- crates/buzz-acp/src/acp.rs | 272 ++++++++++++++++- .../src-tauri/src/managed_agents/discovery.rs | 18 +- desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src-tauri/src/managed_agents/runtime.rs | 24 +- desktop/src-tauri/src/managed_agents/wsl.rs | 277 ++++++++++++++++++ docs/windows-wsl-harnesses.md | 57 ++++ 6 files changed, 643 insertions(+), 6 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/wsl.rs create mode 100644 docs/windows-wsl-harnesses.md diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8a698954a0..18bca21f8c 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -456,8 +456,23 @@ impl AcpClient { ) -> Result { use std::process::Stdio; - let mut cmd = tokio::process::Command::new(command); - cmd.args(args) + // ── WSL agent wrap ────────────────────────────────────────────────── + // When the host (Buzz Desktop on Windows) located the agent inside a + // WSL distribution rather than on the Windows PATH, it sets + // BUZZ_ACP_AGENT_WSL_PATH to the in-distro absolute path. The bare + // command does not exist on Windows, so the agent must be launched + // through wsl.exe; WSL interop bridges the stdio pipes, leaving the + // NDJSON channel byte-transparent. `command` keeps its original + // identity so default_agent_env / default_agent_args still apply. + let wsl_wrap = wsl_spawn_wrap(); + let (program, prefix_args): (String, Vec) = match &wsl_wrap { + Some(wrap) => (wrap.program.clone(), wrap.prefix_args()), + None => (command.to_string(), Vec::new()), + }; + + let mut cmd = tokio::process::Command::new(&program); + cmd.args(&prefix_args) + .args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) // Inherit stderr so agent logs are visible in the harness terminal. @@ -513,6 +528,43 @@ impl AcpClient { cmd.env("CODEX_CONFIG", merged); } + // WSL wrap: forward every agent-intended env var across the WSL + // boundary. wsl.exe children only import Windows-side variables whose + // names appear in WSLENV, so without this the agent would silently + // lose both the per-runtime defaults (e.g. HERMES_ACP_SKIP_CONFIGURED_MCP) + // and persona env (e.g. GOOSE_PROVIDER). Keys are forwarded whether + // the value came from injection or from the inherited parent env — + // either way it is present in the wsl.exe process environment. + if let Some(wrap) = &wsl_wrap { + let mut forward_keys: Vec = crate::config::default_agent_env(command) + .iter() + .map(|(key, _)| (*key).to_string()) + .collect(); + for (key, _) in extra_env { + if key == "WSLENV" { + continue; + } + forward_keys.push(key.clone()); + } + // An explicit persona WSLENV wins as the merge base; otherwise + // inherit the harness's own WSLENV (may itself carry flags). + let base = extra_env + .iter() + .find(|(key, _)| key == "WSLENV") + .map(|(_, value)| value.clone()) + .or_else(|| std::env::var("WSLENV").ok()); + let merged = merge_wslenv(base.as_deref(), &forward_keys); + if !merged.is_empty() { + cmd.env("WSLENV", merged); + } + tracing::info!( + program = %wrap.program, + linux_path = %wrap.linux_path, + distro = ?wrap.distro, + "spawning agent through WSL" + ); + } + // Spawn the agent in its own process group so SIGKILL doesn't propagate // to the harness's own process group on Unix. // tokio::process::Command::process_group is a stable tokio API (no extra imports needed). @@ -2206,6 +2258,127 @@ fn kill_process_group(_pid: u32) -> bool { false } +/// Resolved WSL spawn target: launch `program` (wsl.exe) with +/// [`WslSpawnWrap::prefix_args`] followed by the agent's normal args. +#[derive(Debug, Clone, PartialEq, Eq)] +struct WslSpawnWrap { + /// wsl.exe to launch — System32 absolute path when available, else the + /// bare name for standard CreateProcess search. + program: String, + /// Optional non-default distro (`wsl.exe -d `). + distro: Option, + /// Agent's absolute path inside the distro (e.g. `/home/u/.local/bin/hermes-acp`). + linux_path: String, +} + +impl WslSpawnWrap { + /// Argument prefix that makes wsl.exe exec the in-distro agent binary. + fn prefix_args(&self) -> Vec { + let mut prefix = Vec::with_capacity(4); + if let Some(distro) = &self.distro { + prefix.push("-d".to_string()); + prefix.push(distro.clone()); + } + prefix.push("-e".to_string()); + prefix.push(self.linux_path.clone()); + prefix + } +} + +/// Pure core of [`wsl_spawn_wrap`], factored for unit testing: validate the +/// desktop-supplied contract values and decide whether to wrap. +fn build_wsl_wrap( + linux_path: Option<&str>, + distro: Option<&str>, + host_is_windows: bool, +) -> Option { + let linux_path = linux_path?.trim(); + // Only absolute POSIX paths are meaningful to `wsl.exe -e`; anything else + // (empty, relative, a Windows path) indicates a broken contract — refuse + // to wrap rather than spawn a confusing wsl.exe error. + if linux_path.is_empty() || !linux_path.starts_with('/') { + return None; + } + let distro = distro + .map(str::trim) + .filter(|d| !d.is_empty()) + .map(str::to_string); + if !host_is_windows { + tracing::warn!( + linux_path, + "BUZZ_ACP_AGENT_WSL_PATH is set on a non-Windows host — ignoring WSL wrap" + ); + return None; + } + Some(WslSpawnWrap { + program: wsl_exe_program(), + distro, + linux_path: linux_path.to_string(), + }) +} + +/// Read the desktop → buzz-acp WSL contract from the process environment. +/// +/// Buzz Desktop sets `BUZZ_ACP_AGENT_WSL_PATH` (absolute in-distro path of the +/// agent command) — and optionally `BUZZ_ACP_AGENT_WSL_DISTRO` — when harness +/// discovery resolved the agent through its WSL fallback instead of the +/// Windows PATH. See `managed_agents/wsl.rs` on the desktop side. +fn wsl_spawn_wrap() -> Option { + build_wsl_wrap( + std::env::var("BUZZ_ACP_AGENT_WSL_PATH").ok().as_deref(), + std::env::var("BUZZ_ACP_AGENT_WSL_DISTRO").ok().as_deref(), + cfg!(windows), + ) +} + +/// Prefer the System32 copy of wsl.exe; fall back to the bare name (standard +/// CreateProcess search order) when SystemRoot is unavailable. System32 first +/// avoids the WindowsApps app-execution-alias stubs (see block/buzz#2328). +#[cfg(windows)] +fn wsl_exe_program() -> String { + std::env::var_os("SystemRoot") + .map(|root| { + std::path::PathBuf::from(root) + .join("System32") + .join("wsl.exe") + }) + .filter(|p| p.is_file()) + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "wsl.exe".to_string()) +} + +#[cfg(not(windows))] +fn wsl_exe_program() -> String { + // Never used at runtime (build_wsl_wrap refuses non-Windows hosts), but + // must exist so the cfg!(windows) branch above type-checks everywhere. + "wsl.exe".to_string() +} + +/// Merge env var names into a WSLENV forwarding list. +/// +/// `existing` may carry a pre-set WSLENV value, including per-entry flags +/// (`VAR/p`, `VAR/l`, `VAR/u`) — entries are preserved verbatim and compared +/// by their name part only, so a flagged entry is never duplicated by a +/// bare-name forward of the same variable. +fn merge_wslenv(existing: Option<&str>, keys: &[String]) -> String { + let mut entries: Vec = existing + .unwrap_or("") + .split(':') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_string) + .collect(); + for key in keys { + let already = entries + .iter() + .any(|entry| entry.split('/').next() == Some(key.as_str())); + if !already { + entries.push(key.clone()); + } + } + entries.join(":") +} + /// Suppress the console window that Windows otherwise allocates for every /// console-subsystem child process spawned from a GUI (non-console) parent. /// No-op on non-Windows platforms. @@ -2846,6 +3019,101 @@ mod tests { ); } + // ── WSL spawn wrap (pure helpers) ─────────────────────────────────────── + + #[test] + fn wsl_prefix_args_execs_linux_path_in_default_distro() { + let wrap = WslSpawnWrap { + program: r"C:\Windows\System32\wsl.exe".to_string(), + distro: None, + linux_path: "/home/rat/.local/bin/hermes-acp".to_string(), + }; + assert_eq!( + wrap.prefix_args(), + ["-e", "/home/rat/.local/bin/hermes-acp"] + ); + } + + #[test] + fn wsl_prefix_args_prefixes_distro_when_set() { + let wrap = WslSpawnWrap { + program: "wsl.exe".to_string(), + distro: Some("Ubuntu-24.04".to_string()), + linux_path: "/usr/local/bin/omp".to_string(), + }; + assert_eq!( + wrap.prefix_args(), + ["-d", "Ubuntu-24.04", "-e", "/usr/local/bin/omp"] + ); + } + + #[test] + fn build_wsl_wrap_accepts_absolute_posix_path_on_windows() { + let wrap = build_wsl_wrap(Some("/home/rat/.local/bin/hermes-acp"), Some(" "), true) + .expect("valid contract should wrap"); + assert_eq!(wrap.linux_path, "/home/rat/.local/bin/hermes-acp"); + // Whitespace-only distro is treated as unset. + assert_eq!(wrap.distro, None); + } + + #[test] + fn build_wsl_wrap_rejects_broken_contract_values() { + for bad in [ + "", + " ", + "hermes-acp", + "usr/local/bin/hermes-acp", + r"C:\tools\hermes.exe", + ] { + assert_eq!( + build_wsl_wrap(Some(bad), None, true), + None, + "expected rejection: {bad:?}" + ); + } + assert_eq!(build_wsl_wrap(None, None, true), None); + } + + #[test] + fn build_wsl_wrap_refuses_non_windows_hosts() { + assert_eq!( + build_wsl_wrap(Some("/home/rat/.local/bin/hermes-acp"), None, false), + None + ); + } + + #[test] + fn merge_wslenv_appends_missing_keys_preserving_order() { + let keys = vec![ + "HERMES_ACP_SKIP_CONFIGURED_MCP".to_string(), + "GOOSE_PROVIDER".to_string(), + ]; + assert_eq!( + merge_wslenv(None, &keys), + "HERMES_ACP_SKIP_CONFIGURED_MCP:GOOSE_PROVIDER" + ); + assert_eq!( + merge_wslenv(Some("PATH/l"), &keys), + "PATH/l:HERMES_ACP_SKIP_CONFIGURED_MCP:GOOSE_PROVIDER" + ); + } + + #[test] + fn merge_wslenv_never_duplicates_flagged_entries() { + let keys = vec!["GOPATH".to_string(), "GOPROXY".to_string()]; + // GOPATH already present with a /p flag — must not be re-added bare. + assert_eq!(merge_wslenv(Some("GOPATH/p"), &keys), "GOPATH/p:GOPROXY"); + } + + #[test] + fn merge_wslenv_tolerates_empty_and_colon_noise() { + let keys = vec!["A".to_string()]; + assert_eq!(merge_wslenv(Some(""), &keys), "A"); + assert_eq!(merge_wslenv(Some("::"), &keys), "A"); + assert_eq!(merge_wslenv(Some("B::C:"), &keys), "B:C:A"); + assert_eq!(merge_wslenv(None, &[]), ""); + } + async fn spawn_script(script: &str) -> AcpClient { AcpClient::spawn("bash", &["-c".into(), script.into()], &[], false) .await diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index c8a85be34a..fd587250e3 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -1736,6 +1736,17 @@ pub fn discover_acp_runtimes_from( let mut seen_ids: std::collections::HashSet = entries.iter().map(|e| e.id.clone()).collect(); + // Resolver shared by the preset and custom phases: Windows PATH first, + // then — on Windows hosts — the default WSL distribution, so harnesses + // that only exist inside WSL (common for Hermes Agent) still surface as + // Available. The WSL probe caches its resolution for the spawn path. + let resolve_with_wsl = |command: &str| { + find_command(command).or_else(|| { + crate::managed_agents::wsl::probe_wsl_command(command) + .map(|resolution| crate::managed_agents::wsl::wsl_display_path(&resolution)) + }) + }; + // Phase 2.5: insert static preset entries (PATH-probed, not editable/deletable). for def in PRESET_HARNESSES { if seen_ids.contains(def.id) { @@ -1744,7 +1755,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, &resolve_with_wsl)); } // Phase 3: load and append custom harness definitions. @@ -1759,8 +1770,9 @@ pub fn discover_acp_runtimes_from( continue; } - // Availability: command on PATH → Available, else NotInstalled. - let (availability, command, binary_path) = match find_command(&def.command) { + // Availability: command on PATH (or inside the default WSL + // distribution on Windows) → Available, else NotInstalled. + let (availability, command, binary_path) = match resolve_with_wsl(&def.command) { Some(path) => ( AcpAvailabilityStatus::Available, Some(def.command.clone()), diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index be9b07cf11..2d8ed85c37 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -36,6 +36,7 @@ pub(crate) mod team_events; mod team_repair; mod teams; mod types; +pub(crate) mod wsl; // Shared guard for tests that mutate or read process-global PATH. #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index f3b4cb67fd..d251081d0b 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -541,10 +541,26 @@ pub fn spawn_agent_child( } }; // Resolve agent command to a full path (DMG launches have minimal PATH). - let resolved_agent_command = resolve_command(effective_command) + let host_resolution = resolve_command(effective_command); + let host_resolved = host_resolution.is_some(); + let resolved_agent_command = host_resolution .map(|p| p.display().to_string()) .unwrap_or_else(|| effective_command.clone()); + // WSL fallback: when the command does not exist on the host PATH but + // discovery located it inside the default WSL distribution, hand the + // in-distro path to buzz-acp so it can wrap the agent spawn through + // wsl.exe. BUZZ_ACP_AGENT_COMMAND keeps the original bare identity so + // buzz-acp's per-runtime defaults (e.g. Hermes MCP-startup isolation) + // still apply; WSLENV forwarding happens on the buzz-acp side, where the + // injected key set is known. The probe is cache-first, so this is free + // when discovery already ran. + let wsl_resolution = if host_resolved { + None + } else { + crate::managed_agents::wsl::probe_wsl_command(effective_command) + }; + // The caller supplies the explicit canonical pair relay. This is the only // relay this child may connect to, regardless of the record/workspace default. let effective_relay_url = runtime_key.relay_url.clone(); @@ -582,6 +598,12 @@ pub fn spawn_agent_child( command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); + if let Some(resolution) = &wsl_resolution { + command.env("BUZZ_ACP_AGENT_WSL_PATH", &resolution.linux_path); + if let Some(distro) = &resolution.distro { + command.env("BUZZ_ACP_AGENT_WSL_DISTRO", distro); + } + } match &resolved_mcp_command { Some(mcp_cmd) => { command.env("BUZZ_ACP_MCP_COMMAND", mcp_cmd); diff --git a/desktop/src-tauri/src/managed_agents/wsl.rs b/desktop/src-tauri/src/managed_agents/wsl.rs new file mode 100644 index 0000000000..e55b9fb7e6 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/wsl.rs @@ -0,0 +1,277 @@ +//! WSL fallback discovery for ACP harness commands on Windows. +//! +//! Many agent CLIs (Hermes Agent, Oh My Pi, …) are commonly installed inside +//! a Windows Subsystem for Linux distribution rather than on the Windows host. +//! When a preset or custom harness command cannot be resolved on the Windows +//! PATH, discovery falls back to probing the *default* WSL distribution via +//! `wsl.exe -e `. A positive result is cached so the spawn path +//! (`runtime.rs`) can hand the in-distro path to `buzz-acp` through +//! `BUZZ_ACP_AGENT_WSL_PATH`; `buzz-acp` then wraps the agent spawn through +//! `wsl.exe` and forwards injected environment via `WSLENV`. +//! +//! Design notes: +//! * Only bare command names are probed (`hermes-acp`, `omp`, …). Absolute or +//! relative paths are never sent through the probe — a Windows path has no +//! meaning inside the distro and would be a quoting/injection hazard. +//! * Probing always targets the *default* distribution (`wsl.exe -e` without +//! `-d`). Multi-distro selection is future work; the `distro` field exists +//! so the spawn contract does not need to change when it lands. +//! * All pure helpers (script building, stdout parsing, display paths) are +//! platform-agnostic so the unit tests run on any host, mirroring the +//! `git_bash.rs` pattern. Process-spawning probes are `cfg(windows)`-gated +//! and return `None` everywhere else. + +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; + +/// A harness command located inside the default WSL distribution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WslCommandResolution { + /// Distro name. Always `None` today (probing targets the default distro); + /// carried so the spawn contract can grow multi-distro support without + /// changing the `BUZZ_ACP_AGENT_WSL_*` env surface. + pub distro: Option, + /// Absolute path of the command inside the distro + /// (e.g. `/home/user/.local/bin/hermes-acp`). + pub linux_path: String, +} + +/// Return true when `command` is a bare executable name safe to embed in the +/// probe script: `[A-Za-z0-9._-]+` with no path separators. Paths (Windows or +/// POSIX shaped) are rejected — probing for them inside WSL is meaningless. +#[cfg(any(windows, test))] +fn is_probeable_command(command: &str) -> bool { + !command.is_empty() + && command + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) +} + +/// Build the POSIX sh probe executed inside the default distribution. +/// +/// Well-known per-user install locations are checked first (`~/.local/bin` +/// is where the Hermes installer and `pip install --user` land, and it is +/// NOT on the non-login PATH WSL gives `wsl.exe -e sh`), then the distro's +/// own PATH via `command -v`. Prints the first match on stdout; prints +/// nothing when the command is absent. +/// +/// Extracted as a pure function so tests can pin the probe contract. +#[cfg(any(windows, test))] +fn wsl_probe_script(command: &str) -> String { + debug_assert!(is_probeable_command(command)); + format!( + "for p in \"$HOME/.local/bin/{command}\" \"$HOME/bin/{command}\" \"/usr/local/bin/{command}\"; do \ + [ -x \"$p\" ] && {{ printf '%s\\n' \"$p\"; exit 0; }}; \ + done; \ + command -v {command} 2>/dev/null || true" + ) +} + +/// Parse probe stdout into an in-distro absolute path. +/// +/// Takes the first non-empty line that is an absolute POSIX path. Anything +/// else (blank output, relative `command -v` oddities, WSL interop noise) +/// means "not found". +#[cfg(any(windows, test))] +fn parse_probe_stdout(stdout: &str) -> Option { + stdout + .lines() + .map(str::trim) + .find(|line| line.starts_with('/')) + .map(str::to_string) +} + +/// Display path surfaced in the catalog (`binary_path`) for a WSL-resolved +/// command. Purely informational — the real resolution travels through the +/// cache into the spawn path. +pub(crate) fn wsl_display_path(resolution: &WslCommandResolution) -> PathBuf { + match &resolution.distro { + Some(distro) => PathBuf::from(format!("wsl://{distro}{}", resolution.linux_path)), + None => PathBuf::from(format!("wsl://{}", resolution.linux_path)), + } +} + +fn wsl_cache() -> &'static Mutex>> { + static CACHE: OnceLock>>> = + OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + +/// Probe the default WSL distribution for `command`, caching the outcome +/// (positive or negative) for the app lifetime — mirroring `resolve_command`'s +/// cache contract so repeated discovery runs stay cheap. +/// +/// Returns `None` on non-Windows hosts, when `wsl.exe` is unavailable, when +/// `command` is not a bare executable name, and when the probe finds nothing. +pub(crate) fn probe_wsl_command(command: &str) -> Option { + if let Ok(guard) = wsl_cache().lock() { + if let Some(cached) = guard.get(command) { + return cached.clone(); + } + } + + let result = probe_wsl_command_uncached(command); + + if let Ok(mut guard) = wsl_cache().lock() { + guard.insert(command.to_string(), result.clone()); + } + result +} + +#[cfg(windows)] +fn probe_wsl_command_uncached(command: &str) -> Option { + if !is_probeable_command(command) { + return None; + } + let wsl = resolve_wsl_exe()?; + let mut cmd = std::process::Command::new(wsl); + cmd.args(["-e", "sh", "-c", &wsl_probe_script(command)]); + crate::util::configure_no_window(&mut cmd); + let output = cmd.output().ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + let linux_path = parse_probe_stdout(&stdout)?; + Some(WslCommandResolution { + distro: None, + linux_path, + }) +} + +#[cfg(not(windows))] +fn probe_wsl_command_uncached(_command: &str) -> Option { + None +} + +/// Resolve `wsl.exe` itself: the System32 copy first, then a PATH scan that +/// skips the WindowsApps app-execution-alias stubs (see issue #2328 — those +/// stubs are launchers, not the real binary). +#[cfg(windows)] +fn resolve_wsl_exe() -> Option { + if let Some(root) = std::env::var_os("SystemRoot") { + let candidate = PathBuf::from(root).join("System32").join("wsl.exe"); + if candidate.is_file() { + return Some(candidate); + } + } + std::env::var_os("PATH").and_then(|paths| { + std::env::split_paths(&paths) + .map(|dir| dir.join("wsl.exe")) + .find(|candidate| { + candidate.is_file() && !super::git_bash::is_windows_apps_alias(candidate) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn probeable_accepts_bare_command_names() { + for command in [ + "hermes-acp", + "omp", + "grok", + "claude_agent-acp", + "amp-acp.cmd", + ] { + assert!( + is_probeable_command(command), + "expected probeable: {command}" + ); + } + } + + #[test] + fn probeable_rejects_paths_and_shell_metacharacters() { + for command in [ + "", + "/usr/local/bin/hermes-acp", + r"C:\tools\hermes-acp.exe", + "./hermes-acp", + "../escape", + "hermes-acp; rm -rf /", + "hermes-acp$(whoami)", + "hermes acp", + "hermes-acp\nevil", + ] { + assert!( + !is_probeable_command(command), + "expected rejected: {command:?}" + ); + } + } + + #[test] + fn probe_script_checks_user_bins_before_path() { + let script = wsl_probe_script("hermes-acp"); + // ~/.local/bin must be probed explicitly: it is not on the non-login + // PATH that `wsl.exe -e sh` receives, and it is where the Hermes + // installer drops hermes-acp. + let local_bin = script + .find("$HOME/.local/bin/hermes-acp") + .expect("~/.local/bin probe"); + let command_v = script + .find("command -v hermes-acp") + .expect("command -v fallback"); + assert!( + local_bin < command_v, + "well-known bins must precede PATH probe" + ); + // No format!/interpolation artifacts, no login shell (login shells can + // print profile noise that would corrupt parse_probe_stdout). + assert!( + !script.contains("sh -l"), + "probe must not use a login shell" + ); + } + + #[test] + fn parse_stdout_takes_first_absolute_path_line() { + assert_eq!( + parse_probe_stdout("/home/rat/.local/bin/hermes-acp\n"), + Some("/home/rat/.local/bin/hermes-acp".to_string()) + ); + // Noise lines are skipped in favour of the first absolute path. + assert_eq!( + parse_probe_stdout("some warning\r\n/usr/local/bin/omp\n"), + Some("/usr/local/bin/omp".to_string()) + ); + // Blank / relative-only output means not found. + assert_eq!(parse_probe_stdout(""), None); + assert_eq!(parse_probe_stdout("hermes-acp\n"), None); + } + + #[test] + fn display_path_carries_wsl_scheme() { + let resolution = WslCommandResolution { + distro: None, + linux_path: "/home/rat/.local/bin/hermes-acp".to_string(), + }; + assert_eq!( + wsl_display_path(&resolution).display().to_string(), + "wsl:///home/rat/.local/bin/hermes-acp" + ); + let with_distro = WslCommandResolution { + distro: Some("Ubuntu".to_string()), + ..resolution + }; + assert_eq!( + wsl_display_path(&with_distro).display().to_string(), + "wsl://Ubuntu/home/rat/.local/bin/hermes-acp" + ); + } + + #[test] + fn probe_caches_negative_results() { + // A non-probeable command short-circuits to None and must be cached — + // a second call must not re-run the (here: non-Windows, no-op) probe. + assert_eq!(probe_wsl_command("definitely/not a command"), None); + assert!(wsl_cache() + .lock() + .expect("cache lock") + .contains_key("definitely/not a command")); + } +} diff --git a/docs/windows-wsl-harnesses.md b/docs/windows-wsl-harnesses.md new file mode 100644 index 0000000000..2e9e665469 --- /dev/null +++ b/docs/windows-wsl-harnesses.md @@ -0,0 +1,57 @@ +# Running ACP harnesses from WSL on Windows + +Buzz Desktop discovers ACP harness commands (Hermes Agent's `hermes-acp`, Oh +My Pi's `omp`, custom harnesses, …) by resolving them on the host PATH. On +Windows, many of these CLIs are installed inside a **Windows Subsystem for +Linux (WSL)** distribution instead of on the Windows host — Hermes Agent, for +example, is a Linux-first install. Without help, Doctor shows such harnesses +as *Not installed* even though they work fine inside WSL. + +Buzz bridges this automatically. + +## How it works + +1. **Discovery fallback.** When a preset or custom harness command is not + found on the Windows PATH, Buzz probes the *default* WSL distribution with + `wsl.exe -e sh -c …`, checking `~/.local/bin`, `~/bin`, `/usr/local/bin`, + and finally the distro PATH (`command -v`). A hit marks the harness + **Available** with a `wsl://…` binary path in Doctor. +2. **Spawn wrapping.** At agent start, Buzz Desktop passes the in-distro path + to `buzz-acp` via `BUZZ_ACP_AGENT_WSL_PATH` (and optionally + `BUZZ_ACP_AGENT_WSL_DISTRO`). `buzz-acp` launches the agent through + `wsl.exe -e `; WSL interop bridges the stdio pipes, so the + ACP/NDJSON channel is byte-transparent. +3. **Environment forwarding.** Windows processes do not automatically share + environment variables with WSL. `buzz-acp` computes a `WSLENV` list of + every variable it injects for the agent (per-runtime defaults such as + `HERMES_ACP_SKIP_CONFIGURED_MCP`, persona env such as `GOOSE_PROVIDER`) so + they arrive inside the Linux process. A pre-existing `WSLENV` (including + flagged entries like `GOPATH/p`) is preserved and merged. + +`BUZZ_ACP_AGENT_COMMAND` keeps the original bare command identity +(`hermes-acp`), so all per-runtime defaults keyed on the command name keep +working unchanged. + +## Verifying + +Doctor → harness catalog: a WSL-only harness shows *Available* with a binary +path like `wsl:///home//.local/bin/hermes-acp`. Starting an agent on it +logs `spawning agent through WSL` from `buzz-acp`. + +## Limits + +- Only the **default** WSL distribution is probed today. The spawn contract + (`BUZZ_ACP_AGENT_WSL_DISTRO`) already supports naming a distro; multi-distro + discovery is future work. +- Only bare command names are probed (never paths), and probing runs at most + once per command per app launch (cached). +- Auth probes and install scripts for tier-1 built-in runtimes still assume a + host-native install; the WSL fallback covers preset and custom harnesses. + +## Manual alternative (no rebuild) + +The same result is possible without this feature by registering a custom +harness (`/custom_harnesses/hermes-wsl.json`) that runs +`C:\Windows\System32\wsl.exe` with args `["-e", "/home//.local/bin/hermes-acp"]` +and env `{"HERMES_ACP_SKIP_CONFIGURED_MCP": "1", "WSLENV": "HERMES_ACP_SKIP_CONFIGURED_MCP"}`. +The native fallback exists so users don't have to hand-author this.