Files
qiming/qiming-rcoder/crates/agent_abstraction/src/launcher/claude_code_sacp.rs
2026-06-01 13:54:52 +08:00

2693 lines
113 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.
//! Claude Code ACP Agent 启动器 (SACP 版本)
//!
//! 使用 symposium-acp (sacp) 库的新实现,支持:
//! - 标准 tokio::spawn无需 LocalSet
//! - Builder 模式的连接构建
//! - 回调函数式的消息处理
//!
//! 此文件是 `claude_code.rs` 的 SACP 版本替代品。
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(windows)]
use crate::path_env::TAURI_APP_DATA_DIR;
use agent_config::{AgentServersConfig, ContextServerConfig};
use anyhow::{Context, Result};
use process_wrap::tokio::CommandWrap;
#[cfg(unix)]
use process_wrap::tokio::ProcessGroup;
#[cfg(windows)]
use process_wrap::tokio::{CreationFlags, JobObject};
use shared_types::{ModelProviderConfig, ProjectAndAgentInfo, error_codes};
use tokio::sync::mpsc;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use agent_client_protocol::schema::{
CancelNotification, InitializeRequest, LoadSessionRequest, McpServer, McpServerStdio,
NewSessionRequest, PermissionOptionKind, PromptRequest, ProtocolVersion,
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SelectedPermissionOutcome, SessionId, SessionNotification,
};
use agent_client_protocol::{
Agent, Client, ConnectionTo, Dispatch, Handled, JsonRpcMessage, Responder,
};
use crate::acp::CancelNotificationRequestWrapper;
use crate::traits::AgentStartConfig;
use crate::traits::session_notifier::SessionNotifier;
use crate::traits::session_registry::SessionRegistry;
// 导入生命周期管理
use super::lifecycle::AgentLifecycleGuard;
#[cfg(windows)]
use super::windows_launch::{
CREATE_NO_WINDOW_FLAG, normalize_windows_command_for_no_window,
resolve_windows_node_cli_command,
};
#[cfg(windows)]
use windows::Win32::System::Threading::PROCESS_CREATION_FLAGS;
/// 使用最新协议版本
const VERSION: ProtocolVersion = ProtocolVersion::LATEST;
/// API 密钥占位符(实际密钥由 Pingora 代理注入)
const API_KEY_PLACEHOLDER: &str = "PROXY_MANAGED_KEY";
/// 环境变量键名常量
const ENV_ANTHROPIC_API_KEY: &str = "ANTHROPIC_API_KEY";
const ENV_ANTHROPIC_BASE_URL: &str = "ANTHROPIC_BASE_URL";
const ENV_ANTHROPIC_MODEL: &str = "ANTHROPIC_MODEL";
const ENV_DISABLE_NONESSENTIAL: &str = "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC";
const ENV_AGENT_SDK_SKIP_VERSION_CHECK: &str = "CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK";
const ENV_RUST_LOG: &str = "RUST_LOG";
const ENV_AGENT_WORKING_DIR: &str = "AGENT_WORKING_DIR";
const ENV_AGENT_PROJECT_ID: &str = "AGENT_PROJECT_ID";
/// OpenAI 环境变量常量
const ENV_OPENAI_API_KEY: &str = "OPENAI_API_KEY";
const ENV_OPENAI_BASE_URL: &str = "OPENAI_BASE_URL";
/// nuwaxcode 使用 OPENCODE_MODEL 而不是 OPENAI_MODEL
const ENV_OPENCODE_MODEL: &str = "OPENCODE_MODEL";
/// 默认代理 Base URL包含 UUID 占位符)
const DEFAULT_PROXY_BASE_URL: &str = "http://localhost:8088/api/{SERVICE_UUID}";
/// 获取各平台下常用的用户二进制目录
///
/// Linux/Mac:
/// - ~/.cargo/bin (Rust cargo)
/// - ~/.npm-global/bin (npm global)
/// - ~/.local/bin (uv, pipx)
/// - /opt/homebrew/bin (Homebrew on Apple Silicon)
/// - /usr/local/bin (Homebrew on Intel Mac / 通用)
/// - /home/linuxbrew/.linuxbrew/bin (Linuxbrew)
///
/// Windows:
/// - 返回空Windows 环境变量已包含这些路径
///
/// 注意:使用 $HOME 变量而非硬编码 /rootHOME 不存在时自动回退到 /root
fn get_common_user_bins() -> Vec<&'static str> {
#[cfg(windows)]
{
vec![] // Windows 用户路径已在系统 PATH 中
}
#[cfg(not(windows))]
{
vec![
// Cargo (Rust)
"$HOME/.cargo/bin",
// NPM global
"$HOME/.npm-global/bin",
// UV / PIPX global
"$HOME/.local/bin",
// Homebrew (Apple Silicon)
"/opt/homebrew/bin",
// Homebrew (Intel Mac) / 通用本地安装
"/usr/local/bin",
// Linuxbrew
"/home/linuxbrew/.linuxbrew/bin",
// 系统级 cargo某些容器环境
"/opt/cargo/bin",
]
}
}
/// 确保子进程环境变量中包含 PATH / PATHEXT便于解析可执行路径与 Windows .cmd 脚本
///
/// PATH 组成(优先级从高到低):
/// 1. NUWAX_APP_RUNTIME_PATH — 应用自有运行时目录node/uv/mcp-proxy 等)
/// 2. 当前系统 PATH — 保留所有现有路径(关键改进!)
/// 3. 常用用户目录 — cargo, npm, uv, homebrew 等
/// 4. 系统基础目录 — `/bin`, `/usr/bin` 等(仅在 PATH 为空时)
fn ensure_subprocess_path_env(merged_envs: &mut std::collections::HashMap<String, String>) {
if !merged_envs.contains_key("PATH") {
let path = build_mcp_server_path_env();
if !path.is_empty() {
merged_envs.insert("PATH".to_string(), path);
debug!("[SACP] 📋 already built PATH (using existing PATH directory)");
}
}
#[cfg(windows)]
ensure_windows_subprocess_env(merged_envs);
}
/// 构建 MCP 服务器子进程所需的 PATH 环境变量
///
/// Claude Code SDK 在启动 MCP 服务器子进程时会用提供的 env 替换整个环境。
/// 如果 env 中缺少 PATHmcp-proxy convert --config 模式下的孙进程
/// (如 uvx、npx将因为找不到命令而静默失败。
///
/// 此函数与 ensure_subprocess_path_env 使用相同的逻辑构建 PATH
/// 1. NUWAX_APP_RUNTIME_PATH — 应用自有运行时目录(优先)
/// 2. 当前系统 PATH — 保留所有现有路径(关键改进!)
/// 3. 平台特定目录:
/// - Windows: APPDATA 推导的 Tauri 应用 node/npm 路径
/// - Unix: 常用用户目录cargo, npm, uv, homebrew 等)
/// 4. 系统基础目录 — 仅在 PATH 为空时兜底
fn build_mcp_server_path_env() -> String {
let sep = if cfg!(windows) { ";" } else { ":" };
let mut paths: Vec<String> = Vec::new();
// 1. 优先NUWAX_APP_RUNTIME_PATH
if let Ok(runtime_path) = std::env::var("NUWAX_APP_RUNTIME_PATH") {
for p in runtime_path.split(sep) {
let p = p.trim();
if !p.is_empty() && !paths.contains(&p.to_string()) {
paths.push(p.to_string());
}
}
}
// 2. 追加:当前系统 PATH关键改动
if let Ok(current_path) = std::env::var("PATH") {
for p in current_path.split(sep) {
let p = p.trim();
if !p.is_empty() && !paths.contains(&p.to_string()) {
paths.push(p.to_string());
}
}
}
// 3. 追加:平台特定目录
#[cfg(windows)]
{
// Windows: 从 APPDATA 推导 Tauri 应用的 node/npm 路径
if let Ok(appdata) = std::env::var("APPDATA") {
use std::path::PathBuf;
let app_base = PathBuf::from(&appdata).join(TAURI_APP_DATA_DIR);
let node_bin = app_base.join("runtime").join("node").join("bin");
if node_bin.is_dir() {
let node_bin_str = node_bin.to_string_lossy().to_string();
if !paths.contains(&node_bin_str) {
paths.push(node_bin_str);
}
}
let npm_bin = app_base.join("node_modules").join(".bin");
if npm_bin.is_dir() {
let npm_bin_str = npm_bin.to_string_lossy().to_string();
if !paths.contains(&npm_bin_str) {
paths.push(npm_bin_str);
}
}
}
}
#[cfg(not(windows))]
{
// Unix: 追加常用用户目录(自动扩展 $HOME
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
for bin in get_common_user_bins() {
let expanded = bin.replace("$HOME", &home);
let path = std::path::Path::new(&expanded);
if path.is_dir() && !paths.contains(&expanded) {
paths.push(expanded);
}
}
}
// 4. 兜底:系统基础目录(仅在 PATH 为空时)
// 正常情况下不会走到这里,因为步骤 2 已经追加了当前系统 PATH
// 这是为了防御性编程,确保即使在极端情况下也有可用的基础命令
if paths.is_empty() {
#[cfg(not(windows))]
for sys_dir in &["/bin", "/usr/bin", "/usr/local/bin", "/sbin", "/usr/sbin"] {
let s = sys_dir.to_string();
if std::path::Path::new(sys_dir).is_dir() {
paths.push(s);
}
}
#[cfg(windows)]
{
// Windows: 直接返回系统 PATH 作为最后手段
if let Ok(current_path) = std::env::var("PATH") {
return current_path;
}
}
}
paths.join(sep)
}
/// Windows 专属:确保 PATHEXT 存在,以便解析 .cmd/.bat 等脚本
#[cfg(windows)]
fn ensure_windows_subprocess_env(merged_envs: &mut std::collections::HashMap<String, String>) {
if !merged_envs.contains_key("PATHEXT") {
if let Ok(pathext) = std::env::var("PATHEXT") {
merged_envs.insert("PATHEXT".to_string(), pathext);
debug!("[SACP] 📋 PATHEXT already set");
}
}
}
/// 根据 proxy feature 决定使用占位符还是真实 API Key
fn resolve_api_key(provider: &ModelProviderConfig) -> String {
if cfg!(feature = "proxy") {
API_KEY_PLACEHOLDER.to_string()
} else {
provider.api_key.clone()
}
}
/// 根据 proxy feature 决定使用代理 URL 还是真实 Base URL
fn resolve_base_url(provider: &ModelProviderConfig) -> String {
if cfg!(feature = "proxy") {
DEFAULT_PROXY_BASE_URL.to_string()
} else {
provider.base_url.clone()
}
}
/// Agent 配置参数 (与旧版兼容)
#[derive(Debug, Clone)]
pub struct SacpAgentLaunchConfig {
/// 命令路径
pub command: String,
/// 命令参数
pub args: Vec<String>,
/// 环境变量
pub env: HashMap<String, String>,
/// Context 服务器配置 (MCP servers)
pub context_servers: HashMap<String, ContextServerConfig>,
}
/// Agent 连接信息SACP 版本)
pub struct SacpLauncherConnectionInfo {
/// 会话 ID
pub session_id: SessionId,
/// 发送 Prompt 消息的通道(有界通道,提供背压保护)
pub prompt_tx: mpsc::Sender<PromptRequest>,
/// 发送取消请求的通道(有界通道,提供背压保护)
pub cancel_tx: mpsc::Sender<CancelNotificationRequestWrapper>,
/// 生命周期守卫(自动清理资源)
pub lifecycle_guard: Arc<AgentLifecycleGuard>,
}
/// 从配置文件加载 Agent 配置
///
/// 优先加载嵌入的JSON配置文件如果加载失败则使用默认配置
/// 同时检查并自动安装 agent如果需要
pub async fn load_sacp_agent_config(
model_provider: Option<&ModelProviderConfig>,
service_type: &shared_types::ServiceType,
) -> Result<SacpAgentLaunchConfig> {
// 复用旧版配置加载逻辑
let config = AgentServersConfig::load_or_default_for_service(service_type).await;
if let Some(agent_config) = config.get_agent("claude-code-acp-ts") {
debug!(
"📋 [SACP] using default Agent config: {}",
agent_config.agent_id
);
// 检查并安装 agent - 临时禁用以测试本地 claude-code-acp-ts
// if agent_config.installation.package_name.is_some() {
// let installation_manager = AgentInstallationManager::new();
// match installation_manager
// .ensure_installed(&agent_config.installation, &agent_config.command)
// .await
// {
// Ok(result) => {
// if result.already_installed {
// debug!("[SACP] Agent already installed: {}", agent_config.command);
// } else {
// info!("[SACP] Agent message succeeded: {}", result.message);
// }
// }
// Err(e) => {
// warn!(
// "[SACP] Agent message Installation failed: {}, message started",
// e
// );
// }
// }
// }
// 解析环境变量
let mut resolved_env = agent_config.env.clone();
if let Some(provider) = model_provider {
// 统一替换所有环境变量中的模板
// proxy 模式下API_KEY 和 BASE_URL 使用占位符/代理URL由 Pingora 代理注入真实值
// 非 proxy 模式下:直接使用真实的 API Key 和 Base URL
let resolved_key = resolve_api_key(provider);
let resolved_url = resolve_base_url(provider);
for (_key, value) in resolved_env.iter_mut() {
*value = value
.replace("{MODEL_PROVIDER_API_KEY}", &resolved_key)
.replace("{MODEL_PROVIDER_BASE_URL}", &resolved_url)
.replace("{MODEL_PROVIDER_DEFAULT_MODEL}", &provider.default_model)
.replace("{MODEL_PROVIDER_NAME}", &provider.name);
}
}
// 禁用 Claude Code 非必要网络请求
resolved_env.insert(ENV_DISABLE_NONESSENTIAL.to_string(), "1".to_string());
// 跳过 Agent SDK 版本检查
resolved_env.insert(
ENV_AGENT_SDK_SKIP_VERSION_CHECK.to_string(),
"1".to_string(),
);
// debug: 打印最终环境变量API Key 已脱敏)
let mask_key = |v: &String| -> String {
if v.len() > 8 {
format!("{}***{}", &v[..4], &v[v.len() - 4..])
} else {
"***".to_string()
}
};
debug!(
"[SACP] Final env config: command={}, ANTHROPIC_API_KEY={}, ANTHROPIC_BASE_URL={}, ANTHROPIC_MODEL={}, \
OPENAI_API_KEY={}, OPENAI_BASE_URL={}, OPENCODE_MODEL={}, \
RUST_LOG={}, CLAUDE_CODE_MAX_TOKENS={}, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC={}",
agent_config.command,
resolved_env
.get("ANTHROPIC_API_KEY")
.map(|v| mask_key(v))
.unwrap_or_default(),
resolved_env
.get("ANTHROPIC_BASE_URL")
.unwrap_or(&"<unset>".to_string()),
resolved_env
.get("ANTHROPIC_MODEL")
.unwrap_or(&"<unset>".to_string()),
resolved_env
.get("OPENAI_API_KEY")
.map(|v| mask_key(v))
.unwrap_or_default(),
resolved_env
.get("OPENAI_BASE_URL")
.unwrap_or(&"<unset>".to_string()),
resolved_env
.get("OPENCODE_MODEL")
.unwrap_or(&"<unset>".to_string()),
resolved_env
.get("RUST_LOG")
.unwrap_or(&"<unset>".to_string()),
resolved_env
.get("CLAUDE_CODE_MAX_TOKENS")
.unwrap_or(&"<unset>".to_string()),
resolved_env
.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC")
.unwrap_or(&"<unset>".to_string()),
);
Ok(SacpAgentLaunchConfig {
command: agent_config.command.clone(),
args: agent_config.args.clone(),
env: resolved_env,
context_servers: config.context_servers.clone(),
})
} else {
warn!("[SACP] config not found for claude-code-acp-ts, using default config");
get_default_sacp_agent_config(model_provider, service_type)
}
}
/// 获取默认的 Agent 配置(后备方案)
pub fn get_default_sacp_agent_config(
model_provider: Option<&ModelProviderConfig>,
_service_type: &shared_types::ServiceType,
) -> Result<SacpAgentLaunchConfig> {
let mut env = HashMap::new();
if let Some(provider) = model_provider {
let resolved_key = resolve_api_key(provider);
let resolved_url = resolve_base_url(provider);
// Anthropic 环境变量
if !provider.api_key.is_empty() {
env.insert(ENV_ANTHROPIC_API_KEY.to_string(), resolved_key.clone());
}
if !provider.base_url.is_empty() {
env.insert(ENV_ANTHROPIC_BASE_URL.to_string(), resolved_url.clone());
}
if !provider.default_model.is_empty() {
env.insert(
ENV_ANTHROPIC_MODEL.to_string(),
provider.default_model.clone(),
);
}
// OpenAI 环境变量 (支持 OpenAI 兼容的 Agent)
if !provider.api_key.is_empty() {
env.insert(ENV_OPENAI_API_KEY.to_string(), resolved_key);
}
if !provider.base_url.is_empty() {
env.insert(ENV_OPENAI_BASE_URL.to_string(), resolved_url);
}
if !provider.default_model.is_empty() {
// nuwaxcode 使用 OPENCODE_MODELmodel_name 中已包含 openai-compatible/ 前缀
env.insert(
ENV_OPENCODE_MODEL.to_string(),
provider.default_model.clone(),
);
}
}
env.insert(ENV_RUST_LOG.to_string(), "info".to_string());
env.insert(ENV_DISABLE_NONESSENTIAL.to_string(), "1".to_string());
env.insert(
ENV_AGENT_SDK_SKIP_VERSION_CHECK.to_string(),
"1".to_string(),
);
// Resolve the claude-code-acp-ts command path.
// Priority: CLAUDE_CODE_ACP_PATH env var > `which` crate lookup > bare command name.
// Tauri apps may not inherit the user's shell PATH, so we try `which` crate to get
// an absolute path at build/launch time.
let command = if let Ok(path) = std::env::var("CLAUDE_CODE_ACP_PATH") {
path
} else {
match which::which("claude-code-acp-ts") {
Ok(resolved_path) => {
tracing::info!(
"Resolved claude-code-acp-ts path via `which` crate: {}",
resolved_path.display()
);
resolved_path.to_string_lossy().to_string()
}
Err(_) => "claude-code-acp-ts".to_string(),
}
};
Ok(SacpAgentLaunchConfig {
command,
args: Vec::new(),
env,
context_servers: HashMap::new(),
})
}
/// 从文件内容中解析 D-Bus 会话地址
fn parse_dbus_address_from_content(content: &str) -> Option<String> {
content
.lines()
.find(|line| line.starts_with("DBUS_SESSION_BUS_ADDRESS="))
.and_then(|line| line.split_once('='))
.map(|(_, val)| {
val.trim()
.trim_end_matches(';')
.trim_matches('\'')
.trim_matches('"')
.to_string()
})
}
/// 获取 D-Bus 会话地址
/// 优先从环境变量读取,否则从文件读取
fn get_dbus_session_address() -> Option<String> {
std::env::var("DBUS_SESSION_BUS_ADDRESS").ok().or_else(|| {
std::fs::read_to_string("/tmp/dbus-session-env")
.ok()
.and_then(|content| parse_dbus_address_from_content(&content))
})
}
/// mcp-proxy 日志目录环境变量名
const ENV_MCP_PROXY_LOG_DIR: &str = "MCP_PROXY_LOG_DIR";
/// 检测命令是否为 mcp-proxy简化版只检测命令名
fn is_mcp_proxy_command(command: &str) -> bool {
command == "mcp-proxy"
}
/// 检测参数中是否有 convert 子命令
fn has_convert_subcommand(args: &[String]) -> bool {
args.iter().any(|arg| arg == "convert")
}
/// 检测当前日志级别是否为 debug
fn is_debug_log_level() -> bool {
// 优先检查 RUST_LOG 环境变量
if let Ok(rust_log) = std::env::var("RUST_LOG") {
let log_lower = rust_log.to_lowercase();
return log_lower.contains("debug") || log_lower.contains("trace");
}
// 使用 tracing 的 enabled! 宏检测
tracing::enabled!(tracing::Level::DEBUG)
}
/// 获取 mcp-proxy 日志目录(如果配置了的话)
fn get_mcp_proxy_log_dir() -> Option<String> {
std::env::var(ENV_MCP_PROXY_LOG_DIR).ok()
}
/// 检查参数中是否已有 --log-dir 或 --log-file 参数
fn has_log_dir_arg(args: &[String]) -> bool {
args.iter().any(|arg| {
arg == "--log-dir"
|| arg.starts_with("--log-dir=")
|| arg == "--log-file"
|| arg.starts_with("--log-file=")
})
}
/// 为 mcp-proxy convert 命令追加诊断参数
///
/// 当检测到以下条件时,自动追加 `--diagnostic` 参数:
/// 1. 命令是 `mcp-proxy`
/// 2. 参数包含 `convert` 子命令
/// 3. 当前日志级别是 debug
///
/// 只有配置了 `MCP_PROXY_LOG_DIR` 环境变量时才追加 `--log-dir` 参数
///
/// 重复检查逻辑:
/// 1. 如果参数中已有 --diagnostic跳过注入
/// 2. 如果参数中已有 --log-dir 或 --log-file不追加 --log-dir避免覆盖用户配置
fn enhance_mcp_proxy_args(command: &str, args: Vec<String>) -> Vec<String> {
// 检查是否为 mcp-proxy convert 命令
if !is_mcp_proxy_command(command) || !has_convert_subcommand(&args) {
return args;
}
// 检查日志级别是否为 debug
if !is_debug_log_level() {
debug!("[MCP] mcp-proxy convert detected, but not debug, skip diagnostic params");
return args;
}
// 检查是否已有 --diagnostic 参数
let has_diagnostic = args.iter().any(|arg| arg == "--diagnostic");
if has_diagnostic {
debug!("[MCP] mcp-proxy already has --diagnostic params, skipping");
return args;
}
let mut enhanced_args = args;
// 追加 --diagnostic 参数
enhanced_args.push("--diagnostic".to_string());
info!("[MCP] added --diagnostic params to mcp-proxy");
// 🔒 关键检查:如果用户已配置 --log-dir 或 --log-file不覆盖
if has_log_dir_arg(&enhanced_args) {
debug!("[MCP] already has config params, skip --log-dir");
return enhanced_args;
}
// 只有配置了 MCP_PROXY_LOG_DIR 环境变量时才追加 --log-dir 参数
if let Some(log_dir) = get_mcp_proxy_log_dir() {
enhanced_args.push("--log-dir".to_string());
enhanced_args.push(log_dir.clone());
info!(
"[MCP] adding mcp-proxy convert --log-dir {} params",
log_dir
);
}
enhanced_args
}
/// 将配置中的 Context 服务器转换为 SACP 协议的 McpServer
pub fn convert_context_servers_sacp(
configs: &HashMap<String, ContextServerConfig>,
) -> Vec<McpServer> {
let dbus_address = get_dbus_session_address();
configs
.iter()
.filter(|(_, c)| c.enabled)
.filter_map(|(name, c)| {
let command = c.command.as_ref()?;
let mut server = McpServerStdio::new(name, PathBuf::from(command));
// 处理参数,可能需要为 mcp-proxy convert 追加诊断参数
let final_args = if let Some(args) = &c.args {
enhance_mcp_proxy_args(command, args.clone())
} else {
Vec::new()
};
if !final_args.is_empty() {
server = server.args(final_args);
}
let mut env_vars: Vec<agent_client_protocol::schema::EnvVariable> =
if let Some(env) = &c.env {
env.iter()
.map(|(k, v)| {
agent_client_protocol::schema::EnvVariable::new(k.clone(), v.clone())
})
.collect()
} else {
Vec::new()
};
// 注入 D-Bus 会话地址
if let Some(ref addr) = dbus_address {
if !env_vars
.iter()
.any(|e| e.name == "DBUS_SESSION_BUS_ADDRESS")
{
env_vars.push(agent_client_protocol::schema::EnvVariable::new(
"DBUS_SESSION_BUS_ADDRESS".to_string(),
addr.clone(),
));
}
}
// 注入镜像源环境变量npx/bunx/uvx 子进程使用)
for (key, val) in crate::mirror_env::collect_mirror_env_vars() {
if !env_vars.iter().any(|e| e.name == key) {
env_vars.push(agent_client_protocol::schema::EnvVariable::new(key, val));
}
}
// 注入 PATH 环境变量(关键!)
// Claude Code SDK 用 MCP server 的 env 替换整个子进程环境。
// 如果 env 中没有 PATHmcp-proxy convert --config 模式下
// 无法找到 uvx/npx 等命令来启动 MCP 子服务。
if !env_vars.iter().any(|e| e.name == "PATH") {
let path_value = build_mcp_server_path_env();
if !path_value.is_empty() {
env_vars.push(agent_client_protocol::schema::EnvVariable::new(
"PATH".to_string(),
path_value,
));
}
}
// 注入 HOME 环境变量uvx/npx 等工具需要 HOME 来定位缓存目录)
#[cfg(not(windows))]
if !env_vars.iter().any(|e| e.name == "HOME") {
if let Ok(home) = std::env::var("HOME") {
env_vars.push(agent_client_protocol::schema::EnvVariable::new(
"HOME".to_string(),
home,
));
}
}
// Windows: 注入 USERPROFILE 和 PATHEXT
#[cfg(windows)]
{
if !env_vars.iter().any(|e| e.name == "USERPROFILE") {
if let Ok(profile) = std::env::var("USERPROFILE") {
env_vars.push(agent_client_protocol::schema::EnvVariable::new(
"USERPROFILE".to_string(),
profile,
));
}
}
if !env_vars.iter().any(|e| e.name == "PATHEXT") {
if let Ok(pathext) = std::env::var("PATHEXT") {
env_vars.push(agent_client_protocol::schema::EnvVariable::new(
"PATHEXT".to_string(),
pathext,
));
}
}
}
if !env_vars.is_empty() {
server = server.env(env_vars);
}
Some(McpServer::Stdio(server))
})
.collect()
}
/// Claude Code ACP Agent 启动器 (SACP 版本)
///
/// 使用 SACP 库的 Builder 模式和回调函数,无需 LocalSet。
pub struct SacpClaudeCodeLauncher<N: SessionNotifier> {
/// 会话通知器
notifier: Arc<N>,
}
impl<N: SessionNotifier + 'static> SacpClaudeCodeLauncher<N> {
/// 创建新的启动器
pub fn new(notifier: Arc<N>) -> Self {
Self { notifier }
}
/// 启动 Claude Code ACP Agent 服务
///
/// 使用 SACP 库的 Builder 模式,支持标准 tokio::spawn
pub async fn launch<R: SessionRegistry + 'static>(
&self,
project_id: String,
project_path: PathBuf,
model_provider: Option<ModelProviderConfig>,
start_config: AgentStartConfig,
_registry: Arc<R>,
service_uuid: Option<String>,
) -> Result<SacpLauncherConnectionInfo>
where
R::Entry: Into<ProjectAndAgentInfo> + From<ProjectAndAgentInfo>,
{
// 从配置加载默认 Agent 参数
let default_agent_config =
load_sacp_agent_config(model_provider.as_ref(), &start_config.service_type).await?;
// 🎯 关键:检查是否有自定义 agent_server 配置覆盖
let (command_path, command_args, base_env) =
if let Some(ref agent_server_override) = start_config.agent_server_override {
// 使用自定义 command如果提供否则用默认
let cmd = agent_server_override
.command
.clone()
.unwrap_or_else(|| default_agent_config.command.clone());
// 使用自定义 args如果提供否则用默认
let args = agent_server_override
.args
.clone()
.unwrap_or_else(|| default_agent_config.args.clone());
// 合并环境变量:默认配置 + 自定义配置(自定义覆盖默认)
let mut env = default_agent_config.env.clone();
if let Some(custom_env) = &agent_server_override.env {
// 使用 extend 替代循环,更高效
env.extend(custom_env.iter().map(|(k, v)| (k.clone(), v.clone())));
}
// 🔧 关键修复:替换自定义环境变量中的模板变量
// 用户可能传入 {MODEL_PROVIDER_API_KEY} 等模板,需要替换为实际值
// proxy 模式下API_KEY 和 BASE_URL 使用占位符/代理URL由 Pingora 代理注入真实值
// 非 proxy 模式下:直接使用真实的 API Key 和 Base URL
if let Some(ref provider) = model_provider {
let resolved_key = resolve_api_key(provider);
let resolved_url = resolve_base_url(provider);
for (_key, value) in env.iter_mut() {
*value = value
.replace("{MODEL_PROVIDER_API_KEY}", &resolved_key)
.replace("{MODEL_PROVIDER_BASE_URL}", &resolved_url)
.replace("{MODEL_PROVIDER_DEFAULT_MODEL}", &provider.default_model)
.replace("{MODEL_PROVIDER_NAME}", &provider.name);
}
debug!(
"🔧 [SACP] Replaced custom env var template, model={}",
provider.default_model
);
}
info!(
"🎯 [SACP] Using custom Agent: agent_id={}, command={} {:?}",
agent_server_override.get_agent_id(),
cmd,
args
);
(cmd, args, env)
} else {
// 使用默认配置
info!(
"📋 [SACP] Using default Agent: {} {:?}",
default_agent_config.command, default_agent_config.args
);
(
default_agent_config.command.clone(),
default_agent_config.args.clone(),
default_agent_config.env.clone(),
)
};
// 创建通道(使用有界通道防止 OOM
// 容量由常量定义,足够处理突发请求,同时提供背压保护
let (cancel_tx, cancel_rx) = mpsc::channel::<CancelNotificationRequestWrapper>(
shared_types::AGENT_CANCEL_CHANNEL_CAPACITY,
);
let (prompt_tx, prompt_rx) =
mpsc::channel::<PromptRequest>(shared_types::AGENT_PROMPT_CHANNEL_CAPACITY);
let (session_id_tx, session_id_rx) = tokio::sync::oneshot::channel::<SessionId>();
// 创建 CancellationToken
let cancel_token = CancellationToken::new();
info!(
"[SACP] projectworkdirectory: {}",
&project_path.to_string_lossy()
);
// 准备 MCP 服务器
let mcp_servers = if start_config.has_mcp_servers() {
info!("[SACP] using AgentStartConfig MCP servers");
start_config.mcp_servers.clone()
} else if !default_agent_config.context_servers.is_empty() {
info!("[SACP] using config file MCP servers");
convert_context_servers_sacp(&default_agent_config.context_servers)
} else {
info!("📝 [SACP] no config MCP servers");
Vec::new()
};
let mut command_path = command_path;
let mut command_args = command_args;
#[cfg(windows)]
if let Some((resolved_program, resolved_args)) =
resolve_windows_node_cli_command(&command_path, &command_args)
{
let entry = resolved_args.first().cloned().unwrap_or_default();
info!(
"[SACP] Windows direct node startup: {} -> {} {}",
command_path, resolved_program, entry
);
command_path = resolved_program;
command_args = resolved_args;
}
// 准备环境变量(在 base_env 基础上添加项目相关变量)
let mut merged_envs = base_env;
merged_envs.insert(
ENV_AGENT_WORKING_DIR.to_string(),
project_path.to_string_lossy().to_string(),
);
merged_envs.insert(ENV_AGENT_PROJECT_ID.to_string(), project_id.clone());
ensure_subprocess_path_env(&mut merged_envs);
// 替换 UUID 占位符
if let Some(ref uuid) = service_uuid {
for (_key, value) in merged_envs.iter_mut() {
*value = value.replace("{SERVICE_UUID}", uuid);
}
}
// 🔒 安全防护proxy 模式下强制将敏感环境变量替换为占位符/代理 URL防止密钥泄露
// 即使用户在配置中直接写了真实的 API_KEY 或 BASE_URL也会被替换
if cfg!(feature = "proxy") {
if model_provider.is_some() {
// 强制替换 Anthropic 敏感变量
if merged_envs.contains_key(ENV_ANTHROPIC_API_KEY) {
merged_envs.insert(
ENV_ANTHROPIC_API_KEY.to_string(),
API_KEY_PLACEHOLDER.to_string(),
);
}
// ANTHROPIC_AUTH_TOKEN 也需要替换(某些场景下可能存在)
if merged_envs.contains_key("ANTHROPIC_AUTH_TOKEN") {
merged_envs.insert(
"ANTHROPIC_AUTH_TOKEN".to_string(),
API_KEY_PLACEHOLDER.to_string(),
);
}
if merged_envs.contains_key(ENV_ANTHROPIC_BASE_URL) {
merged_envs.insert(
ENV_ANTHROPIC_BASE_URL.to_string(),
service_uuid
.as_ref()
.map(|uuid| DEFAULT_PROXY_BASE_URL.replace("{SERVICE_UUID}", uuid))
.unwrap_or_else(|| DEFAULT_PROXY_BASE_URL.to_string()),
);
}
// 强制替换 OpenAI 敏感变量
if merged_envs.contains_key(ENV_OPENAI_API_KEY) {
merged_envs.insert(
ENV_OPENAI_API_KEY.to_string(),
API_KEY_PLACEHOLDER.to_string(),
);
}
if merged_envs.contains_key(ENV_OPENAI_BASE_URL) {
merged_envs.insert(
ENV_OPENAI_BASE_URL.to_string(),
service_uuid
.as_ref()
.map(|uuid| DEFAULT_PROXY_BASE_URL.replace("{SERVICE_UUID}", uuid))
.unwrap_or_else(|| DEFAULT_PROXY_BASE_URL.to_string()),
);
}
debug!("[SACP] 🔒 force replaced with proxy URL");
}
} else {
debug!("[SACP] 🔓 not proxy mode, keeping API Key and Base URL");
}
// 🔍 打印传递给 Agent 的完整环境变量(用于调试)
// 注意敏感字段API Key需要脱敏处理防止日志泄露
debug!(
"[SACP] 📋 Start Agent command: {} {:?}",
command_path, command_args
);
debug!("[SACP] 📋 work directory: {:?}", project_path);
debug!(
"[SACP] 📋 Environment variables passed to Agent ({} items):",
merged_envs.len()
);
// 需要脱敏的环境变量 key 列表(即使在 debug 日志中也不暴露完整值)
const SENSITIVE_ENV_KEYS: &[&str] = &[
ENV_ANTHROPIC_API_KEY,
ENV_OPENAI_API_KEY,
"ANTHROPIC_AUTH_TOKEN",
];
// 按字母顺序排序并打印所有环境变量(仅在 debug 级别)
let mut env_keys: Vec<_> = merged_envs.keys().collect();
env_keys.sort();
for key in env_keys.iter() {
let value = merged_envs.get(*key).unwrap();
if SENSITIVE_ENV_KEYS.contains(&key.as_str()) {
// 脱敏只显示前4个字符 + ***
let masked = if value.len() > 4 {
format!("{}***", &value[..4])
} else {
"***".to_string()
};
debug!("[SACP] 📋 {} = {}", key, masked);
} else {
debug!("[SACP] 📋 {} = {}", key, value);
}
}
// 🔧 Windows将 .cmd/.bat 等规范化为不弹窗的 node.exe + JS 形式(逻辑在 windows_launch 中)
#[cfg(windows)]
let (command_path, command_args) =
normalize_windows_command_for_no_window(command_path, command_args);
// 启动子进程(使用进程组/Job Object 来管理整个进程树)
// Unix: ProcessGroup::leader() 创建进程组,确保能够清理所有孙进程
// Windows: JobObject 管理进程树
let mut cmd_wrap = CommandWrap::with_new(&command_path, |cmd| {
cmd.args(&command_args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(&project_path);
cmd.envs(&merged_envs);
});
#[cfg(unix)]
let mut child = cmd_wrap
.wrap(ProcessGroup::leader())
.spawn()
.context("[SACP] Failed to start claude-code-acp-ts subprocess")?;
#[cfg(windows)]
let mut child = cmd_wrap
.wrap(CreationFlags(PROCESS_CREATION_FLAGS(CREATE_NO_WINDOW_FLAG)))
.wrap(JobObject)
.spawn()
.context("[SACP] Failed to start claude-code-acp-ts subprocess")?;
#[cfg(not(any(unix, windows)))]
compile_error!("neither unix nor windows");
let child_pid = child.id().unwrap_or(0);
info!(
"[SACP] Claude Code ACP child process already started, PID: {}",
child_pid
);
// 获取 stdio 句柄process_wrap 使用方法访问 stdio
let stdin = take_stdio(&mut child.stdin(), "stdin")?;
let stdout = take_stdio(&mut child.stdout(), "stdout")?;
let stderr = take_stdio(&mut child.stderr(), "stderr")?;
// 🔥 立即启动 stderr 读取任务(在 session_id 等待之前)
// 这样即使子进程在初始化阶段就退出,也能捕获 stderr 输出
let cancel_token_for_stderr = cancel_token.clone();
let stderr_output_shared = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let stderr_output_clone = stderr_output_shared.clone();
let stderr_task_handle: tokio::task::JoinHandle<()> = tokio::spawn(async move {
use tokio::io::{AsyncBufReadExt, BufReader};
let mut lines = BufReader::new(stderr).lines();
loop {
tokio::select! {
biased; // 优先检查取消信号
_ = cancel_token_for_stderr.cancelled() => {
debug!("[SACP] stderr cancel received");
break;
}
result = lines.next_line() => {
match result {
Ok(Some(line)) if !line.trim().is_empty() => {
warn!("[SACP] Claude Code Agent stderr: {}", line.trim());
// 存储 stderr 输出,用于错误传播
if let Ok(mut buf) = stderr_output_clone.lock() {
buf.push(line.trim().to_string());
// 限制最多存储 20 行,避免内存膨胀
if buf.len() > 20 {
buf.remove(0);
}
}
}
Ok(Some(_)) => {} // 空行,忽略
Ok(None) => break, // EOF
Err(e) => {
error!("[SACP] read stderr failed: {}", e);
break;
}
}
}
}
}
});
// 创建 SACP transport
let transport =
agent_client_protocol::ByteStreams::new(stdin.compat_write(), stdout.compat());
// 🔥 新增:创建共享的异常退出标志
// 此标志在 reaper_task 检测到子进程异常退出时设置为 true
// SACP 连接层可以检测此标志并发送相应的错误通知
let abnormal_exit_flag = Arc::new(AtomicBool::new(false));
// 共享的 session_id用于连接失败时发送错误通知
let session_id_shared = Arc::new(std::sync::Mutex::new(None::<String>));
// 共享的连接错误信息,用于 "channel dropped" 时传播真实错误原因
let connection_error_shared = Arc::new(std::sync::Mutex::new(None::<String>));
// 克隆用于闭包
let project_path_clone = project_path.clone();
let project_id_clone = project_id.clone();
let cancel_token_clone = cancel_token.clone();
let notifier_clone = self.notifier.clone();
let abnormal_exit_flag_clone = abnormal_exit_flag.clone();
let session_id_shared_clone = session_id_shared.clone();
let connection_error_clone = connection_error_shared.clone();
let error_notifier = self.notifier.clone();
// 🔥 连接失败通知通道:连接任务失败时立即通知,不等 60 秒超时
let (connection_failed_tx, connection_failed_rx) =
tokio::sync::oneshot::channel::<String>();
let mut connection_failed_tx = Some(connection_failed_tx);
// 保存 command_path 用于超时日志
let command_path_for_log = command_path.clone();
// 🔥 使用标准 tokio::spawn无需 LocalSet
// 保存 JoinHandle 用于超时时取消子任务
let spawn_project_id = project_id.clone();
let connection_task_handle = tokio::spawn(async move {
info!(
"[SACP] 🚀 Spawned ACP connection task, project_id={}",
spawn_project_id
);
let params = SacpConnectionParams {
project_path: project_path_clone,
project_id: project_id_clone.clone(),
mcp_servers,
start_config,
session_id_tx,
prompt_rx,
cancel_rx,
cancel_token: cancel_token_clone,
notifier: notifier_clone,
abnormal_exit_flag: abnormal_exit_flag_clone,
session_id_shared: session_id_shared_clone,
connection_failed_tx: connection_failed_tx.take(),
child_pid,
};
let result = run_sacp_connection(transport, params).await;
match &result {
Ok(_) => info!(
"[SACP] ✅ ACP connection task completed successfully, project_id={}",
spawn_project_id
),
Err(e) => error!(
"[SACP] ❌ ACP connection task failed: {}, project_id={}",
e, spawn_project_id
),
}
if let Err(e) = result {
error!("[SACP] Claude Code ACP Agent connection failed: {}", e);
// 存储连接错误到共享状态,供 "channel dropped" 时使用
if let Ok(mut guard) = connection_error_clone.lock() {
*guard = Some(format!("{}", e));
}
// 🔥 立即通知外层连接失败,避免等待 60 秒超时
if let Some(tx) = connection_failed_tx.take() {
let _ = tx.send(format!("{}", e));
}
// 🔥 关键修复:连接失败时发送错误通知到 SSE 流
// 只有在 session_id 已经初始化的情况下才能发送(连接建立后才会有 session_id
let session_id = session_id_shared
.lock()
.ok()
.and_then(|guard| guard.clone());
if let Some(session_id) = session_id {
warn!(
"[SACP] Sending error notification to SSE stream: project_id={}, session_id={}",
project_id_clone, session_id
);
let error = agent_client_protocol::schema::Error::new(
1001,
format!("ACP connection failed: {}", e),
);
if let Err(notify_err) = error_notifier
.notify_prompt_error(&project_id_clone, &session_id, error, None)
.await
{
error!("[SACP] Failed to send error notification: {:?}", notify_err);
}
} else {
debug!(
"[SACP] session_id not yet available, skipping error notification: project_id={}",
project_id_clone
);
}
}
});
// 等待会话 ID60 秒超时),同时监听连接失败
info!(
"[SACP] Waiting for session_id from ACP agent, project_id={}, timeout=60s",
project_id
);
let session_id = match tokio::time::timeout(
std::time::Duration::from_secs(60),
async {
tokio::select! {
result = session_id_rx => {
match result {
Ok(sid) => Ok(Ok(sid)),
Err(e) => Ok(Err(anyhow::anyhow!("channel dropped: {}", e))),
}
}
failed = connection_failed_rx => {
match failed {
Ok(err_msg) => Err(anyhow::anyhow!("{}", err_msg)),
Err(_) => Ok(Err(anyhow::anyhow!("connection ended without session_id or error"))),
}
}
}
},
)
.await
{
Ok(Ok(Ok(session_id))) => {
info!(
"[SACP] Received session_id from ACP agent: {}, project_id={}",
session_id, project_id
);
session_id
}
Err(_timeout_elapsed) => {
// 60 秒超时,连接任务仍在运行
let stderr_info = stderr_output_shared.lock().ok()
.map(|buf| buf.join("\n"))
.filter(|s| !s.is_empty())
.map(|s| format!("; stderr: {}", s))
.unwrap_or_default();
error!(
"[SACP] ⏰ Agent initialization timeout (60s), project_id={}, command={}, child_pid={}, stderr={}",
project_id, command_path_for_log, child_pid, stderr_info
);
// 超时后取消 spawned 任务,避免子进程泄漏
connection_task_handle.abort();
// kill 子进程(使用进程组 kill 清理所有孙进程)
#[cfg(unix)]
{
use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid;
if child_pid > 0 {
let pgid = Pid::from_raw(-(child_pid as i32));
match killpg(pgid, Signal::SIGKILL) {
Ok(_) => warn!(
"[SACP] Killed process group (SIGKILL) for child_pid={}, project_id={}",
child_pid, project_id
),
Err(e) => error!(
"[SACP] Failed to kill process group for child_pid={}: {}, project_id={}",
child_pid, e, project_id
),
}
}
}
return Err(anyhow::anyhow!(
"{}: agent initialization timeout (60s){}",
error_codes::get_i18n_message_default("error.agent_init_timeout"),
stderr_info
));
}
Ok(Err(e)) => {
// 连接任务主动报告了失败,立即返回
let err_str = e.to_string();
let stderr_info = stderr_output_shared.lock().ok()
.map(|buf| buf.join("\n"))
.filter(|s| !s.is_empty())
.map(|s| format!("; stderr: {}", s))
.unwrap_or_default();
let clean_msg = err_str
.strip_prefix("connection failed: ")
.unwrap_or(&err_str);
error!(
"[SACP] Agent connection failed early: project_id={}, error={}, stderr={}",
project_id, err_str, stderr_info
);
return Err(anyhow::anyhow!(
"Agent process failed: {}{}",
clean_msg,
stderr_info
));
}
Ok(Ok(Err(e))) => {
// channel dropped — 读取连接任务的实际错误原因
let connection_error = connection_error_shared.lock().ok()
.and_then(|guard| guard.clone())
.unwrap_or_else(|| "unknown error".to_string());
// 读取 stderr 输出
let stderr_info = stderr_output_shared.lock().ok()
.map(|buf| buf.join("\n"))
.filter(|s| !s.is_empty())
.map(|s| format!("; stderr: {}", s))
.unwrap_or_default();
error!(
"[SACP] session_id channel dropped (connection task failed): recv_error={}, actual_error={}, project_id={}",
e, connection_error, project_id
);
// 连接任务已自行结束,无需 abort
return Err(anyhow::anyhow!(
"{}: {}{}",
error_codes::get_i18n_message_default("error.agent_init_timeout"),
connection_error,
stderr_info
));
}
};
info!(
"[SACP] Claude Code ACP Agent service started successfully, session ID: {}",
session_id
);
// stderr 任务已在子进程启动后立即创建stderr_task_handle无需重复创建
// 创建生命周期守卫(带异常退出标志)
let lifecycle_guard = AgentLifecycleGuard::new_claude_with_abnormal_flag(
project_id.clone(),
session_id.clone(),
child,
stderr_task_handle,
cancel_token.clone(),
abnormal_exit_flag,
);
Ok(SacpLauncherConnectionInfo {
session_id,
prompt_tx,
cancel_tx,
lifecycle_guard: Arc::new(lifecycle_guard),
})
}
}
/// 从 Option 中取出 stdio 句柄,失败时返回错误
fn take_stdio<T>(opt: &mut Option<T>, name: &str) -> Result<T> {
opt.take()
.ok_or_else(|| anyhow::anyhow!("[SACP] Failed to get subprocess {}", name))
}
/// SACP 连接参数(封装 run_sacp_connection 的参数)
struct SacpConnectionParams<N: SessionNotifier> {
project_path: PathBuf,
project_id: String,
mcp_servers: Vec<McpServer>,
start_config: AgentStartConfig,
session_id_tx: tokio::sync::oneshot::Sender<SessionId>,
prompt_rx: mpsc::Receiver<PromptRequest>,
cancel_rx: mpsc::Receiver<CancelNotificationRequestWrapper>,
cancel_token: CancellationToken,
notifier: Arc<N>,
/// 🔥 新增:共享的异常退出标志(子进程异常退出时设置为 true
abnormal_exit_flag: Arc<AtomicBool>,
/// 共享的 session_id用于连接失败时发送错误通知
/// 在 connect_with 内部初始化完成后设置,供外部错误处理使用
session_id_shared: Arc<std::sync::Mutex<Option<String>>>,
/// 🔥 连接失败通知通道:内部失败时立即通知外层,避免等待超时
connection_failed_tx: Option<tokio::sync::oneshot::Sender<String>>,
/// 子进程 PID用于 waitpid 检测子进程退出
child_pid: u32,
}
/// 运行 SACP 连接
///
/// 使用 SACP 的 Builder 模式建立连接并处理消息
async fn run_sacp_connection<N: SessionNotifier + 'static>(
transport: agent_client_protocol::ByteStreams<
tokio_util::compat::Compat<tokio::process::ChildStdin>,
tokio_util::compat::Compat<tokio::process::ChildStdout>,
>,
params: SacpConnectionParams<N>,
) -> Result<()> {
// 解构参数
let SacpConnectionParams {
project_path,
project_id,
mcp_servers,
start_config,
session_id_tx,
mut prompt_rx,
mut cancel_rx,
cancel_token,
notifier,
abnormal_exit_flag,
session_id_shared,
mut connection_failed_tx,
child_pid,
} = params;
// 克隆变量供 handlers 使用
let notifier_for_handlers = notifier.clone();
let project_id_for_handlers = project_id.clone();
// 克隆 notifier 和 project_id 供 prompt 结束通知使用
let notifier_for_prompt_end = notifier.clone();
let project_id_for_prompt_end = project_id.clone();
// 使用 SACP Builder 模式
Client.builder()
.name("rcoder-agent-runner-sacp")
// 处理 SessionNotification 通知(使用 dispatch 方式,优雅处理未知消息类型)
.on_receive_dispatch(
{
let notifier = notifier_for_handlers.clone();
let project_id = project_id_for_handlers.clone();
async move |dispatch: Dispatch, _cx: ConnectionTo<Agent>| {
match dispatch {
Dispatch::Notification(message) => {
if SessionNotification::matches_method(&message.method) {
match SessionNotification::parse_message(&message.method, &message.params) {
Ok(notification) => {
handle_session_notification(notification, notifier.clone(), project_id.clone()).await;
Ok(Handled::Yes)
}
Err(err) => {
// 🔥 关键:未知消息类型只打 warn不中断连接
warn!(
"[SACP] Failed to parse SessionNotification, ignoring: method={}, error={:?}, json={:?}",
message.method, err, message.params
);
Ok(Handled::Yes)
}
}
} else {
Ok(Handled::No {
message: Dispatch::Notification(message),
retry: false,
})
}
}
other => Ok(Handled::No {
message: other,
retry: false,
}),
}
}
},
agent_client_protocol::on_receive_dispatch!(),
)
// 处理 RequestPermission
.on_receive_request(
move |request: RequestPermissionRequest,
responder: Responder<RequestPermissionResponse>,
_cx: ConnectionTo<Agent>| {
async move { handle_permission_request(request, responder).await }
},
agent_client_protocol::on_receive_request!(),
)
// 主连接逻辑
.connect_with(transport, move |cx: ConnectionTo<Agent>| {
let project_path = project_path.clone();
let mcp_servers = mcp_servers.clone();
let start_config = start_config.clone();
let notifier_for_prompt = notifier_for_prompt_end.clone();
let project_id_for_prompt = project_id_for_prompt_end.clone();
let abnormal_exit_flag = abnormal_exit_flag.clone();
let session_id_shared = session_id_shared.clone();
async move {
// 1. 初始化连接30 秒超时)
info!(
"[SACP] Step 1/4: Initializing ACP connection, project_id={}",
project_id
);
let init_request = InitializeRequest::new(VERSION)
.client_info(agent_client_protocol::schema::Implementation::new(
"rcoder-agent-runner",
env!("CARGO_PKG_VERSION"),
));
debug!("[SACP] Sending InitializeRequest: {:?}", init_request);
// 🔥 同时等待 InitializeRequest 和子进程退出
// 子进程崩溃时立即返回,不等 50 秒超时
let init_result = tokio::time::timeout(
std::time::Duration::from_secs(50),
async {
tokio::select! {
result = cx.send_request(init_request).block_task() => {
Ok(result)
}
exit_info = tokio::task::spawn_blocking(move || {
use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid};
use nix::unistd::Pid;
use nix::errno::Errno;
let target_pid = Pid::from_raw(child_pid as i32);
// 使用 WNOHANG 轮询特定子进程 PID
// 避免与 tokio 内部进程 reaper 竞争
loop {
match waitpid(target_pid, Some(WaitPidFlag::WNOHANG)) {
Ok(WaitStatus::Exited(pid, code)) => {
return Some((pid.as_raw(), code));
}
Ok(WaitStatus::Signaled(pid, signal, _)) => {
return Some((pid.as_raw(), signal.as_str().parse::<i32>().unwrap_or(-1)));
}
Ok(WaitStatus::StillAlive) => {
// 子进程仍在运行,等待 50ms 后重试
std::thread::sleep(std::time::Duration::from_millis(50));
continue;
}
Err(Errno::ECHILD) => {
// 子进程已被回收(可能被 tokio reaper 或已退出)
// 视为进程已退出
return Some((child_pid as i32, -1));
}
_ => {
// 其他错误
return None;
}
}
}
}) => {
match exit_info {
Ok(Some((pid, code))) => {
// 子进程退出,立即返回错误
if code == -1 {
// ECHILD: 进程已被回收,退出码未知
Err(anyhow::anyhow!("subprocess exited prematurely: pid={}, exit_code unknown (process already reaped)", pid))
} else {
Err(anyhow::anyhow!("subprocess exited prematurely: pid={}, exit_code={}", pid, code))
}
}
_ => {
// waitpid 未返回有效信息
Err(anyhow::anyhow!("subprocess exit detection failed for pid={}", child_pid))
}
}
}
}
}
).await;
let _init_response = match init_result {
Ok(Ok(Ok(result))) => {
// InitializeRequest 成功
result
}
Ok(Ok(Err(e))) => {
// send_request 返回错误
error!("[SACP] InitializeRequest error: {}, project_id={}", e, project_id);
if let Some(tx) = connection_failed_tx.take() {
let _ = tx.send(format!("InitializeRequest error: {}", e));
}
return Err(e);
}
Ok(Err(process_err)) => {
// 子进程异常退出
let err_msg = process_err.to_string();
error!("[SACP] Process exited during init: {}, project_id={}", err_msg, project_id);
if let Some(tx) = connection_failed_tx.take() {
let _ = tx.send(err_msg.clone());
}
return Err(agent_client_protocol::Error::new(1003, err_msg));
}
Err(_elapsed) => {
error!(
"[SACP] ⏰ InitializeRequest timeout (30s), project_id={}",
project_id
);
if let Some(tx) = connection_failed_tx.take() {
let _ = tx.send(format!("ACP InitializeRequest timeout (30s), project_id={}", project_id));
}
return Err(agent_client_protocol::Error::new(
1002,
format!("ACP InitializeRequest timeout (30s), project_id={}", project_id),
));
}
};
info!(
"[SACP] Step 1/4: ACP connection initialized successfully, project_id={}",
project_id
);
// 2. 构建 meta包含系统提示词和可能的 resume
let system_prompt_meta = start_config.build_meta();
// 构建不含 resume 的 clean meta用于 LoadSession 失败后回退到 NewSession
// NewSession 应该是全新会话,不应携带旧的 resume session_id
let new_session_meta = {
let mut meta = system_prompt_meta.clone();
if let Some(claude_code) = meta.get_mut("claudeCode").and_then(|v| v.as_object_mut()) {
if let Some(options) = claude_code.get_mut("options").and_then(|v| v.as_object_mut()) {
options.remove("resume");
}
}
meta
};
// 3. 创建或加载会话
// 从配置获取超时值,默认 60 秒
let timeout_secs = start_config
.acp_session_create_timeout_secs
.unwrap_or(60);
info!(
"[SACP] Step 3/4: Creating/loading session, project_id={}, timeout={}s, has_resume={}",
project_id, timeout_secs, start_config.resume_session_id.is_some()
);
// 🔥 修复:使用 Result 累积错误,避免 ? 操作符提前返回
// 无论成功失败,都确保能执行到 session_id_tx.send()
let session_result: Result<SessionId, String> = if let Some(ref resume_id) =
start_config.resume_session_id
{
// 有 resume_session_id尝试加载历史会话
info!(
"[SACP] Attempting to load existing session: {}",
resume_id
);
let load_request = LoadSessionRequest::new(
resume_id.clone(),
project_path.clone(),
)
.mcp_servers(mcp_servers.clone())
.meta(system_prompt_meta.clone());
debug!("load_session_request: {:?}", load_request);
match tokio::time::timeout(
tokio::time::Duration::from_secs(timeout_secs),
cx.send_request(load_request).block_task(),
)
.await
{
Ok(Ok(_response)) => {
// LoadSession 成功,使用请求中的 session_id
info!(
"[SACP] Session loaded successfully: {}, resuming session",
resume_id
);
Ok(SessionId::from(resume_id.clone()))
}
Ok(Err(load_err)) => {
// LoadSession 返回错误,降级到 NewSessionRequest
warn!(
"[SACP] LoadSession failed, falling back to NewSession: {}",
load_err
);
let cancel_notification =
CancelNotification::new(SessionId::from(resume_id.clone()));
if let Err(e) = cx.send_notification(cancel_notification) {
debug!(
"[SACP] Failed to send cancel notification for LoadSession: {}",
e
);
}
// 等待一小段时间让 agent 有机会清理
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
let new_request = NewSessionRequest::new(project_path.clone())
.mcp_servers(mcp_servers.clone())
.meta(new_session_meta.clone());
debug!("new_session_request: {:?}", new_request);
// 🔥 尝试 NewSession不要用 ? 操作符
match tokio::time::timeout(
tokio::time::Duration::from_secs(timeout_secs),
cx.send_request(new_request).block_task(),
)
.await
{
Ok(Ok(response)) => Ok(response.session_id),
Ok(Err(new_err)) => Err(format!(
"[SACP] LoadSession failed ({}), NewSession also failed ({})",
load_err, new_err
)),
Err(_) => Err(format!(
"[SACP] LoadSession failed (timeout), NewSession timeout"
)),
}
}
Err(_) => {
// LoadSession 超时,降级到 NewSessionRequest
warn!(
"[SACP] LoadSession timeout ({}s), falling back to NewSession",
timeout_secs
);
let cancel_notification =
CancelNotification::new(SessionId::from(resume_id.clone()));
if let Err(e) = cx.send_notification(cancel_notification) {
debug!(
"[SACP] Failed to send cancel notification for LoadSession: {}",
e
);
}
// 等待一小段时间让 agent 有机会清理
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
let new_request = NewSessionRequest::new(project_path.clone())
.mcp_servers(mcp_servers.clone())
.meta(new_session_meta.clone());
debug!("new_session_request: {:?}", new_request);
// 🔥 尝试 NewSession不要用 ? 操作符
match tokio::time::timeout(
tokio::time::Duration::from_secs(timeout_secs),
cx.send_request(new_request).block_task(),
)
.await
{
Ok(Ok(response)) => Ok(response.session_id),
Ok(Err(new_err)) => Err(format!(
"[SACP] LoadSession timeout, NewSession failed ({})",
new_err
)),
Err(_) => {
Err("[SACP] LoadSession timeout, NewSession timeout".to_string())
}
}
}
}
} else {
// 没有 resume_session_id创建新会话
info!("[SACP] Creating new ACP session (no resume_session_id)...");
let new_request = NewSessionRequest::new(project_path.clone())
.mcp_servers(mcp_servers.clone())
.meta(system_prompt_meta);
debug!("new_session_request: {:?}", new_request);
// 🔥 尝试 NewSession不要用 ? 操作符
match tokio::time::timeout(
tokio::time::Duration::from_secs(timeout_secs),
cx.send_request(new_request).block_task(),
)
.await
{
Ok(Ok(response)) => Ok(response.session_id),
Ok(Err(e)) => Err(format!("[SACP] NewSession failed: {}", e)),
Err(_) => Err(format!(
"[SACP] NewSession timeout ({}s)",
timeout_secs
)),
}
};
// 🔥 关键修复:在闭包最后统一处理 session 创建结果
// 确保无论成功失败都能执行到发送逻辑
let session_id = match session_result {
Ok(sid) => sid,
Err(err_msg) => {
error!("[SACP] Session creation failed: {}", err_msg);
return Err(agent_client_protocol::Error::new(1000, err_msg));
}
};
info!(
"[SACP] ACP session ready, session_id={}",
session_id
);
// 发送会话 ID 到主任务
if session_id_tx.send(session_id.clone()).is_err() {
error!("[SACP] unable to send session ID");
return Err(agent_client_protocol::Error::new(
1001,
error_codes::get_i18n_message_default("error.sacp_session_id_send_failed"),
));
}
// 同步设置共享 session_id供连接失败时的错误通知使用
if let Ok(mut guard) = session_id_shared.lock() {
*guard = Some(session_id.to_string());
}
// 4. 处理 Prompt 和 Cancel 请求
info!(
"[SACP] Step 4/4: Entering prompt processing loop, project_id={}, session_id={}",
project_id, session_id
);
let mut session_cancelled = false;
loop {
tokio::select! {
_ = cancel_token.cancelled() => {
// 🔥 检测取消原因,区分"正常取消"和"Agent 进程退出"
// 注意:如果在 prompt 处理中检测到取消,会在内层 loop 发送通知
// 这里只处理"没有正在处理的 prompt"时的情况
let is_abnormal = abnormal_exit_flag.load(Ordering::SeqCst);
if is_abnormal {
// Agent 进程异常退出,发送 SSE 错误通知
warn!(
"[SACP] Agent process exited abnormally, sending SSE error notification and disconnecting: project_id={}, session_id={}",
project_id_for_prompt, session_id
);
if let Err(e) = notifier_for_prompt
.notify_prompt_error(
&project_id_for_prompt,
&session_id.to_string(),
agent_client_protocol::Error::new(
1001,
error_codes::get_i18n_message_default("error.agent_process_abnormal_exit"),
),
None, // request_id 可能已经不可用
)
.await
{
error!("[SACP] send Agent error notification failed: {:?}", e);
} else {
info!("[SACP] already sent Agent error notification: project_id={}", project_id_for_prompt);
}
} else {
// 🔥 修复:正常取消时也要发送 PromptEnd确保状态回退 Idle
// 避免 Agent 一直卡在 Active 状态无法回收
if let Err(e) = notifier_for_prompt
.notify_prompt_end(
&project_id_for_prompt,
&session_id.to_string(),
agent_client_protocol::schema::StopReason::Cancelled,
Some(error_codes::get_i18n_message_default("error.session_cancelled")),
None,
)
.await
{
error!("[SACP] send PromptEnd (Cancelled) notification failed: {:?}", e);
} else {
info!(
"[SACP] Sent PromptEnd (Cancelled) notification, state will revert to Idle: project_id={}, session_id={}",
project_id_for_prompt, session_id
);
}
}
break;
}
Some(cancel_request) = cancel_rx.recv() => {
let session_id_str = cancel_request.cancel_notification.session_id.0.to_string();
info!("[SACP] received cancel request: session_id={}", session_id_str);
// 构建 SACP 版本的 CancelNotification 并发送到 Agent
let sacp_session_id = SessionId::new(Arc::from(session_id_str.as_str()));
let cancel_notification = CancelNotification::new(sacp_session_id);
if let Err(e) = cx.send_notification(cancel_notification) {
error!("[SACP] send cancel notification failed: {:?}", e);
// 通知调用方取消失败
let _ = cancel_request.result_tx.send(shared_types::CancelResult::Failed(
format!("Failed to send cancel notification: {:?}", e)
));
} else {
info!("[SACP] cancel notification sent");
// 通知调用方取消成功
let _ = cancel_request.result_tx.send(shared_types::CancelResult::Success);
// 标记会话已取消,当前 prompt 完成后退出循环
session_cancelled = true;
}
}
Some(prompt_request) = prompt_rx.recv() => {
// 收到新 prompt重置取消标志
// 场景:用户快速发送 prompt A → cancel → prompt B
// - cancel 设置 session_cancelled=true
// - prompt B 到达时重置为 false处理完后不 break
// - 保持 outer loop 存活以接收后续 prompt
session_cancelled = false;
debug!("[SACP] received Prompt request");
// 从 meta 中提取 request_id
let request_id = prompt_request
.meta
.as_ref()
.and_then(|meta| meta.get("request_id"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// 🎯 关键修复:通知状态管理器 Agent 开始处理 prompt
// 此时状态从 Pending -> Active确保状态与 agent 实际执行同步
let session_id_str = session_id.to_string();
if let Err(e) = notifier_for_prompt
.notify_prompt_start(
&project_id_for_prompt,
&session_id_str,
request_id.clone(),
)
.await
{
error!("[SACP] send PromptStart notification failed: {:?}", e);
} else {
info!(
"[SACP] PromptStart notification sent: session_id={}, request_id={:?}",
session_id_str, request_id
);
}
// 创建 Prompt 响应的 Future使用 pin! 来固定它
let prompt_future = cx.send_request(prompt_request).block_task();
tokio::pin!(prompt_future);
// 取消后的超时保护:收到取消请求后最多等待 10 秒
let cancel_timeout = tokio::time::sleep(std::time::Duration::from_secs(3600)); // 初始设置一个很长的超时
tokio::pin!(cancel_timeout);
let mut is_cancelled = false;
// 在等待 Prompt 响应时也监听取消请求
let prompt_result = loop {
tokio::select! {
biased;
// 🔥 监听 cancel_tokenAgent 进程退出时会触发)
_ = cancel_token.cancelled() => {
let is_abnormal = abnormal_exit_flag.load(Ordering::SeqCst);
if is_abnormal {
warn!(
"[SACP] Detected Agent process abnormal exit during prompt processing: project_id={}, session_id={}",
project_id_for_prompt, session_id
);
break Err(agent_client_protocol::Error::new(
1001,
error_codes::get_i18n_message_default("error.agent_process_abnormal_exit"),
));
} else {
// 正常取消(用户主动取消或 Agent 正常退出)
info!(
"[SACP] Received cancel signal during prompt processing: project_id={}, session_id={}",
project_id_for_prompt, session_id
);
break Err(agent_client_protocol::Error::new(
1002,
error_codes::get_i18n_message_default("error.session_cancelled"),
));
}
}
// 取消后的超时保护(只有 is_cancelled 为 true 时才有意义)
_ = &mut cancel_timeout, if is_cancelled => {
// 取消后超时,强制返回错误
warn!("[SACP] cancel message Prompt response timeout (10s), force exit");
break Err(agent_client_protocol::Error::new(
1001,
error_codes::get_i18n_message_default("error.cancel_response_timeout"),
));
}
// 检查取消请求(无论是否已取消都要接收,避免调用方超时)
Some(cancel_request) = cancel_rx.recv() => {
if is_cancelled {
// 🎯 已经在取消中,直接返回成功(通知已发送)
info!("[SACP] already sent cancel request, notification succeeded");
let _ = cancel_request.result_tx.send(shared_types::CancelResult::Success);
} else {
let session_id_str = cancel_request.cancel_notification.session_id.0.to_string();
info!("[SACP] received Prompt cancel request: session_id={}", session_id_str);
// 发送取消通知给 Agent
let sacp_session_id = SessionId::new(Arc::from(session_id_str.as_str()));
let cancel_notification = CancelNotification::new(sacp_session_id);
if let Err(e) = cx.send_notification(cancel_notification) {
error!("[SACP] send cancel notification failed: {:?}", e);
// 发送失败立即返回错误
let _ = cancel_request.result_tx.send(shared_types::CancelResult::Failed(
format!("Failed to send cancel notification: {:?}", e)
));
} else {
info!("[SACP] cancel notification sent");
// 🎯 立即返回成功,不阻塞调用方
let _ = cancel_request.result_tx.send(shared_types::CancelResult::Success);
is_cancelled = true;
// 设置超时保护:取消后最多等待 10 秒让 prompt 完成
cancel_timeout.as_mut().reset(tokio::time::Instant::now() + std::time::Duration::from_secs(10));
}
}
// 继续等待 Prompt 响应Agent 应该会因为取消而提前返回)
}
result = &mut prompt_future => {
// Prompt 响应完成
break result;
}
}
};
// 处理 Prompt 响应结果
match prompt_result {
Ok(response) => {
debug!("[SACP] Prompt response: stop_reason={:?}", response.stop_reason);
// 发送 PromptEnd 通知
if let Err(e) = notifier_for_prompt
.notify_prompt_end(
&project_id_for_prompt,
&session_id.to_string(),
response.stop_reason,
None,
request_id.clone(),
)
.await
{
error!("[SACP] send PromptEnd notification failed: {:?}", e);
} else {
info!(
"[SACP] PromptEnd notification sent: session_id={}, request_id={:?}",
session_id, request_id
);
}
}
Err(e) => {
// 🎯 区分"取消超时"和"真正的错误"
if is_cancelled {
// 取消超时:发送 PromptEnd (Cancelled) 而非 PromptError
info!("[SACP] cancel timeout, send PromptEnd (Cancelled): session_id={}", session_id);
if let Err(notify_err) = notifier_for_prompt
.notify_prompt_end(
&project_id_for_prompt,
&session_id.to_string(),
agent_client_protocol::schema::StopReason::Cancelled,
Some(error_codes::get_i18n_message_default("error.session_cancelled_timeout")),
request_id.clone(),
)
.await
{
error!("[SACP] send PromptEnd (Cancelled) notification failed: {:?}", notify_err);
}
} else {
// 真正的错误:发送 PromptError
error!("[SACP] Prompt request failed: {:?}", e);
if let Err(notify_err) = notifier_for_prompt
.notify_prompt_error(
&project_id_for_prompt,
&session_id.to_string(),
e,
request_id.clone(),
)
.await
{
error!("[SACP] send PromptError notification failed: {:?}", notify_err);
}
}
// 🔥 关键:如果 cancel_token 已取消,直接退出外层 loop
// 避免回到外层 loop 时再次触发 cancel_token.cancelled() 导致重复发送通知
if cancel_token.is_cancelled() {
info!("[SACP] Prompt completed but cancel_token already cancelled, exiting");
break;
}
}
}
// 🎯 关键设计cancel 后不退出 outer loop保持 Agent 子进程存活
//
// 为什么不能 break outer loop
// - outer loop break → spawned task 退出 → lifecycle_guard drop
// - LifecycleGuard::drop() → SIGKILL → Agent 子进程被杀
// - 子进程被杀 → 内存中的对话上下文丢失
// - 下次请求 get_or_create_session → is_channel_closed()=true → 创建新 session → 上下文断裂
//
// 正确行为:
// - inner loop 处理了 cancel → is_cancelled=true → inner loop 退出
// - notify_prompt_end(Cancelled) → 状态恢复 Idle
// - outer loop 继续等待 prompt_rx.recv() → 收到新 prompt → 复用同一 Agent 进程
// - 上下文连续:同一子进程、同一 SACP 连接、同一对话历史
//
// is_cancelled 不传播到 session_cancelled保持 outer loop 存活。
// session_cancelled 仅在 outer select 中收到 cancel无活跃 prompt时设置。
info!(
"[SACP] Prompt cancelled, session ready for next prompt: project_id={}, session_id={}",
project_id_for_prompt, session_id
);
}
else => {
// 所有通道已关闭
info!("[SACP] channels already closed, exiting");
break;
}
}
}
Ok(())
}
})
.await?;
Ok(())
}
/// 处理 SessionNotification 回调
async fn handle_session_notification<N: SessionNotifier>(
notification: SessionNotification,
notifier: Arc<N>,
project_id: String,
) {
let session_id = notification.session_id.to_string();
debug!(
"[SACP] SessionNotification: project_id={}, session_id={}, update={:?}",
project_id, session_id, notification.update
);
// 提取 request_id如果有
let request_id = notification
.meta
.as_ref()
.and_then(|meta| meta.get("request_id"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// 通过 notifier 推送会话更新
// SessionUpdate 通过 agent_client_protocol::schema 导入
if let Err(e) = notifier
.notify_session_update(&project_id, &session_id, notification.update, request_id)
.await
{
error!(
"[SACP] Push session update failed: project_id={}, session_id={}, error={:?}",
project_id, session_id, e
);
}
}
/// 处理 RequestPermission 回调
async fn handle_permission_request(
request: RequestPermissionRequest,
request_cx: Responder<RequestPermissionResponse>,
) -> Result<(), agent_client_protocol::Error> {
debug!("[SACP] permission request: {:?}", request);
// 自动允许:优先选择 AllowAlways其次 AllowOnce
let selected = request
.options
.iter()
.find(|o| o.kind == PermissionOptionKind::AllowAlways)
.or_else(|| {
request
.options
.iter()
.find(|o| o.kind == PermissionOptionKind::AllowOnce)
})
.or_else(|| request.options.first());
if let Some(option) = selected {
request_cx.respond(RequestPermissionResponse::new(
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
option.option_id.clone(),
)),
))
} else {
// 无可选项则取消
request_cx.respond(RequestPermissionResponse::new(
RequestPermissionOutcome::Cancelled,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use agent_config::ContextServerConfig;
#[test]
fn test_default_config() {
let config = get_default_sacp_agent_config(None, &shared_types::ServiceType::RCoder);
assert!(config.is_ok());
let config = config.unwrap();
// 命令应该是 "claude-code-acp-ts" 或其绝对路径(如果 which crate 能找到)
// 两种情况都是正确的
let cmd = &config.command;
assert!(
cmd == "claude-code-acp-ts" || cmd.ends_with("claude-code-acp-ts"),
"Expected command to be 'claude-code-acp-ts' or an absolute path ending with 'claude-code-acp-ts', got: {}",
cmd
);
}
#[test]
fn test_default_config_with_model_provider() {
let provider = ModelProviderConfig {
id: "test-id".to_string(),
name: "test-provider".to_string(),
api_key: "sk-test-key".to_string(),
base_url: "https://api.test.com".to_string(),
default_model: "test-model".to_string(),
requires_openai_auth: false,
api_protocol: None,
};
let config =
get_default_sacp_agent_config(Some(&provider), &shared_types::ServiceType::RCoder);
assert!(config.is_ok());
let config = config.unwrap();
// 验证 API Keyproxy 模式下为占位符,非 proxy 模式下为真实值
assert!(config.env.contains_key("ANTHROPIC_API_KEY"));
if cfg!(feature = "proxy") {
assert_eq!(
config.env.get("ANTHROPIC_API_KEY"),
Some(&API_KEY_PLACEHOLDER.to_string())
);
} else {
assert_eq!(
config.env.get("ANTHROPIC_API_KEY"),
Some(&"sk-test-key".to_string())
);
}
// 应该包含模型设置
assert!(config.env.contains_key("ANTHROPIC_MODEL"));
assert_eq!(
config.env.get("ANTHROPIC_MODEL"),
Some(&"test-model".to_string())
);
}
#[test]
fn test_default_config_disables_nonessential_traffic() {
let config = get_default_sacp_agent_config(None, &shared_types::ServiceType::RCoder);
assert!(config.is_ok());
let config = config.unwrap();
assert_eq!(
config.env.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"),
Some(&"1".to_string())
);
}
#[test]
fn test_default_config_with_openai_provider() {
let provider = ModelProviderConfig {
id: "test-openai".to_string(),
name: "openai".to_string(),
api_key: "sk-test-openai-key".to_string(),
base_url: "https://api.openai.com/v1".to_string(),
default_model: "openai-compatible/gpt-4".to_string(), // model_name 已包含前缀
requires_openai_auth: true,
api_protocol: Some("openai".to_string()),
};
let config =
get_default_sacp_agent_config(Some(&provider), &shared_types::ServiceType::RCoder);
assert!(config.is_ok());
let config = config.unwrap();
// 验证 OpenAI 环境变量
assert!(config.env.contains_key("OPENAI_API_KEY"));
assert!(config.env.contains_key("OPENAI_BASE_URL"));
if cfg!(feature = "proxy") {
assert_eq!(
config.env.get("OPENAI_API_KEY"),
Some(&API_KEY_PLACEHOLDER.to_string())
);
assert_eq!(
config.env.get("OPENAI_BASE_URL"),
Some(&DEFAULT_PROXY_BASE_URL.to_string())
);
} else {
assert_eq!(
config.env.get("OPENAI_API_KEY"),
Some(&"sk-test-openai-key".to_string())
);
assert_eq!(
config.env.get("OPENAI_BASE_URL"),
Some(&"https://api.openai.com/v1".to_string())
);
}
// nuwaxcode 使用 OPENCODE_MODEL直接使用 model_name已包含 openai-compatible/ 前缀)
assert!(config.env.contains_key("OPENCODE_MODEL"));
assert_eq!(
config.env.get("OPENCODE_MODEL"),
Some(&"openai-compatible/gpt-4".to_string())
);
// 同时验证 Anthropic 环境变量也存在 (兼容性)
assert!(config.env.contains_key("ANTHROPIC_API_KEY"));
assert!(config.env.contains_key("ANTHROPIC_BASE_URL"));
}
#[test]
fn test_sensitive_env_vars_protection() {
// 测试默认配置中的环境变量值
// proxy 模式下API_KEY 和 BASE_URL 为占位符/代理 URL
// 非 proxy 模式下API_KEY 和 BASE_URL 为真实值
let provider = ModelProviderConfig {
id: "test".to_string(),
name: "test".to_string(),
api_key: "sk-real-key-should-be-replaced".to_string(),
base_url: "https://real-url-should-be-replaced.com".to_string(),
default_model: "openai-compatible/gpt-4".to_string(),
requires_openai_auth: true,
api_protocol: Some("openai".to_string()),
};
let config =
get_default_sacp_agent_config(Some(&provider), &shared_types::ServiceType::RCoder);
assert!(config.is_ok());
let config = config.unwrap();
if cfg!(feature = "proxy") {
// proxy 模式下:敏感变量应该是占位符
assert_eq!(
config.env.get("ANTHROPIC_API_KEY"),
Some(&API_KEY_PLACEHOLDER.to_string())
);
assert_eq!(
config.env.get("OPENAI_API_KEY"),
Some(&API_KEY_PLACEHOLDER.to_string())
);
assert_eq!(
config.env.get("ANTHROPIC_BASE_URL"),
Some(&DEFAULT_PROXY_BASE_URL.to_string())
);
assert_eq!(
config.env.get("OPENAI_BASE_URL"),
Some(&DEFAULT_PROXY_BASE_URL.to_string())
);
} else {
// 非 proxy 模式下:使用真实值
assert_eq!(
config.env.get("ANTHROPIC_API_KEY"),
Some(&"sk-real-key-should-be-replaced".to_string())
);
assert_eq!(
config.env.get("OPENAI_API_KEY"),
Some(&"sk-real-key-should-be-replaced".to_string())
);
assert_eq!(
config.env.get("ANTHROPIC_BASE_URL"),
Some(&"https://real-url-should-be-replaced.com".to_string())
);
assert_eq!(
config.env.get("OPENAI_BASE_URL"),
Some(&"https://real-url-should-be-replaced.com".to_string())
);
}
}
#[test]
fn test_convert_context_servers_empty() {
let configs: HashMap<String, ContextServerConfig> = HashMap::new();
let servers = convert_context_servers_sacp(&configs);
assert!(servers.is_empty());
}
#[test]
fn test_convert_context_servers_disabled() {
let mut configs = HashMap::new();
configs.insert(
"disabled-server".to_string(),
ContextServerConfig {
source: "local".to_string(),
enabled: false,
command: Some("node".to_string()),
args: None,
env: None,
},
);
let servers = convert_context_servers_sacp(&configs);
assert!(servers.is_empty()); // disabled 的服务器应该被过滤
}
#[test]
fn test_convert_context_servers_no_command() {
let mut configs = HashMap::new();
configs.insert(
"no-command-server".to_string(),
ContextServerConfig {
source: "local".to_string(),
enabled: true,
command: None, // 没有命令
args: None,
env: None,
},
);
let servers = convert_context_servers_sacp(&configs);
assert!(servers.is_empty()); // 没有命令的服务器应该被过滤
}
#[test]
fn test_convert_context_servers_stdio() {
let mut configs = HashMap::new();
configs.insert(
"test-mcp".to_string(),
ContextServerConfig {
source: "local".to_string(),
enabled: true,
command: Some("node".to_string()),
args: Some(vec![
"server.js".to_string(),
"--port".to_string(),
"3000".to_string(),
]),
env: Some({
let mut env = HashMap::new();
env.insert("NODE_ENV".to_string(), "production".to_string());
env
}),
},
);
let servers = convert_context_servers_sacp(&configs);
assert_eq!(servers.len(), 1);
// 验证是 Stdio 类型
match &servers[0] {
McpServer::Stdio(stdio) => {
assert_eq!(stdio.name, "test-mcp");
}
_ => panic!("Expected Stdio variant"),
}
}
#[test]
fn test_convert_context_servers_multiple() {
let mut configs = HashMap::new();
configs.insert(
"server1".to_string(),
ContextServerConfig {
source: "local".to_string(),
enabled: true,
command: Some("node".to_string()),
args: Some(vec!["server1.js".to_string()]),
env: None,
},
);
configs.insert(
"server2".to_string(),
ContextServerConfig {
source: "local".to_string(),
enabled: true,
command: Some("python".to_string()),
args: Some(vec!["server2.py".to_string()]),
env: None,
},
);
configs.insert(
"disabled".to_string(),
ContextServerConfig {
source: "local".to_string(),
enabled: false,
command: Some("ruby".to_string()),
args: None,
env: None,
},
);
let servers = convert_context_servers_sacp(&configs);
// 应该只有 2 个 enabled 的服务器
assert_eq!(servers.len(), 2);
}
#[test]
fn test_sacp_agent_launch_config_fields() {
let config = SacpAgentLaunchConfig {
command: "test-cmd".to_string(),
args: vec!["arg1".to_string(), "arg2".to_string()],
env: {
let mut env = HashMap::new();
env.insert("KEY".to_string(), "VALUE".to_string());
env
},
context_servers: HashMap::new(),
};
assert_eq!(config.command, "test-cmd");
assert_eq!(config.args.len(), 2);
assert_eq!(config.env.get("KEY"), Some(&"VALUE".to_string()));
assert!(config.context_servers.is_empty());
}
#[test]
fn test_sacp_agent_launch_config_debug() {
let config = SacpAgentLaunchConfig {
command: "test".to_string(),
args: vec![],
env: HashMap::new(),
context_servers: HashMap::new(),
};
let debug_str = format!("{:?}", config);
assert!(debug_str.contains("SacpAgentLaunchConfig"));
assert!(debug_str.contains("test"));
}
// === mcp-proxy convert 诊断参数测试 ===
#[test]
fn test_is_mcp_proxy_command_simple() {
// 简化版只检测精确的命令名
assert!(is_mcp_proxy_command("mcp-proxy"));
// 不再检测大小写变体和路径
assert!(!is_mcp_proxy_command("MCP-PROXY"));
assert!(!is_mcp_proxy_command("Mcp-Proxy"));
}
#[test]
fn test_is_mcp_proxy_command_not_mcp_proxy() {
assert!(!is_mcp_proxy_command("node"));
assert!(!is_mcp_proxy_command("bunx"));
assert!(!is_mcp_proxy_command("/usr/bin/uvx"));
assert!(!is_mcp_proxy_command("mcp-proxy-other"));
// 路径形式不再匹配(简化版)
assert!(!is_mcp_proxy_command("/usr/local/bin/mcp-proxy"));
assert!(!is_mcp_proxy_command("C:\\Users\\test\\mcp-proxy.exe"));
}
#[test]
fn test_has_convert_subcommand() {
assert!(has_convert_subcommand(&["convert".to_string()]));
assert!(has_convert_subcommand(&[
"convert".to_string(),
"http://example.com".to_string()
]));
assert!(has_convert_subcommand(&[
"--config".to_string(),
"config.json".to_string(),
"convert".to_string()
]));
}
#[test]
fn test_has_convert_subcommand_no_convert() {
assert!(!has_convert_subcommand(&[]));
assert!(!has_convert_subcommand(&["serve".to_string()]));
assert!(!has_convert_subcommand(&[
"--config".to_string(),
"config.json".to_string()
]));
}
#[test]
fn test_enhance_mcp_proxy_args_non_mcp_proxy() {
// 非 mcp-proxy 命令,应该原样返回
let args = vec!["arg1".to_string(), "arg2".to_string()];
let result = enhance_mcp_proxy_args("node", args.clone());
assert_eq!(result, args);
}
#[test]
fn test_enhance_mcp_proxy_args_no_convert() {
// mcp-proxy 但没有 convert 子命令,应该原样返回
let args = vec!["serve".to_string()];
let result = enhance_mcp_proxy_args("mcp-proxy", args.clone());
assert_eq!(result, args);
}
#[test]
fn test_enhance_mcp_proxy_args_already_has_diagnostic() {
// 已有 --diagnostic 参数
let args = vec![
"convert".to_string(),
"--diagnostic".to_string(),
"--log-dir".to_string(),
"/tmp/logs".to_string(),
];
let result = enhance_mcp_proxy_args("mcp-proxy", args.clone());
// 应该原样返回,不重复添加
assert_eq!(result, args);
}
#[test]
fn test_get_mcp_proxy_log_dir_none_when_unset() {
// 清除环境变量以测试返回 None
// SAFETY: 测试环境中修改环境变量是安全的
unsafe {
std::env::remove_var(ENV_MCP_PROXY_LOG_DIR);
}
let log_dir = get_mcp_proxy_log_dir();
assert_eq!(log_dir, None);
}
#[test]
fn test_get_mcp_proxy_log_dir_from_env() {
let custom_dir = "/custom/mcp-proxy-logs";
// SAFETY: 测试环境中修改环境变量是安全的
unsafe {
std::env::set_var(ENV_MCP_PROXY_LOG_DIR, custom_dir);
}
let log_dir = get_mcp_proxy_log_dir();
assert_eq!(log_dir, Some(custom_dir.to_string()));
// 清理环境变量
// SAFETY: 测试环境中修改环境变量是安全的
unsafe {
std::env::remove_var(ENV_MCP_PROXY_LOG_DIR);
}
}
#[test]
fn test_has_log_dir_arg() {
// 检测 --log-dir 参数
assert!(has_log_dir_arg(&[
"--log-dir".to_string(),
"/tmp".to_string()
]));
assert!(has_log_dir_arg(&["--log-dir=/tmp".to_string()]));
assert!(has_log_dir_arg(&[
"convert".to_string(),
"--log-dir".to_string()
]));
// 检测 --log-file 参数
assert!(has_log_dir_arg(&[
"--log-file".to_string(),
"/tmp/log.txt".to_string()
]));
assert!(has_log_dir_arg(&["--log-file=/tmp/log.txt".to_string()]));
}
#[test]
fn test_has_log_dir_arg_no_log_args() {
assert!(!has_log_dir_arg(&[]));
assert!(!has_log_dir_arg(&["convert".to_string()]));
assert!(!has_log_dir_arg(&["--diagnostic".to_string()]));
assert!(!has_log_dir_arg(&[
"--config".to_string(),
"config.json".to_string()
]));
}
#[test]
fn test_enhance_args_respects_existing_log_dir() {
// 模拟 debug 日志级别
// SAFETY: 测试环境中修改环境变量是安全的
unsafe {
std::env::set_var("RUST_LOG", "debug");
std::env::set_var(ENV_MCP_PROXY_LOG_DIR, "/env/path");
}
// 用户已配置 --log-dir不应覆盖
let args = vec![
"convert".to_string(),
"--log-dir".to_string(),
"/custom/path".to_string(),
];
let result = enhance_mcp_proxy_args("mcp-proxy", args);
// 应该只追加 --diagnostic不重复追加 --log-dir
assert!(result.contains(&"--diagnostic".to_string()));
// 只应有一个 --log-dir
assert_eq!(result.iter().filter(|a| *a == "--log-dir").count(), 1);
// --log-dir 的值应该是用户配置的 /custom/path
let log_dir_idx = result.iter().position(|a| a == "--log-dir").unwrap();
assert_eq!(
result.get(log_dir_idx + 1),
Some(&"/custom/path".to_string())
);
// 清理环境变量
// SAFETY: 测试环境中修改环境变量是安全的
unsafe {
std::env::remove_var("RUST_LOG");
std::env::remove_var(ENV_MCP_PROXY_LOG_DIR);
}
}
#[test]
fn test_enhance_args_respects_existing_log_file() {
// 模拟 debug 日志级别
// SAFETY: 测试环境中修改环境变量是安全的
unsafe {
std::env::set_var("RUST_LOG", "debug");
std::env::set_var(ENV_MCP_PROXY_LOG_DIR, "/env/path");
}
// 用户已配置 --log-file不应追加 --log-dir
let args = vec![
"convert".to_string(),
"--log-file=/custom/file.log".to_string(),
];
let result = enhance_mcp_proxy_args("mcp-proxy", args);
// 应该只追加 --diagnostic
assert!(result.contains(&"--diagnostic".to_string()));
// 不应有 --log-dir
assert!(!result.iter().any(|a| a == "--log-dir"));
// 清理环境变量
// SAFETY: 测试环境中修改环境变量是安全的
unsafe {
std::env::remove_var("RUST_LOG");
std::env::remove_var(ENV_MCP_PROXY_LOG_DIR);
}
}
#[test]
fn test_enhance_args_adds_log_dir_when_env_set() {
// 模拟 debug 日志级别和配置了 MCP_PROXY_LOG_DIR
// SAFETY: 测试环境中修改环境变量是安全的
unsafe {
std::env::set_var("RUST_LOG", "debug");
std::env::set_var(ENV_MCP_PROXY_LOG_DIR, "/var/log/mcp");
}
let args = vec!["convert".to_string()];
let result = enhance_mcp_proxy_args("mcp-proxy", args);
// 应该追加 --diagnostic 和 --log-dir
assert!(result.contains(&"--diagnostic".to_string()));
assert!(result.contains(&"--log-dir".to_string()));
assert!(result.contains(&"/var/log/mcp".to_string()));
// 清理环境变量
// SAFETY: 测试环境中修改环境变量是安全的
unsafe {
std::env::remove_var("RUST_LOG");
std::env::remove_var(ENV_MCP_PROXY_LOG_DIR);
}
}
#[test]
fn test_enhance_args_no_log_dir_when_env_unset() {
// 模拟 debug 日志级别但没有配置 MCP_PROXY_LOG_DIR
// SAFETY: 测试环境中修改环境变量是安全的
unsafe {
std::env::set_var("RUST_LOG", "debug");
std::env::remove_var(ENV_MCP_PROXY_LOG_DIR);
}
let args = vec!["convert".to_string()];
let result = enhance_mcp_proxy_args("mcp-proxy", args);
// 应该只追加 --diagnostic不应有 --log-dir
assert!(result.contains(&"--diagnostic".to_string()));
assert!(!result.iter().any(|a| a == "--log-dir"));
// 清理环境变量
// SAFETY: 测试环境中修改环境变量是安全的
unsafe {
std::env::remove_var("RUST_LOG");
}
}
}