From 1042fb2a321166690be1bb043db25611537b1722 Mon Sep 17 00:00:00 2001 From: Zhou Kai Date: Tue, 16 Jun 2026 16:54:25 +0800 Subject: [PATCH] fix(terminal): respect user shell for provider terminals (#4140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): detect user default shell instead of hardcoded bash All macOS/Linux terminal launch functions in misc.rs hardcoded `bash` as the execution shell, causing zsh users' .zshrc to not load. Now detect the user's default shell via $SHELL (macOS fallback /bin/zsh, Linux fallback /bin/bash) and use it consistently across shebang, AppleScript, spawn args, and interactive shell flags. - Add get_user_shell(), get_shell_name(), interactive_flags_for_shell() - Replace 10 hardcoded bash locations with dynamic shell detection - zsh gets --norcs --no-globalrcs, bash keeps --norc --noprofile - run_tool_lifecycle_silently unchanged (bash-specific syntax) - Fixes #1546, Related #2385 Co-Authored-By: Claude Opus 4.7 * fix(terminal): use POSIX sh for script execution, user shell for interactive session Address code review issues: 1. Replace shell-specific `read -n 1 -s`/`read -k 1 -s` with POSIX `read -r _` (press Enter to close) — ensures portability across bash/zsh/fish/dash 2. Unify AppleScript to use `sh` consistently (not mixing shell/shell_name) 3. Protect fish users: scripts always interpreted by POSIX sh, not fish (fish doesn't support trap/exec). exec line switches to user's shell. 4. Use build_exec_line to avoid trailing space when interactive_flags is empty All terminal launchers now execute scripts via `sh`. Script shebangs are `#!/bin/sh`. The exec line at script end switches to the user's detected shell with appropriate clean-start flags. Terminal launcher functions no longer need shell/shell_name parameters — simplified signatures. Co-Authored-By: Claude Opus 4.7 * fix: validate user shell for terminal launch * fix: load user shell config after terminal command * test: align terminal shell exec expectations * refactor(terminal): remove dead code, quote Warp path, unify shebangs - Remove `interactive_flags_for_shell` (always returned "") and `get_shell_name` (no longer needed after removing flags); simplify `build_exec_line` to single-arg form - Quote the Warp launcher script path with `shell_single_quote` for consistency with the main launcher scripts - Unify all script shebangs to `#!/usr/bin/env sh` for better portability (e.g. NixOS where /bin/sh may not exist) Co-Authored-By: Claude Opus 4.7 * fix(terminal): fix linux shell exec helper call * fix(terminal): run provider command through user shell * fix(terminal): validate user shell executable * fix(terminal): avoid interactive provider shell job * fix(terminal): harden user shell launcher Load zsh provider commands through the user's login and interactive environment, preserve launch cwd handling, and replace Terminal/iTerm launcher shells so the final user shell is restored cleanly. --------- Co-authored-by: Claude Opus 4.7 --- src-tauri/src/commands/misc.rs | 344 ++++++++++++++++++++++++++++----- 1 file changed, 292 insertions(+), 52 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 3abb43e87..90ed3b28a 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1069,6 +1069,111 @@ fn default_flag_for_shell(shell: &str) -> &'static str { } } +fn fallback_user_shell() -> &'static str { + if cfg!(target_os = "macos") { + "/bin/zsh" + } else { + "/bin/bash" + } +} + +fn valid_user_shell_path(shell: &str) -> bool { + if shell.is_empty() + || !shell.starts_with('/') + || !is_valid_shell(shell) + || shell.chars().any(char::is_control) + { + return false; + } + + let path = std::path::Path::new(shell); + path.is_file() && is_executable_file(path) +} + +#[cfg(unix)] +fn is_executable_file(path: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + path.metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn is_executable_file(path: &std::path::Path) -> bool { + path.is_file() +} + +/// 获取用户默认 shell 的完整路径;异常或被污染的 SHELL 回退到平台默认值。 +fn get_user_shell() -> String { + std::env::var("SHELL") + .ok() + .filter(|shell| valid_user_shell_path(shell)) + .unwrap_or_else(|| fallback_user_shell().to_string()) +} + +/// 构建 exec 行:引号保护 shell 路径,交还用户 shell 让其按默认规则加载 rc 配置。 +fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String { + let quoted_shell = shell_single_quote(shell); + + match shell.rsplit('/').next().unwrap_or(shell) { + "zsh" => cwd + .map(|dir| { + let command = format!( + "cd {} || exit 1; exec {} -i", + shell_single_quote(&dir.to_string_lossy()), + quoted_shell + ); + format!("exec {} -lc {}", quoted_shell, shell_single_quote(&command)) + }) + .unwrap_or_else(|| format!("exec {quoted_shell} -l")), + _ => format!("exec {quoted_shell}"), + } +} + +/// 构建 provider 命令行:通过用户 shell 的交互模式执行,确保 GUI 启动的终端也加载用户 PATH。 +fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path>) -> String { + let claude_command = format!("claude --settings {}", shell_single_quote(config_path)); + let command = cwd + .map(|dir| { + format!( + "cd {} && {}", + shell_single_quote(&dir.to_string_lossy()), + claude_command + ) + }) + .unwrap_or(claude_command); + + format!( + "{} {} {}", + shell_single_quote(shell), + provider_command_flag_for_shell(shell), + shell_single_quote(&command) + ) +} + +fn provider_command_flag_for_shell(shell: &str) -> &'static str { + match shell.rsplit('/').next().unwrap_or(shell) { + "dash" | "sh" => "-c", + "zsh" => "-lic", + _ => "-ic", + } +} + +fn build_final_shell_cd_command(shell: &str, cwd: Option<&Path>) -> String { + if matches!(shell.rsplit('/').next().unwrap_or(shell), "zsh") { + return String::new(); + } + + cwd.map(|dir| { + format!( + "cd {} || exit 1\n", + shell_single_quote(&dir.to_string_lossy()) + ) + }) + .unwrap_or_default() +} + #[cfg(target_os = "windows")] fn try_get_version_wsl( tool: &str, @@ -2642,24 +2747,31 @@ fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> R let preferred = crate::settings::get_preferred_terminal(); let terminal = preferred.as_deref().unwrap_or("terminal"); + let shell = get_user_shell(); + let exec_line = build_exec_line(&shell, cwd); + let final_cd_command = build_final_shell_cd_command(&shell, cwd); + let temp_dir = std::env::temp_dir(); let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id())); let config_path = config_file.to_string_lossy(); - let cd_command = build_shell_cd_command(cwd); + let provider_command = build_provider_command_line(&shell, &config_path, cwd); // Write the shell script to a temp file + // 脚本使用 POSIX sh 语法确保可移植性,exec 行切换到用户交互式 shell let script_content = format!( - r#"#!/bin/bash + r#"#!/usr/bin/env sh trap 'rm -f "{config_path}" "{script_file}"' EXIT -{cd_command} echo "Using provider-specific claude config:" echo "{config_path}" -claude --settings "{config_path}" -exec bash --norc --noprofile +{provider_command} +{final_cd_command} +{exec_line} "#, config_path = config_path, script_file = script_file.display(), - cd_command = cd_command, + provider_command = provider_command, + final_cd_command = final_cd_command, + exec_line = exec_line, ); std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?; @@ -2678,7 +2790,7 @@ exec bash --norc --noprofile "ghostty" => launch_macos_ghostty(&script_file), "wezterm" => launch_macos_open_app("WezTerm", &script_file, true), "kaku" => launch_macos_open_app("Kaku", &script_file, true), - _ => launch_macos_terminal_app(&script_file), // "terminal" or default + _ => launch_macos_terminal_app(&script_file), }; // If preferred terminal fails and it's not the default, try Terminal.app as fallback @@ -2704,7 +2816,16 @@ fn applescript_string_literal(value: &str) -> String { #[cfg(target_os = "macos")] fn applescript_launcher_command(script_file: &std::path::Path) -> String { applescript_string_literal(&format!( - "bash {}", + "sh {}", + shell_single_quote(&script_file.to_string_lossy()) + )) +} + +/// Build a launcher command that replaces the terminal-created shell session. +#[cfg(target_os = "macos")] +fn applescript_exec_launcher_command(script_file: &std::path::Path) -> String { + applescript_string_literal(&format!( + "exec sh {}", shell_single_quote(&script_file.to_string_lossy()) )) } @@ -2727,7 +2848,7 @@ tell application "Terminal" activate end if end tell"#, - launcher = applescript_launcher_command(script_file) + launcher = applescript_exec_launcher_command(script_file) ) } @@ -2795,7 +2916,7 @@ tell application "iTerm" write text launcher_script end tell end tell"#, - launcher = applescript_launcher_command(script_file) + launcher = applescript_exec_launcher_command(script_file) ) } @@ -2805,12 +2926,12 @@ fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> { run_terminal_osascript(&build_macos_iterm2_applescript(script_file), "iTerm2") } -/// Keep the launcher path inside a `bash -c` string. +/// Keep the launcher path inside a `sh -c` string. /// A bare `.sh` passed through `open --args` may also be opened as a document. #[cfg(target_os = "macos")] fn build_macos_dash_c_command(script_file: &std::path::Path) -> String { format!( - "exec bash {}", + "exec sh {}", shell_single_quote(&script_file.to_string_lossy()) ) } @@ -2865,8 +2986,8 @@ fn launch_macos_open_app( if use_e_flag { cmd.arg("-e"); } - // Keep the script path inside `bash -c`; a trailing bare `.sh` can be opened as a document. - cmd.arg("bash") + // Keep the script path inside `sh -c`; a trailing bare `.sh` can be opened as a document. + cmd.arg("sh") .arg("-c") .arg(build_macos_dash_c_command(script_file)); @@ -2912,9 +3033,9 @@ fn launch_macos_warp(script_file: &std::path::Path) -> Result<(), String> { rm -- "$0" - exec bash {} + exec sh {quoted_script} "#, - script_file.display(), + quoted_script = shell_single_quote(&script_file.to_string_lossy()), ) .map_err(|e| format!("Failed to write to temporary script file for Warp: {e}"))?; @@ -2946,6 +3067,10 @@ fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> R let preferred = crate::settings::get_preferred_terminal(); + let shell = get_user_shell(); + let exec_line = build_exec_line(&shell, cwd); + let final_cd_command = build_final_shell_cd_command(&shell, cwd); + // Default terminal list with their arguments let default_terminals = [ ("gnome-terminal", vec!["--"]), @@ -2962,20 +3087,22 @@ fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> R let temp_dir = std::env::temp_dir(); let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id())); let config_path = config_file.to_string_lossy(); - let cd_command = build_shell_cd_command(cwd); + let provider_command = build_provider_command_line(&shell, &config_path, cwd); let script_content = format!( - r#"#!/bin/bash + r#"#!/usr/bin/env sh trap 'rm -f "{config_path}" "{script_file}"' EXIT -{cd_command} echo "Using provider-specific claude config:" echo "{config_path}" -claude --settings "{config_path}" -exec bash --norc --noprofile +{provider_command} +{final_cd_command} +{exec_line} "#, config_path = config_path, script_file = script_file.display(), - cd_command = cd_command, + provider_command = provider_command, + final_cd_command = final_cd_command, + exec_line = exec_line, ); std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?; @@ -3019,7 +3146,7 @@ exec bash --norc --noprofile if terminal_exists { let result = Command::new(terminal) .args(&args) - .arg("bash") + .arg("sh") .arg(script_file.to_string_lossy().as_ref()) .spawn(); @@ -3106,16 +3233,6 @@ del \"%~f0\" >nul 2>&1 result } -fn build_shell_cd_command(cwd: Option<&Path>) -> String { - cwd.map(|dir| { - format!( - "cd {} || exit 1\n", - shell_single_quote(&dir.to_string_lossy()) - ) - }) - .unwrap_or_default() -} - fn shell_single_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } @@ -3182,7 +3299,7 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S Ok(()) } -/// 打开用户首选终端并在其中执行一段可信命令脚本。脚本尾部 `read -n 1` / `pause` +/// 打开用户首选终端并在其中执行一段可信命令脚本。脚本尾部 `read -r` / `pause` /// 是刻意设计的——让命令退出后窗口不要瞬间关闭,用户才看得到 `command /// not found` / `ModuleNotFoundError` 这类诊断信息。 /// @@ -3196,14 +3313,14 @@ pub(crate) fn launch_terminal_running(command_line: &str, label: &str) -> Result let (script_file, script_content) = { let file = temp_dir.join(format!("cc_switch_{}_{}.sh", label, pid)); let content = format!( - r#"#!/bin/bash + r#"#!/usr/bin/env sh trap 'rm -f "{script_path}"' EXIT echo "[cc-switch] Starting: {label}" echo "" {cmd} echo "" -echo "[cc-switch] Command exited. Press any key to close." -read -n 1 -s +echo "[cc-switch] Command exited. Press Enter to close." +read -r _ "#, script_path = file.display(), label = label, @@ -3299,7 +3416,7 @@ read -n 1 -s if terminal_exists { let spawn_result = Command::new(terminal) .args(&args) - .arg("bash") + .arg("sh") .arg(script_file.to_string_lossy().as_ref()) .spawn(); match spawn_result { @@ -3387,6 +3504,126 @@ mod tests { use super::*; use std::path::{Path, PathBuf}; + #[cfg(unix)] + fn set_test_executable(path: &Path, executable: bool) { + use std::os::unix::fs::PermissionsExt; + + let mode = if executable { 0o755 } else { 0o644 }; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .expect("fixture permissions should be set"); + } + + #[test] + fn test_build_exec_line() { + assert_eq!(build_exec_line("/bin/zsh", None), "exec '/bin/zsh' -l"); + assert_eq!(build_exec_line("/bin/bash", None), "exec '/bin/bash'"); + assert_eq!( + build_exec_line("/opt/homebrew dir/bin/fish", None), + "exec '/opt/homebrew dir/bin/fish'" + ); + assert_eq!(build_exec_line("/bin/sh", None), "exec '/bin/sh'"); + assert_eq!( + build_exec_line("/tmp/shell'quote/zsh", None), + "exec '/tmp/shell'\"'\"'quote/zsh' -l" + ); + assert_eq!( + build_exec_line("/bin/zsh", Some(Path::new("/tmp/project"))), + r#"exec '/bin/zsh' -lc 'cd '"'"'/tmp/project'"'"' || exit 1; exec '"'"'/bin/zsh'"'"' -i'"# + ); + } + + #[test] + fn test_build_provider_command_line_uses_user_shell_environment() { + assert_eq!( + build_provider_command_line("/bin/zsh", "/tmp/claude config.json", None), + "'/bin/zsh' -lic 'claude --settings '\"'\"'/tmp/claude config.json'\"'\"''" + ); + assert_eq!( + build_provider_command_line( + "/bin/bash", + "/tmp/claude config.json", + Some(Path::new("/tmp/project")) + ), + r#"'/bin/bash' -ic 'cd '"'"'/tmp/project'"'"' && claude --settings '"'"'/tmp/claude config.json'"'"''"# + ); + assert_eq!( + build_provider_command_line( + "/bin/sh", + "/tmp/claude config.json", + Some(Path::new("/tmp/project O'Brien")) + ), + r#"'/bin/sh' -c 'cd '"'"'/tmp/project O'"'"'"'"'"'"'"'"'Brien'"'"' && claude --settings '"'"'/tmp/claude config.json'"'"''"# + ); + } + + #[test] + fn test_build_final_shell_cd_command() { + assert_eq!(build_final_shell_cd_command("/bin/zsh", None), ""); + assert_eq!( + build_final_shell_cd_command("/bin/zsh", Some(Path::new("/tmp/project"))), + "" + ); + assert_eq!( + build_final_shell_cd_command("/bin/bash", Some(Path::new("/tmp/project O'Brien"))), + "cd '/tmp/project O'\"'\"'Brien' || exit 1\n" + ); + } + + #[cfg(unix)] + #[test] + fn test_get_user_shell_fallback() { + // $SHELL 未设置时应按平台 fallback + // 此测试验证 fallback 逻辑,但不验证环境变量值(取决于运行环境) + let shell = get_user_shell(); + // 至少应返回一个合法的绝对路径 + assert!(valid_user_shell_path(&shell)); + // basename 应为合法 shell 名 + let basename = shell.rsplit('/').next().unwrap_or("sh"); + assert!(["sh", "bash", "zsh", "fish", "dash"].contains(&basename)); + } + + #[cfg(unix)] + #[test] + fn test_valid_user_shell_path() { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let executable_zsh = temp.path().join("zsh"); + std::fs::write(&executable_zsh, "#!/usr/bin/env sh\n") + .expect("shell fixture should be written"); + set_test_executable(&executable_zsh, true); + + let executable_fish_dir = temp.path().join("homebrew dir/bin"); + std::fs::create_dir_all(&executable_fish_dir) + .expect("shell fixture directory should be created"); + let executable_fish = executable_fish_dir.join("fish"); + std::fs::write(&executable_fish, "#!/usr/bin/env sh\n") + .expect("shell fixture should be written"); + set_test_executable(&executable_fish, true); + + let non_executable_bash = temp.path().join("bash"); + std::fs::write(&non_executable_bash, "#!/usr/bin/env sh\n") + .expect("shell fixture should be written"); + set_test_executable(&non_executable_bash, false); + + assert!(valid_user_shell_path(&executable_zsh.to_string_lossy())); + assert!(valid_user_shell_path(&executable_fish.to_string_lossy())); + assert!(!valid_user_shell_path("")); + assert!(!valid_user_shell_path("zsh")); + assert!(!valid_user_shell_path( + &temp.path().join("missing/zsh").to_string_lossy() + )); + assert!(!valid_user_shell_path( + &non_executable_bash.to_string_lossy() + )); + assert!(!valid_user_shell_path( + &temp.path().join("zsh; rm -rf /").to_string_lossy() + )); + assert!(!valid_user_shell_path(&format!( + "{}\n/bin/bash", + executable_zsh.to_string_lossy() + ))); + assert!(!valid_user_shell_path("/usr/bin/powershell")); + } + #[test] fn test_extract_version() { assert_eq!(extract_version("claude 1.0.20"), "1.0.20"); @@ -4850,13 +5087,6 @@ mod tests { assert!(error.contains("目录不存在")); } - #[test] - fn build_shell_cd_command_quotes_spaces_and_single_quotes() { - let command = build_shell_cd_command(Some(Path::new("/tmp/project O'Brien"))); - - assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n"); - } - #[cfg(target_os = "macos")] #[test] fn iterm2_applescript_cold_start_avoids_current_window_before_one_exists() { @@ -4918,6 +5148,10 @@ mod tests { ), "already-running branch should use bare do script:\n{script}" ); + assert!( + script.contains(r#"set launcher_script to "exec sh '/tmp/cc_switch_launcher.sh'""#), + "Terminal should replace the auto-created shell:\n{script}" + ); } /// Restored windows should not receive the launcher command. @@ -4943,7 +5177,7 @@ mod tests { // Warm launches execute through the AppleScript command property, not `open -na ... -e`. assert!( - script.contains(r#"set launcher_command to "bash '/tmp/cc_switch_launcher.sh'""#), + script.contains(r#"set launcher_command to "sh '/tmp/cc_switch_launcher.sh'""#), "missing launcher_command:\n{script}" ); assert!(script.contains("if was_running then")); @@ -4983,11 +5217,11 @@ mod tests { fn dash_c_command_wraps_script_path_inside_quoted_arg() { // The script path must stay inside the `-c` string, not as a bare argv. let s = build_macos_dash_c_command(Path::new("/tmp/cc_switch_launcher_1.sh")); - assert_eq!(s, "exec bash '/tmp/cc_switch_launcher_1.sh'"); + assert_eq!(s, "exec sh '/tmp/cc_switch_launcher_1.sh'"); // Spaces and single quotes must stay shell-safe too. let s2 = build_macos_dash_c_command(Path::new("/Users/me/it's dir/x.sh")); - assert_eq!(s2, r#"exec bash '/Users/me/it'"'"'s dir/x.sh'"#); + assert_eq!(s2, r#"exec sh '/Users/me/it'"'"'s dir/x.sh'"#); } /// AppleScript launchers need both shell-path quoting and AppleScript string quoting. @@ -4995,20 +5229,26 @@ mod tests { #[test] fn applescript_builders_safely_quote_special_paths() { // First shell-quote the path, then wrap the whole command as an AppleScript string. - let expected = r#""bash '/Users/me/it'\"'\"'s dir/x.sh'""#; + let expected = r#""sh '/Users/me/it'\"'\"'s dir/x.sh'""#; let p = Path::new("/Users/me/it's dir/x.sh"); assert_eq!(applescript_launcher_command(p), expected); + assert_eq!( + applescript_exec_launcher_command(p), + r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""# + ); assert!( - build_macos_terminal_applescript(p).contains(expected), + build_macos_terminal_applescript(p) + .contains(r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""#), "Terminal did not quote safely" ); assert!( - build_macos_iterm2_applescript(p).contains(expected), + build_macos_iterm2_applescript(p) + .contains(r#""exec sh '/Users/me/it'\"'\"'s dir/x.sh'""#), "iTerm2 did not quote safely" ); assert!( build_macos_ghostty_applescript(p).contains(expected), - "Ghostty did not quote safely" + "Ghostty did not keep the non-exec launcher" ); }