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
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ sha2 = "0.10"
hmac = "0.12"
json5 = "0.4"
json-five = "0.3.1"
shlex = "1.3.0"

[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
Expand Down
148 changes: 130 additions & 18 deletions src-tauri/src/commands/misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1132,8 +1132,25 @@ fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String {
}

/// 构建 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));
fn build_provider_command_line(
shell: &str,
config_path: &str,
cwd: Option<&Path>,
parsed_custom_args: &[String],
) -> String {
let escaped_custom_args =
shlex::try_join(parsed_custom_args.iter().map(|s| s.as_str())).unwrap_or_default();

let claude_command = if escaped_custom_args.is_empty() {
format!("claude --settings {}", shell_single_quote(config_path))
} else {
format!(
"claude --settings {} {}",
shell_single_quote(config_path),
escaped_custom_args
)
};

let command = cwd
.map(|dir| {
format!(
Expand Down Expand Up @@ -2586,8 +2603,14 @@ pub async fn open_provider_terminal(
let config = &provider.settings_config;
let env_vars = extract_env_vars_from_config(config, &app_type);

// 从提供商 meta 中读取自定义 CLI 参数
let custom_args = provider
.meta
.as_ref()
.and_then(|m| m.custom_cli_args.as_deref());

// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref())
launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref(), custom_args)
.map_err(|e| format!("启动终端失败: {e}"))?;

Ok(true)
Expand Down Expand Up @@ -2686,6 +2709,7 @@ fn launch_terminal_with_env(
env_vars: Vec<(String, String)>,
provider_id: &str,
cwd: Option<&Path>,
custom_args: Option<&str>,
) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let config_file = temp_dir.join(format!(
Expand All @@ -2697,22 +2721,34 @@ fn launch_terminal_with_env(
// 创建并写入配置文件
write_claude_config(&config_file, &env_vars)?;

let parsed_custom_args = match custom_args.map(|s| s.trim()).filter(|s| !s.is_empty()) {
Some(s) => match shlex::split(s) {
Comment thread
Chizukuo marked this conversation as resolved.
Some(args) => args,
None => {
return Err(
"无法解析自定义 CLI 参数: 存在未闭合的引号,请检查您的输入并重试。".to_string(),
);
}
},
None => Vec::new(),
};

#[cfg(target_os = "macos")]
{
launch_macos_terminal(&config_file, cwd)?;
launch_macos_terminal(&config_file, cwd, &parsed_custom_args)?;
Ok(())
}

#[cfg(target_os = "linux")]
{
launch_linux_terminal(&config_file, cwd)?;
launch_linux_terminal(&config_file, cwd, &parsed_custom_args)?;
Ok(())
}

#[cfg(target_os = "windows")]
{
launch_windows_terminal(&temp_dir, &config_file, cwd)?;
return Ok(());
launch_windows_terminal(&temp_dir, &config_file, cwd, &parsed_custom_args)?;
Ok(())
}

#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
Expand Down Expand Up @@ -2741,9 +2777,11 @@ fn write_claude_config(

/// macOS: 根据用户首选终端启动
#[cfg(target_os = "macos")]
fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;

fn launch_macos_terminal(
config_file: &std::path::Path,
cwd: Option<&Path>,
parsed_custom_args: &[String],
) -> Result<(), String> {
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("terminal");

Expand All @@ -2754,7 +2792,8 @@ fn launch_macos_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 provider_command = build_provider_command_line(&shell, &config_path, cwd);
let provider_command =
build_provider_command_line(&shell, &config_path, cwd, parsed_custom_args);

// Write the shell script to a temp file
// 脚本使用 POSIX sh 语法确保可移植性,exec 行切换到用户交互式 shell
Expand Down Expand Up @@ -3061,8 +3100,11 @@ fn launch_macos_warp(script_file: &std::path::Path) -> Result<(), String> {

/// Linux: 根据用户首选终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
fn launch_linux_terminal(
config_file: &std::path::Path,
cwd: Option<&Path>,
parsed_custom_args: &[String],
) -> Result<(), String> {
use std::process::Command;

let preferred = crate::settings::get_preferred_terminal();
Expand All @@ -3087,7 +3129,8 @@ 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 provider_command = build_provider_command_line(&shell, &config_path, cwd);
let provider_command =
build_provider_command_line(&shell, &config_path, cwd, parsed_custom_args);

let script_content = format!(
r#"#!/usr/bin/env sh
Expand Down Expand Up @@ -3182,6 +3225,7 @@ fn launch_windows_terminal(
temp_dir: &std::path::Path,
config_file: &std::path::Path,
cwd: Option<&Path>,
parsed_custom_args: &[String],
) -> Result<(), String> {
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("cmd");
Expand All @@ -3190,17 +3234,24 @@ fn launch_windows_terminal(
let config_path_for_batch = escape_windows_batch_value(&config_file.to_string_lossy());
let cwd_command = build_windows_cwd_command(cwd);

let escaped_custom_args = parsed_custom_args
.iter()
.map(|s| escape_windows_batch_arg(s))
.collect::<Vec<_>>()
.join(" ");

let content = format!(
"@echo off
{cwd_command}
echo Using provider-specific claude config:
echo {}
claude --settings \"{}\"
claude --settings \"{}\" {}
del \"{}\" >nul 2>&1
del \"%~f0\" >nul 2>&1
",
config_path_for_batch,
config_path_for_batch,
escaped_custom_args,
config_path_for_batch,
cwd_command = cwd_command,
);
Expand Down Expand Up @@ -3272,6 +3323,35 @@ fn escape_windows_batch_value(value: &str) -> String {
.replace('(', "^(")
.replace(')', "^)")
}

/// 为 Windows 批处理文件 (.bat/.cmd) 安全转义命令行参数
///
/// 批处理文件的参数转义是一个极其复杂的系统,因为参数首先被 `cmd.exe` 解析,
/// 然后才被传给目标程序(对于 Rust 程序,通常通过 `CommandLineToArgvW` 解析)。
/// 此函数通过以下原则保障参数在传递过程中的安全性并防止注入:
///
/// - 将参数整体用双引号 `""` 闭合,从而完全关闭 `cmd.exe` 对诸如 `&`, `|`, `<` 等元字符的特殊处理。
/// - 对参数内部原本的双引号进行双写转义 `""`,以便让 `CommandLineToArgvW` 能够正确剥离。
/// - 对环境变量展开符 `%` 转义为 `%%`,以防止恶意的执行时变量展开注入(如 `%PATH%`)。
/// - 处理尾部的反斜杠加倍逻辑:防范尾部的 `\` 与最外层的双闭合引号结合,导致引号的实际闭合性被破坏。
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn escape_windows_batch_arg(arg: &str) -> String {
// 1. Prevent newline command injection in batch files
let mut s = arg.replace(['\r', '\n'], " ");

// 2. Escape % as %% for batch file to prevent variable expansion
s = s.replace('%', "%%");
// 3. Escape internal double quotes for CommandLineToArgvW compatibility inside double quotes
s = s.replace('"', "\"\"");

// 4. Trailing backslashes must be doubled so they aren't parsed as escaping the closing quote
let trailing_slashes = s.chars().rev().take_while(|&c| c == '\\').count();
s.push_str(&"\\".repeat(trailing_slashes));

// 5. Wrap the entire argument in double quotes so cmd metacharacters are ignored
format!("\"{}\"", s)
}

/// Windows: Run a start command with common error handling
#[cfg(target_os = "windows")]
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
Expand Down Expand Up @@ -3535,22 +3615,24 @@ mod tests {
#[test]
fn test_build_provider_command_line_uses_user_shell_environment() {
assert_eq!(
build_provider_command_line("/bin/zsh", "/tmp/claude config.json", None),
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"))
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"))
Some(Path::new("/tmp/project O'Brien")),
&[]
),
r#"'/bin/sh' -c 'cd '"'"'/tmp/project O'"'"'"'"'"'"'"'"'Brien'"'"' && claude --settings '"'"'/tmp/claude config.json'"'"''"#
);
Expand Down Expand Up @@ -3624,6 +3706,36 @@ mod tests {
assert!(!valid_user_shell_path("/usr/bin/powershell"));
}

#[test]
fn test_escape_windows_batch_arg() {
// 普通参数
assert_eq!(escape_windows_batch_arg("--help"), "\"--help\"");
// 含有空格的参数
assert_eq!(escape_windows_batch_arg("hello world"), "\"hello world\"");
// 单双引号混合与特殊字符
assert_eq!(
escape_windows_batch_arg("--arg='single'"),
"\"--arg='single'\""
);
assert_eq!(
escape_windows_batch_arg("--arg=\"double\""),
"\"--arg=\"\"double\"\"\""
);
// 环境变量扩展防范(避免被替换成具体的环境变量值)
assert_eq!(escape_windows_batch_arg("%PATH%"), "\"%%PATH%%\"");
// 尾部反斜杠加倍:单斜杠 => 双斜杠,双斜杠 => 4斜杠。以防止转义掉外侧的引号闭合
assert_eq!(escape_windows_batch_arg("C:\\test\\"), "\"C:\\test\\\\\"");
assert_eq!(
escape_windows_batch_arg("C:\\test\\\\"),
"\"C:\\test\\\\\\\\\""
);
// 中部反斜杠不受影响
assert_eq!(
escape_windows_batch_arg("C:\\test\\inner"),
"\"C:\\test\\inner\""
);
}

#[test]
fn test_extract_version() {
assert_eq!(extract_version("claude 1.0.20"), "1.0.20");
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,10 @@ pub struct ProviderMeta {
/// 用于多账号支持,关联到特定的 GitHub 账号
#[serde(rename = "githubAccountId", skip_serializing_if = "Option::is_none")]
pub github_account_id: Option<String>,
/// 附加 CLI 启动参数(比如 --dangerously-skip-permissions)
/// 打开终端时会附加到启动命令末尾
#[serde(rename = "customCliArgs", skip_serializing_if = "Option::is_none")]
pub custom_cli_args: Option<String>,
}

/// 解析 Provider 级自定义 User-Agent 字符串(单一真理来源)。
Expand Down
37 changes: 36 additions & 1 deletion src/components/providers/forms/ProviderAdvancedConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { useTranslation } from "react-i18next";
import { useState, useEffect } from "react";
import { ChevronDown, ChevronRight, FlaskConical, Coins } from "lucide-react";
import {
ChevronDown,
ChevronRight,
FlaskConical,
Coins,
Terminal,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
Expand All @@ -27,13 +33,17 @@ interface ProviderAdvancedConfigProps {
pricingConfig: ProviderPricingConfig;
onTestConfigChange: (config: ProviderTestConfig) => void;
onPricingConfigChange: (config: ProviderPricingConfig) => void;
customCliArgs: string;
onCustomCliArgsChange: (value: string) => void;
}

export function ProviderAdvancedConfig({
testConfig,
pricingConfig,
onTestConfigChange,
onPricingConfigChange,
customCliArgs,
onCustomCliArgsChange,
}: ProviderAdvancedConfigProps) {
const { t } = useTranslation();
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
Expand Down Expand Up @@ -324,6 +334,31 @@ export function ProviderAdvancedConfig({
</div>
</div>
</div>

{/* 自定义 CLI 参数 */}
<div className="rounded-lg border border-border/50 bg-muted/20 p-4 space-y-3">
<div className="flex items-center gap-3">
<Terminal className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{t("providerAdvanced.customCliArgs", {
defaultValue: "自定义 CLI 参数",
})}
</span>
</div>
<Input
value={customCliArgs}
onChange={(e) => onCustomCliArgsChange(e.target.value)}
placeholder={t("providerAdvanced.customCliArgsPlaceholder", {
defaultValue: "--dangerously-skip-permissions",
})}
/>
<p className="text-xs text-muted-foreground">
{t("providerAdvanced.customCliArgsHint", {
defaultValue:
"打开终端时附加到启动命令的额外参数,多个参数用空格分隔",
})}
</p>
</div>
</div>
);
}
Loading