Files
CC-Switch/src-tauri/src/session_manager/terminal/mod.rs
T
Jason 35486afdda fix(sessions): use POSIX single-quote escaping for terminal cwd
`shell_escape` wrapped the working directory in double quotes and escaped
only `\` and `"`. Inside double quotes a shell still expands `$(...)`,
backticks and `$VAR`, so the quoting stopped spaces but not command
substitution. Verified: `cd "/tmp/$(id -un)"` runs `id`.

The value is `selectedSession.projectDir` -- a real path recorded in the
AI CLI's session history. macOS allows `$`, `(` and `)` in directory
names, so any project whose folder is named that way triggers it on
Resume; no compromised renderer is required.

Three built-in launchers were affected because they route through
`build_shell_command(command, cwd)`: Terminal.app, iTerm and kitty.
Ghostty, WezTerm/Kaku and Alacritty were already correct -- they pass the
directory as its own argv element (`--working-directory` / `--cwd`) and
call `build_shell_command(command, None)`. Terminal and iTerm go through
AppleScript `do script`, which accepts a single shell line and has no
cwd parameter, so correct quoting is the only option there.

Switch to POSIX single quotes, where nothing expands, using the
close-escape-reopen `'\''` sequence for embedded quotes. A test pins the
two-layer interaction with `escape_osascript`, which doubles backslashes
on the way into the AppleScript literal.

Also escape the `{cwd}` substitution in `launch_custom`, and correct that
function's comment: the escaping there is context-dependent and only
holds while the placeholder sits in an unquoted shell word. A template
written as `echo "{cwd}"` puts the inserted quotes inside double quotes
and command substitution runs again. The branch has no UI entry point
today; the note now says it must be redesigned before one is added
rather than implying it is already safe.
2026-07-29 10:22:29 +08:00

441 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::process::Command;
pub fn launch_terminal(
target: &str,
command: &str,
cwd: Option<&str>,
custom_config: Option<&str>,
) -> Result<(), String> {
if command.trim().is_empty() {
return Err("Resume command is empty".to_string());
}
if !cfg!(target_os = "macos") {
return Err("Terminal resume is only supported on macOS".to_string());
}
match target {
"terminal" => launch_macos_terminal(command, cwd),
"iTerm" | "iterm" => launch_iterm(command, cwd),
"ghostty" => launch_ghostty(command, cwd),
"kitty" => launch_kitty(command, cwd),
"wezterm" => launch_wezterm(command, cwd),
"kaku" => launch_kaku(command, cwd),
"alacritty" => launch_alacritty(command, cwd),
#[cfg(unix)]
"warp" => launch_warp(command, cwd),
"custom" => launch_custom(command, cwd, custom_config),
_ => Err(format!("Unsupported terminal target: {target}")),
}
}
fn launch_macos_terminal(command: &str, cwd: Option<&str>) -> Result<(), String> {
let full_command = build_shell_command(command, cwd);
let escaped = escape_osascript(&full_command);
let script = format!(
r#"tell application "Terminal"
activate
do script "{escaped}"
end tell"#
);
let status = Command::new("osascript")
.arg("-e")
.arg(script)
.status()
.map_err(|e| format!("Failed to launch Terminal: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Terminal command execution failed".to_string())
}
}
fn launch_iterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
let full_command = build_shell_command(command, cwd);
let escaped = escape_osascript(&full_command);
// iTerm2 AppleScript to create a new window and execute command
let script = format!(
r#"tell application "iTerm"
activate
create window with default profile
tell current session of current window
write text "{escaped}"
end tell
end tell"#
);
let status = Command::new("osascript")
.arg("-e")
.arg(script)
.status()
.map_err(|e| format!("Failed to launch iTerm: {e}"))?;
if status.success() {
Ok(())
} else {
Err("iTerm command execution failed".to_string())
}
}
fn launch_ghostty(command: &str, cwd: Option<&str>) -> Result<(), String> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let mut args = vec![
"-na".to_string(),
"Ghostty".to_string(),
"--args".to_string(),
"--quit-after-last-window-closed=true".to_string(),
];
if let Some(dir) = cwd {
if !dir.trim().is_empty() {
args.push(format!("--working-directory={dir}"));
}
}
args.push("-e".to_string());
args.push(shell);
args.push("-l".to_string());
args.push("-c".to_string());
args.push(command.to_string());
let status = Command::new("open")
.args(&args)
.status()
.map_err(|e| format!("Failed to launch Ghostty: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Ghostty. Make sure it is installed.".to_string())
}
}
fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> {
let full_command = build_shell_command(command, cwd);
// 获取用户默认 shell
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let status = Command::new("open")
.arg("-na")
.arg("kitty")
.arg("--args")
.arg("-e")
.arg(&shell)
.arg("-l")
.arg("-c")
.arg(&full_command)
.status()
.map_err(|e| format!("Failed to launch Kitty: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Kitty. Make sure it is installed.".to_string())
}
}
fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
// wezterm start --cwd ... -- command
// To invoke via `open`, we use `open -na "WezTerm" --args start ...`
let args = build_wezterm_compatible_args("WezTerm", command, cwd);
let status = Command::new("open")
.args(args.iter().map(String::as_str))
.status()
.map_err(|e| format!("Failed to launch WezTerm: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch WezTerm.".to_string())
}
}
fn launch_kaku(command: &str, cwd: Option<&str>) -> Result<(), String> {
// Kaku is a WezTerm-derived terminal and keeps a compatible `start` entrypoint.
let args = build_wezterm_compatible_args("Kaku", command, cwd);
let status = Command::new("open")
.args(args.iter().map(String::as_str))
.status()
.map_err(|e| format!("Failed to launch Kaku: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Kaku.".to_string())
}
}
fn build_wezterm_compatible_args(app_name: &str, command: &str, cwd: Option<&str>) -> Vec<String> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
build_wezterm_compatible_args_with_shell(app_name, command, cwd, &shell)
}
fn build_wezterm_compatible_args_with_shell(
app_name: &str,
command: &str,
cwd: Option<&str>,
shell: &str,
) -> Vec<String> {
let full_command = build_shell_command(command, None);
let mut args = vec![
"-na".to_string(),
app_name.to_string(),
"--args".to_string(),
"start".to_string(),
];
if let Some(dir) = cwd {
args.push("--cwd".to_string());
args.push(dir.to_string());
}
// Invoke shell to run the command string (to handle pipes, etc)
args.push("--".to_string());
args.push(shell.to_string());
args.push("-c".to_string());
args.push(full_command);
args
}
#[cfg(unix)]
fn launch_warp(command: &str, cwd: Option<&str>) -> Result<(), String> {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let cwd = cwd.ok_or("Failed to resume session without cwd")?;
let mut script_file = tempfile::Builder::new()
.disable_cleanup(true)
.permissions(std::fs::Permissions::from_mode(0o755))
.tempfile_in(cwd)
.map_err(|e| format!("Failed to create temporary script file for launching Warp: {e}"))?;
writeln!(
&mut script_file,
r#"#!/usr/bin/env sh
rm -- "$0"
exec {command}
"#,
)
.map_err(|e| format!("Failed to write to temporary script file for Warp: {e}"))?;
let mut warp_url = url::Url::parse("warp://action/new_tab").unwrap();
warp_url
.query_pairs_mut()
.append_pair("path", &script_file.path().to_string_lossy());
let warp_url = warp_url.to_string();
let status = Command::new("open")
.args(["-a", "Warp", &warp_url])
.status()
.map_err(|e| format!("Failed to launch Warp: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Warp.".to_string())
}
}
fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> {
// Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command
let full_command = build_shell_command(command, None);
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let mut args = vec!["-na", "Alacritty", "--args"];
if let Some(dir) = cwd {
args.push("--working-directory");
args.push(dir);
}
args.push("-e");
args.push(&shell);
args.push("-c");
args.push(&full_command);
let status = Command::new("open")
.args(&args)
.status()
.map_err(|e| format!("Failed to launch Alacritty: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Alacritty.".to_string())
}
}
fn launch_custom(
command: &str,
cwd: Option<&str>,
custom_config: Option<&str>,
) -> Result<(), String> {
let template = custom_config.ok_or("No custom terminal config provided")?;
if template.trim().is_empty() {
return Err("Custom terminal command template is empty".to_string());
}
let cmd_str = command;
// `{cwd}` 是磁盘上扫来的路径,先做转义;`{command}` 保持原样——模板作者写下
// 这个占位符的本意就是让它当命令展开。
//
// ⚠️ 这里的转义**不是完备防护**,只在占位符处于未加引号的 shell 词位置时成立。
// 模板若写成 `echo "{cwd}"`,插入的单引号会落进双引号里变成普通字符,`cwd`
// 里的 `$(...)` 照样求值。安全性取决于模板怎么写,而模板不由这里控制。
//
// 目前本分支无 UI 入口(终端选项列表没有 `custom`,前端也从不传
// `customConfig`),所以不可达。**接线前必须换掉这个方案**——正确做法是让
// 模板声明参数位、由此处按 argv 传递,而不是让用户拼 shell 字符串。
let dir_str = shell_escape(cwd.unwrap_or("."));
let final_cmd_line = template
.replace("{command}", cmd_str)
.replace("{cwd}", &dir_str);
// Execute via sh -c
let status = Command::new("sh")
.arg("-c")
.arg(&final_cmd_line)
.status()
.map_err(|e| format!("Failed to execute custom terminal launcher: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Custom terminal execution returned error code".to_string())
}
}
fn build_shell_command(command: &str, cwd: Option<&str>) -> String {
match cwd {
Some(dir) if !dir.trim().is_empty() => {
format!("cd {} && {}", shell_escape(dir), command)
}
_ => command.to_string(),
}
}
/// POSIX 单引号转义。
///
/// **必须是单引号**:双引号内 `$(...)`、反引号、`$VAR` 照常展开,而这里包的是
/// `projectDir`——会话历史里记录的真实项目路径,macOS 允许目录名含 `$` `(` `)`
/// 所以一个名为 `$(...)` 的目录就足以让命令替换在用户终端里执行。
///
/// 单引号内不做任何展开,唯一的特例是 `'` 自身无法被表示:用「闭合-转义-重开」
/// 的 `'\''` 序列绕过。
fn shell_escape(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
}
fn escape_osascript(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_shell_command_keeps_command_without_cwd_prefix_when_not_provided() {
assert_eq!(
build_shell_command("claude --resume abc-123", None),
"claude --resume abc-123"
);
}
#[test]
fn wezterm_compatible_terminals_use_start_and_cwd_arguments() {
let args = build_wezterm_compatible_args_with_shell(
"Kaku",
"claude --resume abc-123",
Some("/tmp/project dir"),
"/bin/zsh",
);
assert_eq!(
args,
vec![
"-na".to_string(),
"Kaku".to_string(),
"--args".to_string(),
"start".to_string(),
"--cwd".to_string(),
"/tmp/project dir".to_string(),
"--".to_string(),
"/bin/zsh".to_string(),
"-c".to_string(),
"claude --resume abc-123".to_string(),
]
);
}
#[test]
fn ghostty_uses_working_directory_arg_for_cwd() {
// cwd should be passed as --working-directory, not embedded in the shell command string
// This avoids shell expansion of special characters in directory paths
let cwd = "/tmp/project dir";
let command = "claude --resume abc-123";
// Verify build_shell_command does NOT include cwd when used in ghostty context
// (ghostty passes cwd via --working-directory flag instead)
assert_eq!(
build_shell_command(command, None),
"claude --resume abc-123"
);
// Verify shell_escape works correctly for paths with spaces
assert_eq!(shell_escape(cwd), "'/tmp/project dir'");
}
#[test]
fn shell_escape_neutralizes_command_substitution_in_directory_names() {
// 这些字符在 macOS 目录名里全部合法,而 `cwd` 就是会话历史里的
// `projectDir`——一个名为 `$(...)` 的目录必须原样落到 `cd` 后面,
// 不能被 shell 求值。旧的双引号实现对这三种全部失守。
assert_eq!(shell_escape("/tmp/$(id -un)"), "'/tmp/$(id -un)'");
assert_eq!(shell_escape("/tmp/`id -un`"), "'/tmp/`id -un`'");
assert_eq!(shell_escape("/tmp/$HOME"), "'/tmp/$HOME'");
}
#[test]
fn shell_escape_handles_embedded_single_quote() {
// 单引号是单引号包裹法唯一表示不了的字符,靠「闭合-转义-重开」绕过。
assert_eq!(shell_escape("/tmp/it's"), r"'/tmp/it'\''s'");
}
#[test]
fn shell_escape_survives_the_osascript_layer() {
// Terminal / iTerm 的链路是两层:shell_escape 的结果先被塞进 AppleScript
// 字符串字面量,由 escape_osascript 再转义一次,AppleScript 求值后才交给
// shell。反斜杠会在中间那层被加倍,必须确认最终落到 shell 的字节没变形。
let escaped = shell_escape("/tmp/it's");
assert_eq!(escaped, r"'/tmp/it'\''s'");
let for_applescript = escape_osascript(&escaped);
assert_eq!(for_applescript, r"'/tmp/it'\\''s'");
// AppleScript 把 `\\` 求值回单个 `\`,于是 shell 拿到的正是 escaped 本身。
assert_eq!(for_applescript.replace(r"\\", r"\"), escaped);
}
#[test]
fn build_shell_command_quotes_the_cwd_it_prefixes() {
// Terminal / iTerm / kitty 三条路径都经这里;ghostty / wezterm / alacritty
// 走 `cwd = None` 并把目录当独立 argv 传,不受影响。
assert_eq!(
build_shell_command("claude --resume x", Some("/tmp/$(id -un)")),
"cd '/tmp/$(id -un)' && claude --resume x"
);
}
}