fix(terminal): respect user shell for provider terminals (#4140)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
This commit is contained in:
Zhou Kai
2026-06-16 16:54:25 +08:00
committed by GitHub
parent de0a149df5
commit 1042fb2a32
+292 -52
View File
@@ -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"
);
}