diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 40c4333a11..f6c5bf8d39 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -536,21 +536,36 @@ fn persist_last_error_on_install( save_managed_agents(app, &records) } -/// Build a login-shell `Command` for `command` with hermit env vars stripped, -/// Buzz-managed npm locations set, and the user's PATH set. This is the -/// single source of truth for -/// the shell selection and environment cleanup shared by `run_install_command` -/// and managed npm install path — keeping them in sync so the hermit-strip list -/// can't drift between command execution paths. +/// Build an install `Command` for `command` with hermit env vars stripped, +/// Buzz-managed npm locations set, and the user's PATH set. This is the single +/// source of truth for the shell selection and environment cleanup shared by +/// `run_install_command` and managed npm install paths — keeping them in sync +/// so the hermit-strip list can't drift between command execution paths. /// -/// On Windows, resolves Git Bash via `resolve_bash_path` (skips `BUZZ_SHELL` -/// since install commands require bash syntax). Returns `Err` when no shell -/// can be found. +/// On Windows, known PowerShell install commands run directly in PowerShell. +/// Sending them through Git Bash enables MSYS path conversion, which corrupts +/// paths consumed by native installers (for example, `C:` passed to `tar`). +/// Other install commands use Git Bash via `resolve_bash_path`. fn install_shell_command(command: &str) -> Result { - let shell: std::path::PathBuf = resolve_install_shell()?; + #[cfg(windows)] + let mut cmd = if let Some(script) = powershell_install_script(command) { + let mut cmd = std::process::Command::new("powershell.exe"); + cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]); + cmd + } else { + let shell = resolve_install_shell()?; + let mut cmd = std::process::Command::new(shell); + cmd.args(["-l", "-c", command]); + cmd + }; - let mut cmd = std::process::Command::new(&shell); - cmd.args(["-l", "-c", command]); + #[cfg(not(windows))] + let mut cmd = { + let shell = resolve_install_shell()?; + let mut cmd = std::process::Command::new(shell); + cmd.args(["-l", "-c", command]); + cmd + }; // Strip hermit env vars so npm/node use the user's normal registry rather // than the project-local hermit-managed paths, then give npm defaults for @@ -625,6 +640,20 @@ fn install_shell_command(command: &str) -> Result Ok(cmd) } +const POWERSHELL_INSTALL_PREFIX: &str = "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "; + +/// Extract the script from a catalogued PowerShell installer command. +/// +/// The catalog owns these command strings, so this deliberately recognizes +/// only the quoted form used by the Windows runtime installers. All other +/// commands retain the login-shell execution path. +fn powershell_install_script(command: &str) -> Option<&str> { + command + .strip_prefix(POWERSHELL_INSTALL_PREFIX)? + .strip_prefix('"')? + .strip_suffix('"') +} + /// Resolve the shell binary for install commands. /// /// Unix: `/bin/zsh` if present, else `/bin/bash`. @@ -1262,6 +1291,16 @@ mod tests { assert!(result.is_ok(), "install_shell_command must succeed on Unix"); } + #[test] + fn test_powershell_install_script_extracts_catalogued_script() { + let command = "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""; + assert_eq!( + super::powershell_install_script(command), + Some("irm https://claude.ai/install.ps1 | iex") + ); + assert_eq!(super::powershell_install_script("echo test"), None); + } + // ── Phase A: Windows install shell selection ─────────────────────────────── /// On Windows (CI runner has Git pre-installed), resolve_install_shell succeeds. @@ -1312,6 +1351,34 @@ mod tests { ); } + /// Catalogued PowerShell installers must bypass Git Bash so MSYS path + /// conversion cannot corrupt paths passed to native Windows tools. + #[cfg(windows)] + #[test] + fn test_powershell_installer_runs_natively_on_windows() { + let command = "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""; + let cmd = super::install_shell_command(command) + .expect("PowerShell installer must not require Git Bash"); + + assert_eq!( + cmd.get_program(), + std::ffi::OsStr::new("powershell.exe"), + "catalogued PowerShell installers must run in PowerShell" + ); + assert_eq!( + cmd.get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(), + vec![ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "irm https://claude.ai/install.ps1 | iex", + ] + ); + } + /// On Windows, `install_shell_command` must set PATH to a value that /// includes the inherited process PATH, so node/npm are visible inside /// the install shell even when no managed Node runtime is present. diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index f40eed5a13..682329ddd0 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -111,7 +111,10 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ adapter_install_hint: "Install the Claude Code ACP adapter via npm.", skill_dir: Some(".claude/skills"), supports_acp_model_switching: false, - model_env_var: None, + // Claude Code reads the selected model from ANTHROPIC_MODEL at process + // startup. Keep this in the runtime catalog so the structured picker + // remains the source of truth for every spawn. + model_env_var: Some("ANTHROPIC_MODEL"), provider_env_var: None, provider_locked: true, default_env: &[], diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 6318a5c838..afbb8a01c1 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -25,6 +25,7 @@ use std::collections::BTreeMap; /// Non-structured knobs (`GOOSE_TEMPERATURE`, `GOOSE_CONTEXT_LIMIT`) are NOT /// in this list — they have no structured counterpart and must be preserved. pub(crate) const DERIVED_PROVIDER_MODEL_ENV_KEYS: &[&str] = &[ + "ANTHROPIC_MODEL", "GOOSE_MODEL", "GOOSE_PROVIDER", "BUZZ_AGENT_MODEL", diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index cf57b12546..fef4f063ac 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -435,6 +435,7 @@ fn is_derived_key_matches_all_known_keys() { #[test] fn is_derived_key_is_case_insensitive() { + assert!(is_derived_provider_model_key("anthropic_model")); assert!(is_derived_provider_model_key("goose_model")); assert!(is_derived_provider_model_key("Goose_Provider")); assert!(is_derived_provider_model_key("buzz_agent_model")); diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index fd758047c3..8cc11e80de 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -518,13 +518,13 @@ fn runtime_metadata_env_vars_injects_model_and_provider() { #[test] fn runtime_metadata_env_vars_skips_provider_when_locked() { let vars = runtime_metadata_env_vars( - None, // claude has no model_env_var + Some("ANTHROPIC_MODEL"), // Claude reads its selected model from this key None, // claude has no provider_env_var true, // provider_locked = true Some("claude-opus-4-7"), Some("anthropic"), ); - assert!(vars.is_empty()); + assert_eq!(vars, vec![("ANTHROPIC_MODEL", "claude-opus-4-7")]); } #[test]