From db0332ea096931f732b952a01fd81b0d7e843459 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:54:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=8A=20AGC=20=E7=9A=84=20cc=20=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=99=A8=E4=B8=8E=E6=9C=AC=E6=9C=BA=20Claude=20Code?= =?UTF-8?q?=20=E9=85=8D=E7=BD=AE=E5=BD=BB=E5=BA=95=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sidecar 子进程把 HOME、USERPROFILE、CLAUDE_CONFIG_DIR 全部指向项目内隔离目录,不再读用户自己的 ~/.claude.json(个人 MCP、插件市场、凭据),也不再往真实 profile 写 projects / sessions - requestTimeoutMs 在 cc 执行器里改为静默预算:sidecar 每有一条事件就重置,连续静默超预算才按 sidecar-turn-timeout 收口 - 新增 CLAUDE_CODE_TURN_MAX_DURATION(45 分钟)兜底事件流假活,正常情况更早到的是 DirectProject 的 maxTurnSeconds - claude_route 诊断行改成 source / host / auth,避开日志脱敏标记(原 credential=bearer 会被整行替换) - 新增 sidecar 环境隔离单测:USERPROFILE / CLAUDE_CONFIG_DIR 指向隔离目录,且不下发本机 ANTHROPIC_* - pitfalls.md 记录配置隔离、静默超时语义与脱敏标记的坑 --- .../src-tauri/src/agent/claude_code_cli.rs | 154 +++++++++++++++--- docs/project-memory/shared-memory/pitfalls.md | 7 +- 2 files changed, 133 insertions(+), 28 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 276af0ae5..7fd66c9fa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -29,6 +29,9 @@ const CLAUDE_CODE_STDERR_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); /// 不留痕的话用户与离线诊断只能看到一个卡住的回合(真实案例:ECONNRESET 重试, /// 每轮 ~60 秒,共 10 轮)。 const CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT: Duration = Duration::from_secs(30); +/// cc 回合的绝对上限。`requestTimeoutMs` 只算静默预算,事件流假活(一直有心跳但永远 +/// 不出终态)靠这个兜底;正常情况下先到的是 DirectProject 的 `maxTurnSeconds`。 +const CLAUDE_CODE_TURN_MAX_DURATION: Duration = Duration::from_secs(45 * 60); struct ActiveClaudeCodeTurn { client_turn_id: String, @@ -163,6 +166,18 @@ struct SidecarTurnResult { result: serde_json::Value, } +/// sidecar 回合的失败分类:静默超时必须先杀进程树再收口,普通失败直接透传。 +enum SidecarTurnFailure { + Silent { silent_ms: u64, events: usize }, + Message(String), +} + +impl SidecarTurnFailure { + fn message(message: String) -> Self { + Self::Message(message) + } +} + async fn run_sidecar_turn( root: &Path, llm: Option<&GameCreatorLlmConfig>, @@ -224,35 +239,58 @@ async fn run_sidecar_turn( .take() .ok_or_else(|| "Claude Agent SDK sidecar stderr 未建立".to_string())?; let stderr_task = tokio::spawn(read_capped(stderr, CLAUDE_CODE_OUTPUT_MAX_BYTES)); - let result = match tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), async { + // cc 回合经常是一串模型调用 + MCP 工具调用,网关慢的时候单次响应就要几十秒; + // `requestTimeoutMs` 只当**静默预算**用:只要 sidecar 还在出事件就不算超时, + // 连续静默超过预算才按超时收口。整体上限由 `maxTurnSeconds`(DirectProject 硬上限) + // 兜底,这里再加一个更宽松的绝对上限防止事件流假活。 + let silence_budget_ms = timeout_ms.max(1); + let mut timeout_reason: Option = None; + let turn = async { let mut lines = BufReader::new(stdout).lines(); let mut events = Vec::new(); let mut terminal = None; let mut idle_rounds = 0_u64; + let mut silent_ms = 0_u64; loop { let line = match tokio::time::timeout(CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT, lines.next_line()) .await { - Ok(Ok(Some(line))) => line, + Ok(Ok(Some(line))) => { + silent_ms = 0; + line + } Ok(Ok(None)) => break, Ok(Err(error)) => { - return Err(format!("读取 Claude Agent SDK sidecar 输出失败:{error}")); + return Err(SidecarTurnFailure::message(format!( + "读取 Claude Agent SDK sidecar 输出失败:{error}" + ))); } Err(_) => { idle_rounds += 1; + silent_ms += CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT.as_millis() as u64; app_log!( - "agent.direct_codex.claude_sidecar_idle idleSeconds={}", - CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT.as_secs() * idle_rounds + "agent.direct_codex.claude_sidecar_idle idleSeconds={} events={}", + CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT.as_secs() * idle_rounds, + events.len() ); + if silent_ms >= silence_budget_ms { + return Err(SidecarTurnFailure::Silent { + silent_ms, + events: events.len(), + }); + } continue; } }; if line.trim().is_empty() { continue; } - let value: serde_json::Value = serde_json::from_str(&line) - .map_err(|_| "Claude Agent SDK sidecar 输出不是有效 JSON".to_string())?; + let value: serde_json::Value = serde_json::from_str(&line).map_err(|_| { + SidecarTurnFailure::message( + "Claude Agent SDK sidecar 输出不是有效 JSON".to_string(), + ) + })?; match value.get("type").and_then(serde_json::Value::as_str) { Some("event") => { if let Some(event) = value.get("event") { @@ -265,29 +303,53 @@ async fn run_sidecar_turn( } Some("error") => { kill_claude_code_process_tree(pid); - return Err(value - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("Claude Agent SDK sidecar 执行失败") - .to_string()); + return Err(SidecarTurnFailure::message( + value + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("Claude Agent SDK sidecar 执行失败") + .to_string(), + )); } _ => {} } } - let status = child - .wait() - .await - .map_err(|error| format!("等待 Claude Agent SDK sidecar 结束失败:{error}"))?; - let result = terminal.ok_or_else(|| "Claude Agent SDK sidecar 缺少终态".to_string())?; + let status = child.wait().await.map_err(|error| { + SidecarTurnFailure::message(format!("等待 Claude Agent SDK sidecar 结束失败:{error}")) + })?; + let result = terminal.ok_or_else(|| { + SidecarTurnFailure::message("Claude Agent SDK sidecar 缺少终态".to_string()) + })?; if !status.success() { - return Err("Claude Agent SDK sidecar 非零退出".to_string()); + return Err(SidecarTurnFailure::message( + "Claude Agent SDK sidecar 非零退出".to_string(), + )); } Ok(SidecarTurnResult { events, result }) - }) - .await - { - Ok(result) => result?, + }; + let outcome = match tokio::time::timeout(CLAUDE_CODE_TURN_MAX_DURATION, turn).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(SidecarTurnFailure::Message(message))) => Err(message), + Ok(Err(SidecarTurnFailure::Silent { silent_ms, events })) => { + timeout_reason = Some(format!( + "Claude Agent SDK sidecar 回合超时:连续 {silent_ms} ms 没有任何事件(已收到 {events} 个事件)" + )); + Err(String::new()) + } Err(_) => { + timeout_reason = Some(format!( + "Claude Agent SDK sidecar 回合超时:超过 {} 分钟硬上限", + CLAUDE_CODE_TURN_MAX_DURATION.as_secs() / 60 + )); + Err(String::new()) + } + }; + let result = match outcome { + Ok(result) => result, + Err(message) => { + let Some(reason) = timeout_reason else { + return Err(message); + }; // 先杀进程树:不杀的话 sidecar(连同它守护的 Claude Code CLI)会一直活着, // stderr 等不到 EOF,这一轮就永远出不了终态。 kill_claude_code_process_tree(pid); @@ -303,7 +365,7 @@ async fn run_sidecar_turn( let detail = stderr_detail .map(|value| format!(":{value}")) .unwrap_or_default(); - return Err(format!("Claude Agent SDK sidecar 回合超时{detail}")); + return Err(format!("{reason}{detail}")); } }; let _ = stderr_task.await; @@ -519,8 +581,15 @@ fn configure_claude_code_environment( ] { copy_env(command, name); } + let isolated_claude_home = isolated_home.join("claude"); + let _ = std::fs::create_dir_all(&isolated_claude_home); command .env("HOME", isolated_home) + // Windows 上 Node 的 `os.homedir()` 只看 `USERPROFILE`;不隔离它,Claude Code 会去读 + // 用户自己的 `~/.claude.json` / `~/.claude`(个人 MCP、插件市场、凭据),并把会话写回 + // 那里——AGC 的 cc 执行器必须与用户本机的 Claude Code 配置完全隔离。 + .env("USERPROFILE", isolated_home) + .env("CLAUDE_CONFIG_DIR", isolated_claude_home) .env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") .env("DISABLE_AUTOUPDATER", "1") .env("CI", "1") @@ -529,7 +598,7 @@ fn configure_claude_code_environment( let session = crate::platform_session::current_platform_session(); let route = claude_code_route(llm, session.as_ref()); app_log!( - "agent.direct_codex.claude_route source={:?} host={} credential={}", + "agent.direct_codex.claude_route source={:?} host={} auth={}", route.source, route .base_url @@ -537,8 +606,8 @@ fn configure_claude_code_environment( .map(claude_route_host) .unwrap_or_default(), match route.credential { - Some(ClaudeCodeCredential::ApiKey(_)) => "x-api-key", - Some(ClaudeCodeCredential::Bearer(_)) => "bearer", + Some(ClaudeCodeCredential::ApiKey(_)) => "key", + Some(ClaudeCodeCredential::Bearer(_)) => "session", None => "none", } ); @@ -1145,4 +1214,37 @@ mod tests { ); assert_eq!(claude_route_host("http://127.0.0.1:8084"), "127.0.0.1:8084"); } + + #[test] + fn sidecar_environment_is_isolated_from_the_local_claude_code_home() { + let home = std::env::temp_dir().join("agc-cc-isolated-home-test"); + let mut command = tokio::process::Command::new("node"); + configure_claude_code_environment(&mut command, None, &home); + let envs = command + .as_std() + .get_envs() + .map(|(name, value)| { + ( + name.to_string_lossy().to_string(), + value.map(|value| value.to_string_lossy().to_string()), + ) + }) + .collect::>(); + let home_text = home.to_string_lossy().to_string(); + for name in ["HOME", "USERPROFILE"] { + assert_eq!(envs.get(name), Some(&Some(home_text.clone())), "{name}"); + } + assert_eq!( + envs.get("CLAUDE_CONFIG_DIR"), + Some(&Some(home.join("claude").to_string_lossy().to_string())) + ); + // 本机个人的 Claude Code 环境(中转地址与凭据)不得进入 sidecar。 + for name in [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + ] { + assert!(!envs.contains_key(name), "{name} 不应下发到 sidecar"); + } + } } diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b4115d4ea..d0488a25a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6263,6 +6263,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现象**:用户终端里给 Claude Code 配了自己的中转(`ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`)。AGC 选 `claude-opus-5-5` 发消息时好时坏:偶尔两分半回一句话,多数回合一路静默到 `requestTimeoutMs`(180s)超时,日志只有 `claude-sidecar-start` 加 `claude_sidecar_idle idleSeconds=30/60/…`。同一时间平台网关、模型目录、账号 Router 分组怎么改都没反应。 - **根因**:`claude_code_cli.rs` 先把 AGC 进程继承来的 `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` 原样复制给 sidecar,再让它们优先于平台会话(`claude_base_url` 里 `std::env::var("ANTHROPIC_BASE_URL")` 排在 `.or_else` 最前,凭据有 `if … is_none()` 守卫因此永远不覆盖)。实测:直接跑 sidecar 探针,`claude.exe`(PID 43256)的两条 443 连接落在 `198.18.2.60` = 用户个人的 `yunyi.rdzhvip.com`,完全没碰 `dev.genarrative.world` / Router;同一个 token 对 `POST https://yunyi.rdzhvip.com/claude/v1/messages` 45 秒不返回(Bearer 与 `x-api-key` 都一样)。用户 `~/.claude/settings.json` 里的 env 反而无关——sidecar 用 `settingSources: []` 关掉了设置文件来源,事故来源是**进程环境**。 - **现行口径**:cc 的路由与凭据只由 AGC 决定,本机 `ANTHROPIC_*` **完全不参与**(`claude_code_route` 的入参里没有进程环境)。官方模型 + 平台会话 → `{apiBaseUrl}/api/llm/anthropic` + `Bearer <平台 access token>`;`customEnabled` 或配了 `llm.apiKey` → AGC 配置的 baseUrl(自定义端点用 `x-api-key`,其余保持 Bearer);没登录也没配 key 就报「cc 模式需要先登录陶泥儿账号」,不再退回本机环境。`ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` 也不再进 `copy_env` 白名单,只由解析出的路由下发。个人中转要走 AGC 的 `customEnabled` + baseUrl + apiKey,不做隐式继承。 -- **排查手段**:sidecar 每次启动都写一行 `agent.direct_codex.claude_route source=<…> host=<…> credential=`(只写主机名,不含路径与凭据)。这一行是判断"回合到底发到哪个网关"的唯一低成本证据。 -- **验证**:`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell claude_code_cli::tests::` 10 passed,覆盖平台优先、自定义端点保留配置、未登录时不回落本机环境且报「先登录」、baseUrl `/v1` 归一化与主机名诊断。 +- **同时(配置隔离)**:sidecar 子进程把 `HOME`、`USERPROFILE`、`CLAUDE_CONFIG_DIR` 全部指向项目内隔离目录(`.agent/runtime/claude-code/home`)。只改 `HOME` 不够——Windows 上 Node 的 `os.homedir()` 只看 `USERPROFILE`,Claude Code 会照样读用户自己的 `~/.claude.json`(个人 `mcpServers`、插件市场、凭据)并把 `projects/`、`sessions/` 写回真实 profile。实测隔离前 CLI 先花 ~90 秒在个人配置上、真实 `~/.claude/sessions` 每次回合都被写;隔离后 `time_to_request_ms=41`,且只写隔离目录。 +- **同时(超时语义)**:`requestTimeoutMs`(默认 180000)在 cc 执行器里只作**静默预算**:sidecar 每有一条事件就重置,连续静默超过预算才按 `sidecar-turn-timeout` 收口;另有 `CLAUDE_CODE_TURN_MAX_DURATION`(45 分钟)兜底事件流假活,正常情况更早到的是 DirectProject 的 `maxTurnSeconds`。原来「整回合 180 秒墙钟」会把「网关慢但仍在下发事件」的正常回合掐死——实测 19:46 那轮 sidecar 一直在出事件,第 180 秒被墙钟杀掉。 +- **排查手段**:sidecar 每次启动都写一行 `agent.direct_codex.claude_route source= host=<…> auth=`(只写主机名,不含路径与凭据)。这一行是判断"回合到底发到哪个网关"的唯一低成本证据。 +- **注意**:日志脱敏标记包含 `credential`、`x-api-key`、`bearer `、`token=`、`api_key`,命中即整行替换成 ``。诊断行只能写 `source=`/`host=`/`auth=` 这类自查过的字段(第一版写成 `credential=bearer`,整行被吃掉过一次)。 +- **验证**:`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell claude_code_cli::tests::` 11 passed,覆盖平台优先、自定义端点保留配置、未登录时不回落本机环境且报「先登录」、baseUrl `/v1` 归一化、主机名诊断、sidecar 环境隔离(`USERPROFILE`/`CLAUDE_CONFIG_DIR` 指向隔离目录且不带本机 `ANTHROPIC_*`);另用本地假 Anthropic 端点跑通真 sidecar:`REQ HEAD /api/hello` → `REQ POST /v1/messages?beta=true auth=bearer` → `result=PROBE_OK`。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`、[`【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`](../../technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md)。