Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 79 additions & 12 deletions desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::process::Command, String> {
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
Expand Down Expand Up @@ -625,6 +640,20 @@ fn install_shell_command(command: &str) -> Result<std::process::Command, String>
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`.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<_>>(),
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.
Expand Down
5 changes: 4 additions & 1 deletion desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: &[],
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/env_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/env_vars/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/managed_agents/runtime/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down