把 AGC 的 cc 执行器与本机 Claude Code 配置彻底隔离
Project CI / AI game creator shell Rust crates (push) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 4m14s
Project CI / Backend tests (push) Successful in 4m38s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 5m23s
Project CI / Frontend tests (push) Successful in 2m8s
Project CI / Native shell tests (push) Successful in 6m11s
Project CI / Repository checks (push) Successful in 3m15s
Project CI / AI game creator shell web tests (push) Successful in 2m34s
Project CI / AI game creator shell Rust crates (push) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 4m14s
Project CI / Backend tests (push) Successful in 4m38s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 5m23s
Project CI / Frontend tests (push) Successful in 2m8s
Project CI / Native shell tests (push) Successful in 6m11s
Project CI / Repository checks (push) Successful in 3m15s
Project CI / AI game creator shell web tests (push) Successful in 2m34s
- 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 记录配置隔离、静默超时语义与脱敏标记的坑
This commit is contained in:
@@ -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<String> = 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::<std::collections::HashMap<_, _>>();
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=<x-api-key|bearer|none>`(只写主机名,不含路径与凭据)。这一行是判断"回合到底发到哪个网关"的唯一低成本证据。
|
||||
- **验证**:`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=<Platform|Custom|Config> host=<…> auth=<key|session|none>`(只写主机名,不含路径与凭据)。这一行是判断"回合到底发到哪个网关"的唯一低成本证据。
|
||||
- **注意**:日志脱敏标记包含 `credential`、`x-api-key`、`bearer `、`token=`、`api_key`,命中即整行替换成 `<sensitive diagnostic details redacted>`。诊断行只能写 `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)。
|
||||
|
||||
Reference in New Issue
Block a user