diff --git a/desktop/src-tauri/src/commands/project_git_exec.rs b/desktop/src-tauri/src/commands/project_git_exec.rs index e4a8ad7b41..66c3916d0a 100644 --- a/desktop/src-tauri/src/commands/project_git_exec.rs +++ b/desktop/src-tauri/src/commands/project_git_exec.rs @@ -185,9 +185,11 @@ fn configure_git_auth(command: &mut Command, auth: &GitAuthConfig, needs_credent /// Format a path for git `credential.helper`. /// /// Git for Windows invokes helpers via MinGW bash, which treats `\` as -/// escapes. Forward slashes work on every platform git supports. +/// escapes, so paths are normalized to forward slashes first. The result is +/// then shell-escaped — see `git_credential_helper_config_value` for why a +/// naive whole-value quote doesn't work. fn credential_helper_config_value(path: &std::path::Path) -> String { - path.to_string_lossy().replace('\\', "/") + crate::managed_agents::git_credential_helper_config_value(path) } fn apply_git_config(command: &mut Command, entries: &[(&str, String)]) { @@ -341,6 +343,24 @@ mod tests { ); } + #[test] + fn credential_helper_config_value_escapes_spaces() { + let path = std::path::PathBuf::from("/Users/x/My Apps/git-credential-nostr"); + assert_eq!( + credential_helper_config_value(&path), + r"/Users/x/My\ Apps/git-credential-nostr", + ); + } + + #[test] + fn credential_helper_config_value_escapes_apostrophes() { + let path = std::path::PathBuf::from("/Users/o'brien/git-credential-nostr"); + assert_eq!( + credential_helper_config_value(&path), + r"/Users/o\'brien/git-credential-nostr", + ); + } + #[test] fn git_subcommand_skips_global_config_options() { assert_eq!( diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index eecbf4de3e..edd96878ac 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -590,6 +590,29 @@ pub fn resolve_command(command: &str) -> Option { result } +/// Escape a path for use as a git `credential.helper` config value. +/// +/// Git builds the helper command by concatenating the raw `credential.helper` +/// string with an operation (`get`/`store`/`erase`) and running the result +/// through `sh -c`, so any shell metacharacter in the path (most commonly a +/// space) gets word-split and breaks the invocation. Git also detects an +/// absolute-path helper by checking whether the *unescaped* string starts +/// with a path separator (or drive letter on Windows) — quoting the value as +/// a whole hides that leading character and git falls back to treating it as +/// a bare helper name instead. So each shell-special character is escaped +/// individually with a backslash, leaving the leading separator intact. +pub fn git_credential_helper_config_value(path: &std::path::Path) -> String { + let normalized = path.to_string_lossy().replace('\\', "/"); + let mut escaped = String::with_capacity(normalized.len()); + for ch in normalized.chars() { + if !(ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':')) { + escaped.push('\\'); + } + escaped.push(ch); + } + escaped +} + /// 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()); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index f3b4cb67fd..5dc6f218a3 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -6,10 +6,10 @@ use super::agent_env::build_buzz_agent_provider_defaults; use crate::{ managed_agents::{ - append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, - missing_command_message, normalize_agent_args, open_log_file, resolve_command, - spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, ManagedAgentSummary, + append_log_marker, git_credential_helper_config_value, known_acp_runtime, login_shell_path, + managed_agent_log_path, missing_command_message, normalize_agent_args, open_log_file, + resolve_command, spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, + ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentSummary, }, util::now_iso, }; @@ -835,8 +835,10 @@ pub fn spawn_agent_child( "GIT_CONFIG_KEY_0", format!("credential.{relay_http_url}/git.helper"), ); - let helper = cred_helper.to_string_lossy().replace('\\', "/"); - command.env("GIT_CONFIG_VALUE_0", helper); + command.env( + "GIT_CONFIG_VALUE_0", + git_credential_helper_config_value(&cred_helper), + ); command.env( "GIT_CONFIG_KEY_1", format!("credential.{relay_http_url}/git.useHttpPath"),