diff --git a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs index 07682aafc..34a4dd31a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs @@ -16,7 +16,7 @@ use shared_contracts::runtime::{ ProfileReferralInviteCenterResponse, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse, }; -use std::time::Duration; +use std::{path::Path, time::Duration}; use url::Url; const HTTP_TIMEOUT: Duration = Duration::from_secs(30); @@ -37,7 +37,35 @@ fn build_client() -> Result { .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建账户网络客户端失败".to_string()) + .map_err(|error| format!("创建账户网络客户端失败:{error}")) +} + +fn describe_account_request_failure(error: &reqwest::Error) -> String { + let kind = if error.is_timeout() { + "请求超时" + } else if error.is_connect() { + "连接失败" + } else if error.is_body() { + "响应正文读取失败" + } else if error.is_request() { + "请求发送失败" + } else { + "网络请求失败" + }; + let mut causes = Vec::new(); + let mut source = std::error::Error::source(error); + while let Some(current) = source { + let text = current.to_string(); + if !text.is_empty() && !causes.contains(&text) { + causes.push(text); + } + source = current.source(); + } + if causes.is_empty() { + kind.to_string() + } else { + format!("{kind}:{}", causes.join(" → ")) + } } fn endpoint(snapshot: &PlatformSessionSnapshot, segments: &[&str]) -> Result { @@ -87,30 +115,55 @@ fn error_code(body: &str) -> Option { error_field(body, "code") } +fn safe_error_body_detail(body: &str) -> Option { + let value = serde_json::from_str::(body).ok(); + let detail = value + .as_ref() + .and_then(|value| value.get("error").or(Some(value))) + .and_then(|value| serde_json::to_string(value).ok()) + .or_else(|| (!body.trim().is_empty()).then(|| body.trim().to_string()))?; + if matches!(detail.trim(), "{}" | "null" | "\"\"") { + return None; + } + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 600, + ); + (!detail.trim().is_empty()).then_some(detail) +} + fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); + let server_detail = error_message(body) + .or_else(|| error_code(body)) + .or_else(|| safe_error_body_detail(body)); if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); + return match server_detail { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; } if status == StatusCode::FORBIDDEN { return format!( "permission-denied: {}", - error_message(body).unwrap_or_else(|| "当前账号无权执行此操作".to_string()) + server_detail.unwrap_or_else(|| "当前账号无权执行此操作".to_string()) ); } - let detail = error_message(body) - .or_else(|| error_code(body)) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + let detail = server_detail.unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") } fn unwrap_envelope(body: &str, fallback: &str) -> Result { - let value: Value = - serde_json::from_str(body).map_err(|_| format!("{fallback}:服务端响应不是合法 JSON"))?; + let value: Value = serde_json::from_str(body) + .map_err(|error| format!("{fallback}:服务端响应不是合法 JSON:{error}"))?; if value.get("ok").and_then(Value::as_bool) == Some(false) { let detail = error_message(body) .or_else(|| error_code(body)) - .unwrap_or_else(|| "服务器未返回错误信息".to_string()); + .or_else(|| safe_error_body_detail(body)) + .unwrap_or_else(|| "服务端返回 ok=false,但未提供 error/code/message 详情".to_string()); return Err(format!("{fallback}:{detail}")); } Ok(value.get("data").cloned().unwrap_or(value)) @@ -158,22 +211,21 @@ async fn request_json( request = request.json(&body); } let response = request.send().await.map_err(|error| { - if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - } + format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_account_request_failure(&error) + ) })?; let status = response.status(); let text = response .text() .await - .map_err(|_| format!("{fallback}:读取响应失败"))?; + .map_err(|error| format!("{fallback}:读取响应失败:{error}"))?; if !status.is_success() { return Err(map_http_error(status, &text, fallback)); } let data = unwrap_envelope(&text, fallback)?; - serde_json::from_value(data).map_err(|_| format!("{fallback}:响应格式无效")) + serde_json::from_value(data).map_err(|error| format!("{fallback}:响应格式无效:{error}")) } #[tauri::command] @@ -344,6 +396,14 @@ mod tests { map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"), "authentication-required: 陶泥儿登录态已过期,请重新登录后重试" ); + assert_eq!( + map_http_error( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"access token expired"}}"#, + "读取失败", + ), + "authentication-required: 陶泥儿登录态已过期,请重新登录后重试:access token expired" + ); assert_eq!( map_http_error( StatusCode::FORBIDDEN, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 57da398bf..63d8b9378 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -44,7 +44,7 @@ use art_manifest::*; #[cfg(not(test))] pub(crate) use claude_code_cli::direct_game_creator_claude_code_home_chat; pub(crate) use claude_code_cli::{ - cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error, + cancel_direct_claude_code_turn_at, claude_code_failure_to_turn_error, direct_game_creator_claude_code_chat_at, game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity, }; 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 6cc1e86ee..d5cd0fa8a 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 @@ -5,6 +5,7 @@ //! protocols are never mixed. use super::*; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; @@ -14,6 +15,20 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; const CLAUDE_AGENT_SIDECAR_ENV: &str = "GENARRATIVE_CLAUDE_AGENT_SIDECAR"; const CLAUDE_NODE_ENV: &str = "GENARRATIVE_CLAUDE_NODE"; +/// DirectProject 的一次 CC 回合可能包含代码生成、试玩、修复和再次复核;8 次会在真实 +/// 游戏链路的修复阶段提前收口成 `Reached maximum number of turns (8)`。 +const CLAUDE_DIRECT_MAX_TURNS: u32 = 16; + +/// cc 侧 loopback MCP 工具调用的硬超时(毫秒)。 +/// +/// Claude Code 的 MCP 工具调用默认只有 60 秒硬墙,且"进度通知不会延长"它。AGC 的工具里 +/// 包含真实付费与长耗时操作:图片生成一次约 56~70 秒,浏览器试玩、构建、验证也都在分钟级。 +/// 现场证据(2026-10-06,项目 gameagent-cfd26650):模型在 13:05:30.760 发起两个 +/// `agc_generate_image`,13:06:30.767(正好 60.0 秒)MCP 客户端返回 +/// `{"content":"The operation timed out.","is_error":true}`,随后同轮的第二个调用被判 +/// `direct-execution-interrupted: 原执行未正常结算`,本轮账本落成 `phase=interrupted`。 +/// Codex 侧对同一套工具桥已设 `tool_timeout_sec=6600`(110 分钟),这里按同一口径对齐。 +const CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS: u64 = 110 * 60 * 1_000; const CLAUDE_CODE_OUTPUT_MAX_BYTES: usize = 8 * 1024 * 1024; #[cfg(not(test))] const CLAUDE_CODE_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; @@ -59,20 +74,33 @@ fn claude_project_key(root: &Path) -> PathBuf { std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()) } -fn kill_claude_code_process_tree(pid: u32) { +fn kill_claude_code_process_tree(pid: u32) -> Result<(), String> { #[cfg(unix)] - unsafe { - libc::kill(-(pid as i32), libc::SIGKILL); + { + let result = unsafe { libc::kill(-(pid as i32), libc::SIGKILL) }; + if result != 0 { + return Err(format!( + "终止 Claude Code sidecar 进程树失败:{}", + std::io::Error::last_os_error() + )); + } } #[cfg(windows)] { - let _ = std::process::Command::new("taskkill") + let status = std::process::Command::new("taskkill") .args(["/PID", &pid.to_string(), "/T", "/F"]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .status(); + .status() + .map_err(|error| format!("启动 taskkill 终止 Claude Code sidecar 失败:{error}"))?; + if !status.success() { + return Err(format!( + "终止 Claude Code sidecar 进程树失败:taskkill 返回 {status}" + )); + } } + Ok(()) } struct ActiveClaudeCodeTurnGuard { @@ -159,17 +187,34 @@ fn configure_sidecar_command( .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); - configure_claude_code_environment(command, llm, home); + configure_claude_code_environment(command, llm, home)?; configure_claude_code_process(command); Ok(()) } #[derive(Debug)] struct SidecarTurnResult { - events: Vec, result: serde_json::Value, } +/// 把 cc sidecar 的进程树退出证明写进当前回合账本。 +/// +/// 交付封口(`finish_sealing`)要求 `executor_stopped`,而这项证明过去只有 Codex app-server +/// 会写:cc 回合因此永远停在 sealing(`deliveryReviews` 烧到上限后落成 interrupted), +/// 下一轮的修改与付费调用再被 `direct-execution-interrupted` 拦住。这里让 cc 执行器按同一 +/// 口径上报"后台子树确实已退出",证明缺失时如实上报失败而不是缺席。 +fn record_claude_sidecar_exit_proof(root: &Path, proven: Option) { + let Some(proven) = proven else { + return; + }; + app_log!("agent.direct_codex.claude_sidecar_exit_proof proven={proven}"); + if let Ok(session) = super::direct_execution::current(root) { + if let Err(error) = session.record_process_exit_proof(proven) { + app_log!("agent.direct_codex.claude_sidecar_exit_proof_unrecorded detail={error}"); + } + } +} + /// sidecar 回合的失败分类:静默超时必须先杀进程树再收口,普通失败直接透传。 enum SidecarTurnFailure { Silent { silent_ms: u64, events: usize }, @@ -189,6 +234,7 @@ async fn run_sidecar_turn( request: serde_json::Value, timeout_ms: u64, active_turn: Option<&str>, + mut on_event: Option<&mut (dyn FnMut(&serde_json::Value) + Send)>, ) -> Result { let node = resolve_claude_node(root); let mut command = tokio::process::Command::new(node); @@ -199,6 +245,18 @@ async fn run_sidecar_turn( let pid = child .id() .ok_or_else(|| "Claude Agent SDK sidecar 进程缺少 PID".to_string())?; + // 与 Codex app-server 同一套 Job Object 归属:只有把 sidecar 及其子进程树纳入受控 + // 归属,回合结束时才拿得到"子树已退出"的可信证明,交付封口才有条件完成。 + let owned_tree = match super::codex_app_server::process_tree::OwnedProcessTree::attach_with_role( + &child, + "ClaudeCode", + ) { + Ok(tree) => Some(tree), + Err(error) => { + app_log!("agent.direct_codex.claude_sidecar_tree_unowned detail={error}"); + None + } + }; let active_key = active_turn.map(|_| claude_project_key(root)); let active_alive = Arc::new(AtomicBool::new(true)); if let (Some(key), Some(client_turn_id)) = (active_key.clone(), active_turn) { @@ -250,10 +308,11 @@ async fn run_sidecar_turn( let mut timeout_reason: Option = None; let turn = async { let mut lines = BufReader::new(stdout).lines(); - let mut events = Vec::new(); + let mut event_count = 0_usize; let mut terminal = None; let mut idle_rounds = 0_u64; let mut silent_ms = 0_u64; + let mut last_event_detail = None; loop { let line = match tokio::time::timeout(CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT, lines.next_line()) @@ -275,12 +334,12 @@ async fn run_sidecar_turn( app_log!( "agent.direct_codex.claude_sidecar_idle idleSeconds={} events={}", CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT.as_secs() * idle_rounds, - events.len() + event_count ); if silent_ms >= silence_budget_ms { return Err(SidecarTurnFailure::Silent { silent_ms, - events: events.len(), + events: event_count, }); } continue; @@ -289,15 +348,30 @@ async fn run_sidecar_turn( if line.trim().is_empty() { continue; } - let value: serde_json::Value = serde_json::from_str(&line).map_err(|_| { - SidecarTurnFailure::message( - "Claude Agent SDK sidecar 输出不是有效 JSON".to_string(), - ) + let value: serde_json::Value = serde_json::from_str(&line).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &line, + 480, + ); + SidecarTurnFailure::message(format!( + "Claude Agent SDK sidecar 输出不是有效 JSON:{error};原文={detail}" + )) })?; + last_event_detail = serde_json::to_string(&value).ok().map(|detail| { + crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ) + }); match value.get("type").and_then(serde_json::Value::as_str) { Some("event") => { if let Some(event) = value.get("event") { - events.push(event.clone()); + event_count = event_count.saturating_add(1); + if let Some(on_event) = on_event.as_deref_mut() { + on_event(event); + } } } Some("done") => { @@ -305,13 +379,18 @@ async fn run_sidecar_turn( break; } Some("error") => { - kill_claude_code_process_tree(pid); + if let Err(error) = kill_claude_code_process_tree(pid) { + app_log!("agent.direct_codex.claude_sidecar_kill_failed detail={error}"); + } + let detail = claude_result_error_detail(&value).or_else(|| { + last_event_detail + .clone() + .filter(|detail| !detail.trim().is_empty()) + }); return Err(SidecarTurnFailure::message( - value - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("Claude Agent SDK sidecar 执行失败") - .to_string(), + detail + .map(|detail| format!("Claude Agent SDK sidecar 执行失败:{detail}")) + .unwrap_or_else(|| "Claude Agent SDK sidecar 执行失败".to_string()), )); } _ => {} @@ -320,15 +399,34 @@ async fn run_sidecar_turn( let status = child.wait().await.map_err(|error| { SidecarTurnFailure::message(format!("等待 Claude Agent SDK sidecar 结束失败:{error}")) })?; + let exit_status = status + .code() + .map(|code| code.to_string()) + .unwrap_or_else(|| "signal".to_string()); let result = terminal.ok_or_else(|| { - SidecarTurnFailure::message("Claude Agent SDK sidecar 缺少终态".to_string()) + SidecarTurnFailure::message(format!( + "Claude Agent SDK sidecar 缺少终态;exitStatus={exit_status};已收到 {} 个事件;最后事件={}", + event_count, + last_event_detail + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or_else(|| "<无有效事件>".to_string()) + )) })?; if !status.success() { - return Err(SidecarTurnFailure::message( - "Claude Agent SDK sidecar 非零退出".to_string(), - )); + return Err(SidecarTurnFailure::message(format!( + "Claude Agent SDK sidecar 非零退出;exitStatus={exit_status}" + ))); } - Ok(SidecarTurnResult { events, result }) + let proven = match owned_tree.as_ref() { + Some(tree) => Some( + tree.shutdown(&mut child) + .await + .is_ok_and(|proof| proof.confirmed()), + ), + None => None, + }; + record_claude_sidecar_exit_proof(root, proven); + Ok(SidecarTurnResult { result }) }; let outcome = match tokio::time::timeout(CLAUDE_CODE_TURN_MAX_DURATION, turn).await { Ok(Ok(result)) => Ok(result), @@ -350,24 +448,69 @@ async fn run_sidecar_turn( let result = match outcome { Ok(result) => result, Err(message) => { - let Some(reason) = timeout_reason else { - return Err(message); + // 所有失败都走同一条收口:先杀进程树,再以有界窗口读取 stderr。之前只有 + // 静默超时读取 stderr,cc 的非零退出 / stdout JSON 错误 / RPC error 会丢掉 + // sidecar 给出的真正原因,最终只剩一条 transport 通用句。 + // 失败路径同样要收口受控子树并留下退出证明:证明确认时不必再叠加 taskkill, + // 证明缺失时回退到原有的进程树强杀,保证不留后台执行器。 + let proven = match owned_tree.as_ref() { + Some(tree) => Some( + tree.shutdown(&mut child) + .await + .is_ok_and(|proof| proof.confirmed()), + ), + None => None, + }; + record_claude_sidecar_exit_proof(root, proven); + let kill_detail = match proven { + Some(true) => None, + _ => kill_claude_code_process_tree(pid).err(), }; - // 先杀进程树:不杀的话 sidecar(连同它守护的 Claude Code CLI)会一直活着, - // stderr 等不到 EOF,这一轮就永远出不了终态。 - kill_claude_code_process_tree(pid); let stderr_detail = match tokio::time::timeout(CLAUDE_CODE_STDERR_DRAIN_TIMEOUT, stderr_task).await { - Ok(Ok(Ok(bytes))) => String::from_utf8(bytes).ok(), - _ => None, + Ok(Ok(Ok(bytes))) => match String::from_utf8(bytes) { + Ok(value) => Some(value), + Err(error) => Some(format!( + "读取 Claude Agent SDK sidecar stderr 失败:{};原始字节数={}", + error.utf8_error(), + error.as_bytes().len() + )), + }, + Ok(Ok(Err(error))) => Some(format!( + "读取 Claude Agent SDK sidecar stderr 失败:{error}" + )), + Ok(Err(error)) => Some(format!( + "等待 Claude Agent SDK sidecar stderr 读取任务失败:{error}" + )), + Err(_) => Some(format!( + "读取 Claude Agent SDK sidecar stderr 超时({} 秒)", + CLAUDE_CODE_STDERR_DRAIN_TIMEOUT.as_secs() + )), } .map(|value| value.trim().chars().take(512).collect::()) .filter(|value| !value.is_empty()) - // 断开 stdout 之后 sidecar 自己会抛 EPIPE;那是收尾噪声,不是失败原因。 - .filter(|value| !value.contains("EPIPE")); + // 断开 stdout 之后 sidecar 自己会抛 EPIPE;过滤该噪声行,但保留同一份 + // stderr 中其它真正的 provider / Node 错误。 + .map(|value| { + value + .lines() + .filter(|line| !line.contains("EPIPE")) + .collect::>() + .join(" ") + }) + .filter(|value| !value.trim().is_empty()); + let reason = timeout_reason.unwrap_or_else(|| { + if message.trim().is_empty() { + "Claude Agent SDK sidecar 执行失败".to_string() + } else { + message + } + }); let detail = stderr_detail + .into_iter() + .chain(kill_detail) .map(|value| format!(":{value}")) - .unwrap_or_default(); + .collect::(); return Err(format!("{reason}{detail}")); } }; @@ -395,7 +538,9 @@ pub(crate) fn cancel_direct_claude_code_turn_at( } } active.alive.store(false, Ordering::Release); - kill_claude_code_process_tree(active.pid); + if let Err(error) = kill_claude_code_process_tree(active.pid) { + return Err(error); + } let client_turn_id = active.client_turn_id.clone(); Ok(Some(super::codex_app_server::TurnCancelView { outcome: super::codex_app_server::DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(), @@ -410,7 +555,6 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result Result, isolated_home: &Path, -) { +) -> Result<(), String> { command.env_clear(); for name in [ "PATH", @@ -585,7 +741,8 @@ 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); + std::fs::create_dir_all(&isolated_claude_home) + .map_err(|error| format!("准备 Claude Code 隔离目录失败:{error}"))?; command .env("HOME", isolated_home) // Windows 上 Node 的 `os.homedir()` 只看 `USERPROFILE`;不隔离它,Claude Code 会去读 @@ -597,7 +754,13 @@ fn configure_claude_code_environment( .env("DISABLE_AUTOUPDATER", "1") .env("CI", "1") .env("NO_COLOR", "1") - .env("TERM", "dumb"); + .env("TERM", "dumb") + // 不设这一项,CC 的 MCP 工具调用会回落到 60 秒硬超时,把分钟级的付费生成/试玩 + // 直接取消掉,并让本轮租约未结算、后续工具全部被拒。 + .env( + "MCP_TOOL_TIMEOUT", + CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS.to_string(), + ); let session = crate::platform_session::current_platform_session(); let route = claude_code_route(llm, session.as_ref()); app_log!( @@ -633,25 +796,41 @@ fn configure_claude_code_environment( command.env("ANTHROPIC_MODEL", llm.model.trim()); } } + Ok(()) } fn claude_result_error_detail(value: &serde_json::Value) -> Option { + fn value_detail(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(detail) => { + let detail = detail.trim(); + (!detail.is_empty()).then(|| detail.chars().take(512).collect()) + } + serde_json::Value::Object(object) => { + let detail = ["message", "detail", "error", "type", "code"] + .into_iter() + .filter_map(|field| object.get(field).and_then(value_detail)) + .collect::>() + .join(";"); + (!detail.is_empty()).then_some(detail) + } + _ => None, + } + } let candidates = [ - value.get("result").and_then(serde_json::Value::as_str), - value.get("error").and_then(serde_json::Value::as_str), - value.get("message").and_then(serde_json::Value::as_str), + value.get("result").and_then(value_detail), + value.get("error").and_then(value_detail), + value.get("message").and_then(value_detail), value .get("errors") .and_then(serde_json::Value::as_array) .and_then(|errors| errors.first()) - .and_then(serde_json::Value::as_str), + .and_then(value_detail), ]; candidates .into_iter() .flatten() - .map(str::trim) .find(|detail| !detail.is_empty()) - .map(|detail| detail.chars().take(512).collect()) } fn configure_claude_code_process(command: &mut tokio::process::Command) { @@ -758,6 +937,15 @@ fn parse_usage(value: &serde_json::Value) -> Option /// 的上游事实显示成“执行通道已断开”。这里只认结构化的状态位置(`Request rejected (N)` / /// `HTTP N`),其它文本继续保留为 Transport,避免凭关键词猜测。 pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::LlmError { + let normalized = detail.to_ascii_lowercase(); + if normalized.contains("maximum number of turns") + || normalized.contains("max turns") + || normalized.contains("最大回合数") + { + return platform_llm::LlmError::InvalidRequest(format!( + "codex-app-server-error:session-budget-exceeded detail={detail}" + )); + } if let Some(status_code) = claude_code_failure_status_code(&detail) { return platform_llm::LlmError::Upstream { status_code, @@ -777,9 +965,43 @@ pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm:: platform_llm::LlmError::Transport(detail) } +/// 把 cc 的失败投影到 Direct 回合错误;平台 `LlmError::Timeout` / `EmptyResponse` 本身没有 +/// detail 字段,因此这两类必须在这里把 sidecar 的原始原因留在统一 `ModelCallFailed.detail` 中, +/// 否则 stderr、超时阶段和缺失字段会在类型转换时丢掉,前端又只能显示固定兜底句。 +pub(crate) fn claude_code_failure_to_turn_error( + detail: String, +) -> super::codex_app_server::turn_error::TurnError { + use super::codex_app_server::turn_error::{ModelCallFailed, ModelCallKind, TurnError}; + + let llm_error = claude_code_failure_to_llm_error(detail.clone()); + match llm_error { + platform_llm::LlmError::Timeout { attempts } => { + TurnError::ModelCallFailed(ModelCallFailed { + kind: ModelCallKind::ResponseTimedOut { attempts }, + detail, + }) + } + platform_llm::LlmError::EmptyResponse => TurnError::ModelCallFailed(ModelCallFailed { + kind: ModelCallKind::EmptyResponse, + detail, + }), + other => TurnError::from_model_call(&other), + } +} + fn claude_code_failure_status_code(detail: &str) -> Option { - ["Request rejected (", "HTTP "].iter().find_map(|marker| { - let tail = detail.split_once(marker)?.1; + let normalized = detail.to_ascii_lowercase(); + [ + "request rejected (", + "http ", + "status ", + "status=", + "status_code=", + "statuscode=", + ] + .iter() + .find_map(|marker| { + let tail = normalized.split_once(marker)?.1; let digits = tail .chars() .take_while(|character| character.is_ascii_digit()) @@ -796,8 +1018,15 @@ fn parse_claude_code_result( stdout: &[u8], request: &LlmRunRequest, ) -> Result { - let value: serde_json::Value = serde_json::from_slice(stdout).map_err(|_| { - platform_llm::LlmError::Deserialize("Claude Code JSON 输出无效".to_string()) + let value: serde_json::Value = serde_json::from_slice(stdout).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &String::from_utf8_lossy(stdout), + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Claude Code JSON 输出无效:{error};原文={detail}" + )) })?; if value.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { let detail = claude_result_error_detail(&value) @@ -824,20 +1053,33 @@ fn parse_claude_code_result( } (result, Vec::new()) } else { - let envelope = structured - .or_else(|| serde_json::from_str(&result).ok()) - .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code structured output 缺失".to_string(), - ) - })?; + let envelope = match structured { + Some(value) => value, + None => serde_json::from_str(&result).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &result, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Claude Code structured output JSON 无效:{error};原文={detail}" + )) + })?, + }; let calls = envelope .get("toolCalls") .and_then(serde_json::Value::as_array) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code structured output 缺少 toolCalls".to_string(), - ) + let detail = + serde_json::to_string(&envelope).unwrap_or_else(|_| "<不可序列化>".into()); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Claude Code structured output 缺少 toolCalls;原文={detail}" + )) })?; let calls = calls .iter() @@ -847,17 +1089,20 @@ fn parse_claude_code_result( .get("name") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code tool call 缺少 name".to_string(), - ) + platform_llm::LlmError::Deserialize(format!( + "Claude Code tool call 第 {} 项缺少 name;原文={}", + index.saturating_add(1), + serde_json::to_string(call).unwrap_or_else(|_| "<不可序列化>".into()) + )) })?; let arguments = call .get("arguments") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code tool call 缺少 arguments".to_string(), - ) + platform_llm::LlmError::Deserialize(format!( + "Claude Code tool call 第 {} 项缺少 arguments;name={name}", + index.saturating_add(1) + )) })?; if !request.function_tools.iter().any(|tool| tool.name == name) { return Err(platform_llm::LlmError::Deserialize( @@ -928,6 +1173,7 @@ pub(in crate::agent) async fn request_game_creator_agent_claude_code_cli( .request_timeout_ms .unwrap_or(config.llm.request_timeout_ms), None, + None, ) .await .map_err(platform_llm::LlmError::Transport)?; @@ -955,16 +1201,655 @@ pub(crate) async fn direct_game_creator_claude_code_home_chat( .map_err(|error| error.to_string()) } +#[derive(Debug)] +struct ClaudeCodeToolState { + name: String, + arguments: String, + started: bool, + completed: bool, +} + +#[derive(Default, Debug)] +struct ClaudeCodeStreamState { + current_message_id: String, + /// 内容块 → 过程条目 id。 + /// + /// 键必须带内容块类型:Claude Code 会把同一条 message 的内容块拆成多条 `assistant` + /// 事件(每条只含一个块),这些事件在各自数组里的下标都是 0。只按 `(message_id, index)` + /// 记忆,会让同一条消息里的 thinking 与 text 抢同一个 id(现场:`…:thinking:0` 先落盘, + /// 随后 text 复用该 id 且内容不同,历史层判冲突并让整轮变成“过程历史保存失败”)。 + block_item_ids: HashMap<(String, String, usize), String>, + block_tool_ids: HashMap<(String, usize), String>, + tool_calls: HashMap, + history_error: Option, + partial_history: DirectProjectHistoryAccumulator, + assistant_texts: Vec, + result_text: Option, + result_error: Option, +} + +fn claude_value_text(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => String::new(), + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Array(parts) => parts + .iter() + .map(claude_value_text) + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"), + serde_json::Value::Object(object) => object + .get("text") + .or_else(|| object.get("thinking")) + .or_else(|| object.get("content")) + .map(claude_value_text) + .filter(|part| !part.is_empty()) + .unwrap_or_else(|| serde_json::to_string_pretty(value).unwrap_or_default()), + other => serde_json::to_string_pretty(other).unwrap_or_default(), + } +} + +fn claude_content_text(block: &serde_json::Value) -> String { + block + .get("text") + .or_else(|| block.get("thinking")) + .or_else(|| block.get("content")) + .map(claude_value_text) + .unwrap_or_default() +} + +fn claude_tool_arguments(value: Option<&serde_json::Value>) -> String { + let Some(value) = value else { + return String::new(); + }; + match value { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Null => String::new(), + other => serde_json::to_string(other).unwrap_or_default(), + } +} + +fn claude_stream_observe( + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + observation: TurnObservation, +) { + if let Some(observer) = observer.as_deref_mut() { + observer(observation); + } +} + +/// 过程条目 id 撞车时的兜底条目:保留原内容,只把 id 换成带唯一后缀的取值。 +/// +/// 历史层只接受“同 id 同内容”的重复写;同 id 不同内容一律判冲突。Claude Code 会把同一条 +/// message 的内容块拆成多条 `assistant` 事件,因此仍有无法用 `(message_id, kind, index)` +/// 区分的情况(例如同一条消息里的两个 text 块)。撞车时宁可多留一条过程历史,也不能让已经 +/// 跑完的整轮被判成失败。缺失 id 时给一个自说明的兜底 id,保证追加一定能拿到唯一键。 +fn completed_item_conflict_fallback(item: &serde_json::Value) -> serde_json::Value { + let mut retried = item.clone(); + let fallback_id = match item.get("id").and_then(serde_json::Value::as_str) { + Some(id) if !id.trim().is_empty() => format!("{id}:{}", uuid::Uuid::new_v4()), + _ => format!("direct-cc:item:{}", uuid::Uuid::new_v4()), + }; + if let Some(object) = retried.as_object_mut() { + object.insert("id".to_string(), serde_json::Value::String(fallback_id)); + } + retried +} + +impl ClaudeCodeStreamState { + /// 已完成过程与 Codex 共用项目历史出口,收到时即保存;终态失败不能撤掉前面已发生的事实。 + fn persist_completed_item(&mut self, root: &Path, item: &serde_json::Value) { + match append_direct_project_history_item_at(root, item) { + Ok(()) => {} + Err(error) if error.starts_with("DirectProject 历史 item id 冲突:") => { + // 同一个 message id 的内容块会在多条 `assistant` 事件里重复出现,条目 id 一旦 + // 与已有条目“同 id 不同内容”,历史层会判冲突。这里按收尾回复的既有做法补一个 + // 唯一后缀:把 id 撞车降级成多留一条过程历史,而不是让整轮变成过程历史保存失败。 + let retried = completed_item_conflict_fallback(item); + if let Err(retry_error) = append_direct_project_history_item_at(root, &retried) { + self.history_error.get_or_insert(format!( + "过程历史追加失败:{retry_error}(首个条目冲突:{error})" + )); + } + } + Err(error) => { + self.history_error.get_or_insert(error); + } + } + } + + fn persist_partial_history(&mut self, root: &Path) { + if let Err(error) = persist_direct_project_partial_items_at(root, &mut self.partial_history) + { + self.history_error.get_or_insert(error); + } + } + + fn block_item_id(&mut self, message_id: &str, index: usize, kind: &str) -> String { + self.block_item_ids + .entry((message_id.to_string(), kind.to_string(), index)) + .or_insert_with(|| format!("direct-cc:{message_id}:{kind}:{index}")) + .clone() + } + + fn append_delta( + &mut self, + root: &Path, + item_id: String, + kind: ThreadDeltaKind, + delta: String, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + if delta.is_empty() { + return; + } + self.partial_history.observe_delta(&item_id, &delta); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_delta(item_id, kind, delta.clone()), + ); + match kind { + ThreadDeltaKind::Message => { + claude_stream_observe(observer, TurnObservation::AccumulatedText(delta)); + } + ThreadDeltaKind::Reasoning => { + claude_stream_observe(observer, TurnObservation::Reasoning(delta)); + } + } + } + + fn start_tool( + &mut self, + root: &Path, + tool_id: String, + name: String, + arguments: String, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let arguments = if arguments.trim().is_empty() || arguments.trim() == "{}" { + String::new() + } else { + arguments + }; + let entry = self + .tool_calls + .entry(tool_id.clone()) + .or_insert_with(|| ClaudeCodeToolState { + name: name.clone(), + arguments: String::new(), + started: false, + completed: false, + }); + if !name.trim().is_empty() { + entry.name = name; + } + let needs_update = !arguments.is_empty() && entry.arguments != arguments; + if !arguments.is_empty() { + entry.arguments = arguments; + } + if entry.completed || (entry.started && !needs_update) { + return; + } + entry.started = true; + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_started( + ThreadItem::McpToolCall { + item_id: tool_id, + tool: entry.name.clone(), + arguments: entry.arguments.clone(), + output: None, + status: Some("inProgress".to_string()), + at, + }, + at, + ), + ); + claude_stream_observe(observer, TurnObservation::Activity("controlled-tool")); + } + + fn complete_tool( + &mut self, + root: &Path, + tool_id: String, + output: String, + failed: bool, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let state = self + .tool_calls + .entry(tool_id.clone()) + .or_insert_with(|| ClaudeCodeToolState { + name: "MCP 工具".to_string(), + arguments: "{}".to_string(), + started: false, + completed: false, + }); + if state.completed { + return; + } + state.completed = true; + let status = if failed { "failed" } else { "completed" }; + let at = crate::agent::now_ms(); + let item = ThreadItem::McpToolCall { + item_id: tool_id.clone(), + tool: state.name.clone(), + arguments: state.arguments.clone(), + output: Some(output.clone()), + status: Some(status.to_string()), + at, + }; + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed(item, at), + ); + let history_item = serde_json::json!({ + "type": "mcpToolCall", + "id": tool_id, + "tool": state.name.clone(), + "arguments": state.arguments.clone(), + "result": output, + "status": status, + }); + self.persist_completed_item(root, &history_item); + claude_stream_observe(observer, TurnObservation::Activity("controlled-tool")); + } + + fn handle_stream_event( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let Some(raw_event) = event.get("event") else { + return; + }; + match raw_event.get("type").and_then(serde_json::Value::as_str) { + Some("message_start") => { + self.current_message_id = raw_event + .pointer("/message/id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + } + Some("content_block_start") => { + let index = raw_event + .get("index") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() as usize; + let Some(block) = raw_event.get("content_block") else { + return; + }; + let kind = block + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or("block"); + if kind == "tool_use" { + let tool_id = block + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + if tool_id.is_empty() { + return; + } + self.block_tool_ids + .insert((self.current_message_id.clone(), index), tool_id.clone()); + self.start_tool( + root, + tool_id, + block + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or("MCP 工具") + .to_string(), + claude_tool_arguments(block.get("input")), + observer, + ); + } else if !self.current_message_id.is_empty() { + self.block_item_id(&self.current_message_id.clone(), index, kind); + } + } + Some("content_block_delta") => { + let index = raw_event + .get("index") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() as usize; + let Some(delta) = raw_event.get("delta") else { + return; + }; + match delta.get("type").and_then(serde_json::Value::as_str) { + Some("text_delta") => { + let item_id = + self.block_item_id(&self.current_message_id.clone(), index, "text"); + self.append_delta( + root, + item_id, + ThreadDeltaKind::Message, + delta + .get("text") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + observer, + ); + } + Some("thinking_delta") => { + let item_id = + self.block_item_id(&self.current_message_id.clone(), index, "thinking"); + self.append_delta( + root, + item_id, + ThreadDeltaKind::Reasoning, + delta + .get("thinking") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + observer, + ); + } + Some("input_json_delta") => { + let Some(tool_id) = self + .block_tool_ids + .get(&(self.current_message_id.clone(), index)) + .cloned() + else { + return; + }; + if let Some(partial) = delta + .get("partial_json") + .and_then(serde_json::Value::as_str) + { + if let Some(tool) = self.tool_calls.get_mut(&tool_id) { + if tool.arguments == "{}" { + tool.arguments.clear(); + } + tool.arguments.push_str(partial); + } + } + } + _ => {} + } + } + _ => {} + } + } + + fn handle_assistant( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let message_id = event + .pointer("/message/id") + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| { + event + .get("uuid") + .and_then(serde_json::Value::as_str) + .unwrap_or("cc-message") + }); + let Some(content) = event + .pointer("/message/content") + .and_then(serde_json::Value::as_array) + else { + return; + }; + for (index, block) in content.iter().enumerate() { + match block.get("type").and_then(serde_json::Value::as_str) { + Some("text") => { + let text = claude_content_text(block); + if text.trim().is_empty() { + continue; + } + let item_id = self.block_item_id(message_id, index, "text"); + self.partial_history + .complete_item(&serde_json::json!({"id": item_id.clone()})); + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Message { + item_id: item_id.clone(), + role: "assistant".to_string(), + text: text.clone(), + at, + }, + at, + ), + ); + self.persist_completed_item( + root, + &serde_json::json!({ + "type": "message", + "role": "assistant", + "id": item_id, + "content": [{"type": "output_text", "text": text}], + }), + ); + self.assistant_texts.push(text); + claude_stream_observe( + observer, + TurnObservation::AccumulatedText( + self.assistant_texts.last().cloned().unwrap_or_default(), + ), + ); + } + Some("thinking") => { + let text = claude_content_text(block); + if text.trim().is_empty() { + continue; + } + let item_id = self.block_item_id(message_id, index, "thinking"); + self.partial_history + .complete_item(&serde_json::json!({"id": item_id})); + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Reasoning { + item_id: item_id.clone(), + text: text.clone(), + at, + }, + at, + ), + ); + self.persist_completed_item( + root, + &serde_json::json!({ + "type": "reasoning", + "id": item_id, + "text": text, + }), + ); + claude_stream_observe(observer, TurnObservation::Reasoning(String::new())); + } + Some("tool_use") => { + let Some(tool_id) = block.get("id").and_then(serde_json::Value::as_str) else { + continue; + }; + self.start_tool( + root, + tool_id.to_string(), + block + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or("MCP 工具") + .to_string(), + claude_tool_arguments(block.get("input")), + observer, + ); + } + _ => {} + } + } + } + + fn handle_user( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let Some(content) = event + .pointer("/message/content") + .and_then(serde_json::Value::as_array) + else { + return; + }; + for block in content { + if block.get("type").and_then(serde_json::Value::as_str) != Some("tool_result") { + continue; + } + let Some(tool_id) = block + .get("tool_use_id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.trim().is_empty()) + else { + continue; + }; + self.complete_tool( + root, + tool_id.to_string(), + claude_content_text(block), + block + .get("is_error") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + observer, + ); + } + } + + fn observe( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + match event.get("type").and_then(serde_json::Value::as_str) { + Some("stream_event") => self.handle_stream_event(root, event, observer), + Some("assistant") => self.handle_assistant(root, event, observer), + Some("user") => self.handle_user(root, event, observer), + Some("tool_progress") => { + claude_stream_observe(observer, TurnObservation::Activity("controlled-tool")); + } + Some("system") => match event.get("subtype").and_then(serde_json::Value::as_str) { + Some("compact_boundary") => { + claude_stream_observe( + observer, + TurnObservation::Activity("context-compaction"), + ); + } + Some("permission_denied") => { + if let Some(tool_id) = event + .get("tool_use_id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.trim().is_empty()) + { + self.complete_tool( + root, + tool_id.to_string(), + event + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("工具调用被拒绝") + .to_string(), + true, + observer, + ); + } + } + Some("thinking_tokens") => { + claude_stream_observe(observer, TurnObservation::Reasoning(String::new())); + } + _ => {} + }, + Some("result") => { + if event.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { + self.result_error = claude_result_error_detail(event); + } else { + self.result_text = event + .get("result") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + } + } + _ => {} + } + } + + fn finish( + mut self, + root: &Path, + terminal: &serde_json::Value, + client_turn_id: Option<&str>, + ) -> Result { + if self.result_error.is_none() + && terminal + .get("is_error") + .and_then(serde_json::Value::as_bool) + == Some(true) + { + self.result_error = claude_result_error_detail(terminal); + } + if let Some(detail) = self.result_error.clone() { + self.persist_partial_history(root); + return Err(self.with_history_error(format!("Claude Code 返回失败终态:{detail}"))); + } + if let Some(error) = &self.history_error { + return Err(format!("Claude Code 过程历史保存失败:{error}")); + } + let text = self + .result_text + .or_else(|| { + terminal + .get("result") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Claude Code 缺少最终回复".to_string())?; + if self.assistant_texts.last().map(String::as_str) != Some(text.as_str()) { + let item_id = persist_direct_claude_assistant_reply_at(root, client_turn_id, &text)?; + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Message { + item_id, + role: "assistant".to_string(), + text: text.clone(), + at, + }, + at, + ), + ); + } + Ok(text) + } + + fn with_history_error(&self, original: String) -> String { + match &self.history_error { + Some(error) => format!("{original};Claude Code 过程历史保存失败:{error}"), + None => original, + } + } +} + /// DirectProject 使用 Claude Code 自己的 MCP 调用能力;宿主只暴露 loopback -/// MCP,关闭所有内置工具。它先作为最小可用执行器接入,后续可在同一观察协议上补充 -/// 更细的 Claude tool_use 活动投影。 +/// MCP,关闭所有内置工具。CC 的 SDK 事件在读取时投影到与 Codex 相同的 Thread Manager 合同。 pub(crate) async fn direct_game_creator_claude_code_chat_at( root: &Path, llm: &GameCreatorLlmConfig, system_prompt: String, user_prompt: String, client_turn_id: Option<&str>, - observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, + mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, ) -> Result { direct_turn_trace("claude-executor-enter"); let (mcp_url, mcp_token) = @@ -980,8 +1865,9 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( let session_key = claude_project_key(root); let resume = claude_direct_sessions() .lock() - .ok() - .and_then(|sessions| sessions.get(&session_key).cloned()); + .map_err(|_| "Claude Code Direct 会话表不可用,无法恢复上一次会话".to_string())? + .get(&session_key) + .cloned(); direct_turn_trace("claude-sidecar-start"); let payload = serde_json::json!({ "type": "turn", @@ -992,7 +1878,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( "systemPrompt": system_prompt, "cwd": root, "model": llm.model.trim(), - "maxTurns": 8, + "maxTurns": CLAUDE_DIRECT_MAX_TURNS, "mcpServers": { "agc": { "type": "http", @@ -1001,15 +1887,27 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( } } }); - let sidecar = run_sidecar_turn( + let mut stream_state = ClaudeCodeStreamState::default(); + let mut on_event = |event: &serde_json::Value| { + stream_state.observe(root, event, &mut observer); + }; + let sidecar_result = run_sidecar_turn( root, Some(llm), &home, payload, llm.request_timeout_ms, client_turn_id, + Some(&mut on_event), ) - .await?; + .await; + let sidecar = match sidecar_result { + Ok(sidecar) => sidecar, + Err(error) => { + stream_state.persist_partial_history(root); + return Err(stream_state.with_history_error(error)); + } + }; direct_turn_trace("claude-sidecar-done"); if let Some(session_id) = sidecar .result @@ -1020,51 +1918,17 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( sessions.insert(session_key, session_id.to_string()); } } - let mut stream = sidecar - .events - .into_iter() - .chain(std::iter::once(sidecar.result)) - .filter_map(|event| serde_json::to_string(&event).ok()) - .collect::>(); - stream.push(String::new()); direct_turn_trace("claude-parse-start"); - let parsed = parse_direct_stream_result(stream.join("\n").as_bytes(), observer); + let parsed = stream_state.finish(root, &sidecar.result, client_turn_id); match &parsed { - Ok(text) => { - direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())); - // 项目对话历史是这条对话的单一事实源:回复不落盘,UI 就看不到本轮结果。codex 路径由 - // app-server 的 collect-history 负责写 assistant 条目,cc 没有那一步——只补终态会让 - // 用户看到"回合结束但没有回复"。落盘失败按回合失败收口,不吞。 - let item_id = match persist_direct_claude_assistant_reply_at(root, client_turn_id, text) - { - Ok(item_id) => item_id, - Err(error) => { - eprintln!("[agc-cc-direct] persist assistant failed: {error}"); - direct_turn_trace("claude-parse-error"); - return Err(format!("写入本项目对话历史失败:{error}")); - } - }; - // 聊天区是按 `item.completed` 事件流投影的(codex 路径在 rawResponseItem/completed - // 时下发同款事件),只落盘历史不会让本轮回复出现在界面上——重进项目才看得到。 - // 条目身份与落盘的历史条目保持同一个,重进项目按 id 去重。 - let at = crate::agent::now_ms(); - crate::agent::append_thread_event( - &crate::agent::thread_id_for_project(root), - ThreadEvent::item_completed( - ThreadItem::Message { - item_id, - role: "assistant".to_string(), - text: text.clone(), - at, - }, - at, - ), - ); - } + Ok(text) => direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())), Err(error) => { - // 这一段必须走 stderr:AppData 日志的脱敏会把含 turnToken= 的整行替换掉, - // 收尾失败时看不到任何原因(真实案例:2026-10-02 18:19 模型已回复但回合被丢弃)。 - eprintln!("[agc-cc-direct] parse failed: {error}"); + // 统一进入应用日志的精确脱敏出口;不再把原始 parse detail 直接写 stderr,避免 + // 凭据/路径随 cc 收尾错误绕过 Runtime 诊断合同。 + app_log!( + "agent.direct_codex.claude_parse_failed detail={}", + crate::agent::redact_agent_runtime_error(root, &error, 600) + ); direct_turn_trace("claude-parse-error"); } } @@ -1103,58 +1967,6 @@ fn persist_direct_claude_assistant_reply_at( } } -fn parse_direct_stream_result( - stdout: &[u8], - mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, -) -> Result { - let text = std::str::from_utf8(stdout) - .map_err(|_| "Claude Agent SDK stream-json 不是 UTF-8".to_string())?; - let mut result = None; - for line in text.lines().filter(|line| !line.trim().is_empty()) { - let event: serde_json::Value = serde_json::from_str(line) - .map_err(|_| "Claude Agent SDK stream-json 包含无效 JSON".to_string())?; - let event_type = event.get("type").and_then(serde_json::Value::as_str); - if event_type == Some("assistant") { - let content = event - .pointer("/message/content") - .and_then(serde_json::Value::as_array); - if let Some(content) = content { - let visible = content - .iter() - .filter_map(|part| { - (part.get("type").and_then(serde_json::Value::as_str) == Some("text")).then( - || { - part.get("text") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - }, - ) - }) - .collect::(); - if !visible.is_empty() { - if let Some(observer) = observer.as_deref_mut() { - observer(TurnObservation::AgentMessageSegment(visible)); - } - } - } - } else if event_type == Some("result") { - if event.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { - let detail = claude_result_error_detail(&event) - .map(|detail| format!(":{detail}")) - .unwrap_or_default(); - return Err(format!("Claude Code 返回失败终态{detail}")); - } - result = event - .get("result") - .and_then(serde_json::Value::as_str) - .map(str::to_string); - } - } - result - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| "Claude Code 缺少最终回复".to_string()) -} - #[cfg(test)] mod tests { use super::*; @@ -1173,6 +1985,54 @@ mod tests { request } + #[test] + fn claude_block_item_id_keeps_thinking_and_text_apart() { + let mut state = ClaudeCodeStreamState::default(); + let thinking = state.block_item_id("msg_1", 0, "thinking"); + let text = state.block_item_id("msg_1", 0, "text"); + + assert_eq!(thinking, "direct-cc:msg_1:thinking:0"); + assert_eq!(text, "direct-cc:msg_1:text:0"); + assert_ne!(thinking, text); + // 同一个块重复出现(Claude Code 按块分事件重发同一 message)必须复用同一个 id。 + assert_eq!(state.block_item_id("msg_1", 0, "thinking"), thinking); + assert_eq!(state.block_item_id("msg_1", 0, "text"), text); + } + + #[test] + fn claude_completed_item_conflict_fallback_keeps_content_and_unique_id() { + let item = serde_json::json!({ + "type": "reasoning", + "id": "direct-cc:msg_1:thinking:0", + "text": "已经落盘过的思考内容", + }); + let fallback = completed_item_conflict_fallback(&item); + + assert_eq!(fallback["type"], item["type"]); + assert_eq!(fallback["text"], item["text"]); + let fallback_id = fallback["id"].as_str().expect("fallback id"); + assert_ne!(fallback_id, "direct-cc:msg_1:thinking:0"); + assert!( + fallback_id.starts_with("direct-cc:msg_1:thinking:0:"), + "兜底 id 必须保留原 id 便于排查:{fallback_id}" + ); + assert_ne!( + fallback["id"], + completed_item_conflict_fallback(&item)["id"], + "兜底 id 每次都必须是新的唯一取值" + ); + + let without_id = serde_json::json!({ "type": "reasoning", "text": "无 id" }); + let fallback_without_id = completed_item_conflict_fallback(&without_id); + assert!( + fallback_without_id["id"] + .as_str() + .expect("fallback id") + .starts_with("direct-cc:item:"), + "缺少 id 时也要给出自说明的兜底 id" + ); + } + #[test] fn parses_claude_code_text_result() { let response = parse_claude_code_result( @@ -1207,6 +2067,63 @@ mod tests { assert!(error.to_string().contains("Authentication failed")); } + #[test] + fn preserves_claude_invalid_json_and_object_error_details() { + let invalid = parse_claude_code_result( + br#"{"is_error":false,"result":"unterminated""#, + &request(false), + ) + .expect_err("invalid JSON must be rejected"); + let invalid = invalid.to_string(); + assert!(invalid.contains("Claude Code JSON 输出无效"), "{invalid}"); + assert!(invalid.contains("line"), "{invalid}"); + assert!(invalid.contains("unterminated"), "{invalid}"); + + let object_error = parse_claude_code_result( + br#"{"is_error":true,"error":{"type":"invalid_request_error","message":"HTTP 422: bad schema"}}"#, + &request(false), + ) + .expect_err("object error must be rejected"); + assert!( + object_error.to_string().contains("HTTP 422: bad schema"), + "{object_error}" + ); + } + + #[test] + fn cc_failure_projection_preserves_timeout_and_empty_response_detail() { + let timeout = super::claude_code_failure_to_turn_error( + "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件;stderr=OOM".into(), + ); + match timeout { + super::codex_app_server::turn_error::TurnError::ModelCallFailed(payload) => { + assert!(matches!( + payload.kind, + super::codex_app_server::turn_error::ModelCallKind::ResponseTimedOut { + attempts: 1 + } + )); + assert!(payload.detail.contains("180000")); + assert!(payload.detail.contains("OOM")); + } + other => panic!("expected timed out model failure, got {other:?}"), + } + + let empty = super::claude_code_failure_to_turn_error( + "Claude Code 缺少最终回复;sidecar result 缺少 result 字段".into(), + ); + match empty { + super::codex_app_server::turn_error::TurnError::ModelCallFailed(payload) => { + assert!(matches!( + payload.kind, + super::codex_app_server::turn_error::ModelCallKind::EmptyResponse + )); + assert!(payload.detail.contains("缺少 result 字段")); + } + other => panic!("expected empty response model failure, got {other:?}"), + } + } + #[test] fn claude_failure_reuses_codex_error_categories() { assert!(matches!( @@ -1228,36 +2145,202 @@ mod tests { claude_code_failure_to_llm_error("Claude Code 缺少最终回复".into()), platform_llm::LlmError::EmptyResponse )); - } - - #[test] - fn parses_direct_stream_result_and_final_text() { - let mut observed = Vec::new(); - let text = parse_direct_stream_result( - r#"{"type":"system","subtype":"init","session_id":"s"} -{"type":"assistant","uuid":"a","message":{"content":[{"type":"text","text":"中间"}]}} -{"type":"result","is_error":false,"result":"最终回复"} -"# - .as_bytes(), - Some(&mut |event| observed.push(event)), - ) - .expect("stream result"); - assert_eq!(text, "最终回复"); assert!(matches!( - observed.as_slice(), - [TurnObservation::AgentMessageSegment(_)] + claude_code_failure_to_llm_error( + "Claude Code 返回失败终态:HTTP 409 session already active".into() + ), + platform_llm::LlmError::Upstream { + status_code: 409, + .. + } + )); + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Agent SDK 返回错误:status_code=401 invalid token".into() + ), + platform_llm::LlmError::Upstream { + status_code: 401, + .. + } + )); + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Code 返回失败终态:Reached maximum number of turns (8)".into() + ), + platform_llm::LlmError::InvalidRequest(message) + if message.contains("codex-app-server-error:session-budget-exceeded") )); } #[test] - fn preserves_direct_stream_terminal_error_detail() { - let error = parse_direct_stream_result( - br#"{"type":"result","is_error":true,"result":"Authentication failed"} -"#, - None, - ) - .expect_err("failed terminal must be rejected"); - assert!(error.contains("Authentication failed")); + fn projects_claude_stream_text_and_mcp_tool_lifecycle_into_thread_events() { + let root = tempfile::tempdir().expect("temp root"); + crate::init_local_game_project_at(root.path(), "cc-stream", "CC 流式投影") + .expect("init project"); + let thread_id = crate::agent::thread_id_for_project(root.path()); + crate::agent::append_thread_event( + &thread_id, + ThreadEvent::turn_started(crate::agent::now_ms()), + ); + let subscription = crate::agent::thread_manager::subscribe_thread(&thread_id); + let mut observed = Vec::new(); + let mut observer_fn = |event| observed.push(event); + let mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)> = Some(&mut observer_fn); + let mut state = ClaudeCodeStreamState::default(); + + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "message_start", "message": {"id": "message-1"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "正在处理"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "assistant", + "message": {"id": "message-1", "content": [{"type": "text", "text": "正在处理"}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "assistant", + "message": {"id": "message-2", "content": [{"type": "tool_use", "id": "tool-1", "name": "mcp__agc__agc_read_project_context", "input": {"path": "game/index.html"}}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "user", + "message": {"content": [{"type": "tool_result", "tool_use_id": "tool-1", "content": [{"type": "text", "text": "读取成功"}]}]} + }), + &mut observer, + ); + + let events = crate::agent::thread_manager::consume_thread(&subscription.subscription_id) + .expect("consume projected events") + .events; + assert!(events.iter().any(|event| matches!( + event, + ThreadEvent::ItemDelta { kind: ThreadDeltaKind::Message, delta, .. } + if delta == "正在处理" + ))); + assert!(events.iter().any(|event| matches!( + event, + ThreadEvent::ItemStarted { item: ThreadItem::McpToolCall { item_id, .. }, .. } + if item_id == "tool-1" + ))); + assert!(events.iter().any(|event| matches!( + event, + ThreadEvent::ItemCompleted { item: ThreadItem::McpToolCall { item_id, output, status, .. }, .. } + if item_id == "tool-1" + && output.as_deref() == Some("读取成功") + && status.as_deref() == Some("completed") + ))); + assert!(observed.iter().any(|event| matches!( + event, + TurnObservation::AccumulatedText(text) if text == "正在处理" + ))); + assert!(observed + .iter() + .any(|event| matches!(event, TurnObservation::Activity("controlled-tool")))); + let history = read_direct_project_history_items_at(root.path()) + .expect("read process history before result"); + assert_eq!(history.len(), 2); + } + + #[test] + fn failed_claude_turn_keeps_completed_and_partial_process_history() { + let root = tempfile::tempdir().expect("temp root"); + crate::init_local_game_project_at(root.path(), "cc-failure-history", "CC 失败历史") + .expect("init project"); + let mut state = ClaudeCodeStreamState::default(); + let mut observer_fn = |_event| {}; + let mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)> = Some(&mut observer_fn); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "message_start", "message": {"id": "partial-message"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "已经读取项目"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "assistant", + "message": {"id": "tool-message", "content": [{"type": "tool_use", "id": "tool-without-args", "name": "mcp__agc__agc_read_project_context"}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "user", + "message": {"content": [{"type": "tool_result", "tool_use_id": "tool-without-args", "content": [{"type": "text", "text": "读取完成"}]}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({"type": "result", "is_error": true, "error": "上游 429"}), + &mut observer, + ); + + let failure = state + .finish( + root.path(), + &serde_json::json!({"is_error": true}), + Some("cc-failure-turn"), + ) + .expect_err("the terminal result is a failure"); + assert!(failure.contains("Claude Code 返回失败终态")); + let history = read_direct_project_history_items_at(root.path()).expect("read history"); + assert_eq!(history.len(), 2); + assert!(history.iter().any(|item| { + item.get("type").and_then(serde_json::Value::as_str) == Some("message") + && item.to_string().contains("已经读取项目") + })); + assert!(history.iter().any(|item| { + item.get("type").and_then(serde_json::Value::as_str) == Some("mcpToolCall") + && item.to_string().contains("读取完成") + })); } #[test] @@ -1377,7 +2460,7 @@ mod tests { 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); + configure_claude_code_environment(&mut command, None, &home).unwrap(); let envs = command .as_std() .get_envs() @@ -1404,5 +2487,11 @@ mod tests { ] { assert!(!envs.contains_key(name), "{name} 不应下发到 sidecar"); } + // MCP 工具调用必须显式抬高超时:默认 60 秒会把分钟级的付费生成/试玩取消掉, + // 让本轮租约未结算并拒绝后续工具调用。 + assert_eq!( + envs.get("MCP_TOOL_TIMEOUT"), + Some(&Some(CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS.to_string())) + ); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 118184ae4..6bea3606d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -367,7 +367,7 @@ impl ExecutionAdapter { Ok::<_, String>((session.root.clone(), session)) }) .await - .map_err(|_| "宿主执行身份读取中断")??; + .map_err(|error| format!("宿主执行身份读取中断:{error}"))??; Ok(Self::new( root, session, @@ -1031,9 +1031,13 @@ impl ExecutionAdapter { } fn report(&self) -> String { - direct_delivery::terminal_report(&self.session).unwrap_or_else(|| { - "本轮执行已停止,宿主尚未确认交付完成;请核对保留的证据与未完成项。".into() - }) + match direct_delivery::terminal_report(&self.session) { + Ok(Some(report)) => report, + Ok(None) => "本轮执行已停止,宿主尚未确认交付完成;请核对保留的证据与未完成项。".into(), + Err(error) => format!( + "本轮执行已停止,读取宿主交付状态失败:{error};请核对保留的证据与未完成项。" + ), + } } async fn drain(&self) { @@ -1132,8 +1136,9 @@ impl ExecutionAdapter { self.interrupt("收尾仍有未结算操作,交付尚未完成。").await; HostOutcome::Report(self.report()) } - Err(_) => { - self.interrupt("交付复核失败,已停止本轮并保留证据。").await; + Err(error) => { + let message = format!("交付复核失败:{error};已停止本轮并保留证据。"); + self.interrupt(&message).await; HostOutcome::Report(self.report()) } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index a8e4b69bb..61fea2476 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -11,7 +11,7 @@ use tokio::sync::{mpsc, oneshot, Mutex, Notify}; mod direct_project_history_wire; use direct_project_history_wire::build_direct_project_history_injection_params; -mod process_tree; +pub(crate) mod process_tree; use process_tree::{OwnedProcessTree, ProcessTreeExitProof}; mod direct_project_identity; mod execution; @@ -25,6 +25,8 @@ const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str = "CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED"; +const GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL: &str = "https://api.openai.com/v1"; +const GAME_CREATOR_CODEX_APP_SERVER_PROTOCOL: &str = "genarrative-codex-app-server.v3"; /// 单条 JSON-RPC 消息(NDJSON 一行)的字节上限:**同时**是 stdout 读侧上限与 stdin 写侧守卫。 /// /// 两侧必须共用这一个常量。2026-09-11 的隔离探针实测(codex-cli 0.147.0,AGC 捆绑版本): @@ -41,6 +43,7 @@ const GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES: usize = 32 * 1024 * 1024; /// 之后再输出,所以**单条 item** 允许占用的字节数必须小于整行上限。 const GAME_CREATOR_CODEX_APP_SERVER_ECHO_ENVELOPE_MARGIN_BYTES: usize = 8 * 1024; const GAME_CREATOR_CODEX_APP_SERVER_STDERR_RECORD_MAX_BYTES: usize = 256 * 1024; +const GAME_CREATOR_CODEX_APP_SERVER_AUTH_MAX_BYTES: usize = 1024 * 1024; const GAME_CREATOR_CODEX_APP_SERVER_BACKLOG_TURN_MAX: usize = 128; const GAME_CREATOR_CODEX_APP_SERVER_POOL_MAX: usize = 32; const GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX: usize = 128; @@ -94,22 +97,19 @@ enum CodexAppServerCredential { AppDataKey { fingerprint: String, }, + AuthBridge { + auth_json: Vec, + api_key: Option, + fingerprint: String, + }, } impl CodexAppServerCredential { fn fingerprint(&self) -> &str { match self { - Self::PlatformSession { fingerprint, .. } | Self::AppDataKey { fingerprint } => { - fingerprint - } - } - } - - /// 测试态默认凭据:AGD 自定义 Key 走 AppDataKey 路由;正式构建只有平台会话路由。 - #[cfg(test)] - fn app_data_key(api_key: &str) -> Self { - Self::AppDataKey { - fingerprint: format!("app-data-key:{:x}", Sha256::digest(api_key.as_bytes())), + Self::PlatformSession { fingerprint, .. } + | Self::AppDataKey { fingerprint } + | Self::AuthBridge { fingerprint, .. } => fingerprint, } } @@ -138,6 +138,12 @@ impl CodexAppServerCredential { Self::PlatformSession { .. } => None, Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty()) .then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())), + #[cfg(test)] + Self::AuthBridge { api_key, .. } => api_key + .as_deref() + .map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)), + #[cfg(not(test))] + Self::AuthBridge { .. } => None, } } } @@ -309,6 +315,7 @@ fn game_creator_codex_app_server_error_kind_with_machine_detail( error: &serde_json::Value, ) -> platform_llm::LlmError { let mut fields = Vec::new(); + let mut preserved_detail = false; if let Some(object) = error.as_object() { if let Some(code) = object.get("code").and_then(serde_json::Value::as_str) { if !code.is_empty() @@ -337,6 +344,71 @@ fn game_creator_codex_app_server_error_kind_with_machine_detail( .collect::>(); if !keys.is_empty() { fields.push(format!("fields={}", keys.join(","))); + // `codexErrorInfo` is often the only field returned by app-server for a + // failed turn. Keeping just its key (the old behaviour) turns a real + // HTTP/connection failure into `other detail=fields=codexErrorInfo`. + // Preserve the bounded structured value as diagnostic text; the same + // redaction boundary below removes credentials, URLs and paths while + // retaining status codes and actionable provider fields. + let structured_details = keys + .iter() + .filter_map(|key| { + let value = object.get(key)?; + let serialized = serde_json::to_string(value).ok()?; + let safe = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &serialized, + 360, + ); + (!safe.trim().is_empty()).then(|| format!("{key}={safe}")) + }) + .collect::>(); + preserved_detail |= !structured_details.is_empty(); + fields.extend(structured_details); + } + let detail = ["message", "additionalDetails"] + .into_iter() + .filter_map(|field| object.get(field).and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + .join(";"); + if !detail.is_empty() { + // 先在 app-server 投影边界做一次无根目录脱敏;终态载荷还会按项目根目录再脱敏。 + let safe_detail = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + fields.push(format!("detail={safe_detail}")); + preserved_detail = true; + } + } else if !error.is_null() { + let serialized = + serde_json::to_string(error).unwrap_or_else(|_| "<无法序列化 error>".into()); + let safe_detail = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &serialized, + 360, + ); + if !safe_detail.trim().is_empty() { + fields.push(format!("error={safe_detail}")); + preserved_detail = true; + } + } + if !preserved_detail { + // Unknown object shapes and machine-only errors must still carry a bounded + // safe payload. Returning only the native kind recreates the old opaque + // fallback when app-server adds a field this version does not know yet. + let serialized = + serde_json::to_string(error).unwrap_or_else(|_| "<无法序列化 error>".into()); + let safe_detail = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &serialized, + 360, + ); + if !safe_detail.trim().is_empty() && safe_detail != "{}" { + fields.push(format!("error={safe_detail}")); } } let suffix = if fields.is_empty() { @@ -363,18 +435,52 @@ fn game_creator_codex_app_server_error_http_status( fn game_creator_codex_app_server_connection_error( info: &serde_json::Value, field: &str, + error: &serde_json::Value, ) -> platform_llm::LlmError { match game_creator_codex_app_server_error_http_status(info, field) { - Some(401 | 403) => game_creator_codex_app_server_error_kind("unauthorized"), - Some(413) => game_creator_codex_app_server_error_kind("request-too-large"), - Some(status_code) => platform_llm::LlmError::Upstream { - status_code, - message: "Codex app-server 连接上游失败".to_string(), - }, - None => platform_llm::LlmError::Connectivity { - attempts: 1, - message: "Codex app-server 连接失败".to_string(), - }, + Some(status_code @ (401 | 403)) => { + let platform_llm::LlmError::InvalidRequest(message) = + game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error) + else { + unreachable!("native unauthorized projection must remain InvalidRequest"); + }; + platform_llm::LlmError::InvalidRequest(format!("{message} detail=HTTP {status_code}")) + } + Some(413) => { + let platform_llm::LlmError::InvalidRequest(message) = + game_creator_codex_app_server_error_kind_with_machine_detail( + "request-too-large", + error, + ) + else { + unreachable!("native request-too-large projection must remain InvalidRequest"); + }; + platform_llm::LlmError::InvalidRequest(format!("{message} detail=HTTP 413")) + } + Some(status_code) => { + let detail = game_creator_codex_app_server_error_display_detail(error); + let detail = if detail.is_empty() { + game_creator_codex_app_server_json_rpc_error_detail(error) + } else { + detail + }; + platform_llm::LlmError::Upstream { + status_code, + message: format!("Codex app-server 连接上游失败({field}):{detail}"), + } + } + None => { + let detail = game_creator_codex_app_server_error_display_detail(error); + let detail = if detail.is_empty() { + game_creator_codex_app_server_json_rpc_error_detail(info) + } else { + detail + }; + platform_llm::LlmError::Connectivity { + attempts: 1, + message: format!("Codex app-server 连接失败({field}):{detail}"), + } + } } } @@ -416,6 +522,54 @@ fn game_creator_codex_app_server_error_detail(error: &serde_json::Value) -> Stri .to_ascii_lowercase() } +fn game_creator_codex_app_server_error_display_detail(error: &serde_json::Value) -> String { + let Some(error) = error.as_object() else { + return String::new(); + }; + ["message", "additionalDetails", "code"] + .into_iter() + .filter_map(|field| error.get(field).and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + .join(";") +} + +/// JSON-RPC 请求失败时不要只取可选的 `message`:有些上游只返回 `code` / `data`, +/// 丢掉整个 error 对象会把真实协议错误再次压成“未知错误”。这里保留结构化字段, +/// 最后仍由回合错误投影按项目根目录做一次精确脱敏。 +fn game_creator_codex_app_server_json_rpc_error_detail(error: &serde_json::Value) -> String { + let detail = error + .as_object() + .map(|object| { + ["code", "message", "additionalDetails", "data"] + .into_iter() + .filter_map(|field| object.get(field).map(|value| (field, value))) + .filter_map(|(field, value)| { + let value = match value.as_str() { + Some(value) => value.to_string(), + None if !value.is_null() => serde_json::to_string(value).ok()?, + _ => return None, + }; + let value = value.trim().to_string(); + (!value.is_empty()).then(|| format!("{field}={value}")) + }) + .collect::>() + .join(";") + }) + .unwrap_or_default(); + let detail = if detail.is_empty() { + serde_json::to_string(error).unwrap_or_else(|_| "无法序列化 JSON-RPC error".to_string()) + } else { + detail + }; + crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &detail, + 480, + ) +} + fn game_creator_codex_app_server_error_detail_indicates_stream_requirement( error: &serde_json::Value, ) -> bool { @@ -479,7 +633,15 @@ fn game_creator_codex_app_server_failed_turn_error( turn: &serde_json::Value, ) -> platform_llm::LlmError { let Some(error) = turn.get("error").filter(|error| !error.is_null()) else { - return game_creator_codex_app_server_error_kind("other"); + let status = turn + .get("status") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"); + // app-server 有时只给 failed 状态、不带 error 对象。仍然保留协议事实,不能 + // 把“失败终态缺少 error 载荷”伪装成没有任何原因的 other。 + return platform_llm::LlmError::InvalidRequest(format!( + "{GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX}other detail=turn/completed status={status} error=missing" + )); }; if game_creator_codex_app_server_error_detail_indicates_insufficient_mud_points(error) { return platform_llm::LlmError::Upstream { @@ -488,19 +650,28 @@ fn game_creator_codex_app_server_failed_turn_error( }; } if game_creator_codex_app_server_error_detail_indicates_request_too_large(error) { - return game_creator_codex_app_server_error_kind("request-too-large"); + return game_creator_codex_app_server_error_kind_with_machine_detail( + "request-too-large", + error, + ); } if game_creator_codex_app_server_error_detail_indicates_stream_requirement(error) { - return game_creator_codex_app_server_error_kind("stream-required"); + return game_creator_codex_app_server_error_kind_with_machine_detail( + "stream-required", + error, + ); } if game_creator_codex_app_server_error_detail_indicates_timeout(error) { return platform_llm::LlmError::Connectivity { attempts: 1, - message: "Codex app-server 上游请求超时".to_string(), + message: format!( + "Codex app-server 上游请求超时:{}", + game_creator_codex_app_server_error_display_detail(error) + ), }; } if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) { - return game_creator_codex_app_server_error_kind("unauthorized"); + return game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error); } let Some(info) = error.get("codexErrorInfo").filter(|info| !info.is_null()) else { return game_creator_codex_app_server_error_kind_with_machine_detail("other", error); @@ -508,25 +679,44 @@ fn game_creator_codex_app_server_failed_turn_error( if let Some(kind) = info.as_str() { return match kind { "contextWindowExceeded" => { - game_creator_codex_app_server_error_kind("context-window-exceeded") + game_creator_codex_app_server_error_kind_with_machine_detail( + "context-window-exceeded", + error, + ) } "sessionBudgetExceeded" => { - game_creator_codex_app_server_error_kind("session-budget-exceeded") - } - "usageLimitExceeded" => { - game_creator_codex_app_server_error_kind("usage-limit-exceeded") + game_creator_codex_app_server_error_kind_with_machine_detail( + "session-budget-exceeded", + error, + ) } + "usageLimitExceeded" => game_creator_codex_app_server_error_kind_with_machine_detail( + "usage-limit-exceeded", + error, + ), "serverOverloaded" | "internalServerError" => platform_llm::LlmError::Upstream { status_code: 503, - message: "Codex app-server 上游服务暂时不可用".to_string(), + message: format!( + "Codex app-server 上游服务暂时不可用:{}", + game_creator_codex_app_server_error_display_detail(error) + ), }, - "cyberPolicy" => game_creator_codex_app_server_error_kind("cyber-policy"), - "unauthorized" => game_creator_codex_app_server_error_kind("unauthorized"), - "badRequest" => game_creator_codex_app_server_error_kind("bad-request"), - "threadRollbackFailed" => { - game_creator_codex_app_server_error_kind("thread-rollback-failed") + "cyberPolicy" => { + game_creator_codex_app_server_error_kind_with_machine_detail("cyber-policy", error) + } + "unauthorized" => { + game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error) + } + "badRequest" => { + game_creator_codex_app_server_error_kind_with_machine_detail("bad-request", error) + } + "threadRollbackFailed" => game_creator_codex_app_server_error_kind_with_machine_detail( + "thread-rollback-failed", + error, + ), + "sandboxError" => { + game_creator_codex_app_server_error_kind_with_machine_detail("sandbox-error", error) } - "sandboxError" => game_creator_codex_app_server_error_kind("sandbox-error"), "other" => game_creator_codex_app_server_error_kind_with_machine_detail("other", error), _ => game_creator_codex_app_server_error_kind_with_machine_detail("other", error), }; @@ -538,7 +728,7 @@ fn game_creator_codex_app_server_failed_turn_error( "responseTooManyFailedAttempts", ] { if info.get(field).is_some() { - return game_creator_codex_app_server_connection_error(info, field); + return game_creator_codex_app_server_connection_error(info, field, error); } } if info.get("activeTurnNotSteerable").is_some() { @@ -647,6 +837,9 @@ pub(crate) enum TurnObservation { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum CodexAppServerWorkspaceMode { + /// The legacy AGC ToolHost path: use a throwaway workspace and keep the + /// app-server constrained to passive runtime items. + ToolHost, /// A direct conversation attached to a user-selected game project. DirectProject, /// The home-page conversation: direct Codex instructions, but no user @@ -657,6 +850,7 @@ enum CodexAppServerWorkspaceMode { impl CodexAppServerWorkspaceMode { fn pool_identity(self) -> &'static str { match self { + Self::ToolHost => "tool-host", Self::DirectProject => "direct-project", Self::DirectHome => "direct-home", } @@ -676,6 +870,7 @@ impl CodexAppServerWorkspaceMode { fn passive_item_boundary_name(self) -> &'static str { match self { + Self::ToolHost => "AGC ToolHost", Self::DirectHome => "AGC 首页只读对话", Self::DirectProject => "AGC 直连项目", } @@ -689,13 +884,13 @@ pub(super) fn resolve_direct_codex_project_authority( return Err("AGC 直连项目根目录必须是绝对路径".to_string()); } let project_metadata = std::fs::metadata(project_root) - .map_err(|_| "AGC 直连项目根目录不存在或无法读取".to_string())?; + .map_err(|error| format!("AGC 直连项目根目录不存在或无法读取:{error}"))?; if !project_metadata.is_dir() { return Err("AGC 直连项目根目录不是目录".to_string()); } let project_root = project_root .canonicalize() - .map_err(|_| "AGC 直连项目根目录无法安全解析".to_string())?; + .map_err(|error| format!("AGC 直连项目根目录无法安全解析:{error}"))?; // 用户选择的项目目录就是 Codex 工作区根,不再强制要求 game/ 子目录。 // 原生文件工具仍由项目文件层拒绝 .agent/**、.git/**、密钥等控制面路径。 Ok((project_root.clone(), project_root)) @@ -1354,6 +1549,7 @@ struct CodexAppServerInner { workspace_path: std::path::PathBuf, workspace_mode: CodexAppServerWorkspaceMode, direct_project_root: Option, + oauth_handoff: Option, client_mcp_server_ids_by_name: HashMap, client_mcp_connection_id: Option, client_mcp_startup_statuses: Mutex>, @@ -1412,6 +1608,10 @@ fn game_creator_codex_app_server_pool() -> &'static Mutex &'static str { + GAME_CREATOR_CODEX_APP_SERVER_PROTOCOL +} + fn game_creator_codex_app_server_pool_key( llm: &GameCreatorLlmConfig, codex_cli_version: &str, @@ -1588,8 +1788,8 @@ async fn stage_codex_app_server_image( let (extension, encoded) = image_data_url_parts(image_url)?; let bytes = base64::engine::general_purpose::STANDARD .decode(encoded) - .map_err(|_| { - platform_llm::LlmError::InvalidRequest("多模态图片 base64 内容无效".to_string()) + .map_err(|error| { + platform_llm::LlmError::InvalidRequest(format!("多模态图片 base64 内容无效:{error}")) })?; if bytes.is_empty() || bytes.len() > GAME_CREATOR_CODEX_APP_SERVER_IMAGE_MAX_BYTES { return Err(platform_llm::LlmError::InvalidRequest( @@ -1807,6 +2007,82 @@ fn game_creator_codex_app_server_interaction_response( }) } +#[cfg(test)] +fn find_game_creator_codex_auth_path() -> Option { + std::env::var_os("CODEX_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME").map(|home| std::path::PathBuf::from(home).join(".codex")) + }) + .or_else(|| { + std::env::var_os("USERPROFILE") + .map(|home| std::path::PathBuf::from(home).join(".codex")) + }) + .map(|home| home.join("auth.json")) + .filter(|path| path.is_file()) +} + +#[cfg(test)] +fn read_game_creator_codex_auth_bridge( + source_auth: &std::path::Path, +) -> Result { + let mut auth_json = Vec::new(); + { + use std::io::Read; + std::fs::File::open(source_auth) + .map_err(|_| { + platform_llm::LlmError::InvalidConfig( + "读取 Codex CLI 登录态失败;请重新登录 Codex CLI 后重试".to_string(), + ) + })? + .take(GAME_CREATOR_CODEX_APP_SERVER_AUTH_MAX_BYTES.saturating_add(1) as u64) + .read_to_end(&mut auth_json) + .map_err(|_| { + platform_llm::LlmError::InvalidConfig( + "读取 Codex CLI 登录态失败;请重新登录 Codex CLI 后重试".to_string(), + ) + })?; + } + if auth_json.is_empty() || auth_json.len() > GAME_CREATOR_CODEX_APP_SERVER_AUTH_MAX_BYTES { + return Err(platform_llm::LlmError::InvalidConfig( + "Codex CLI 登录态文件为空或超过大小上限;请重新登录 Codex CLI 后重试".to_string(), + )); + } + let api_key = serde_json::from_slice::(&auth_json) + .ok() + .and_then(|value| { + value + .get("OPENAI_API_KEY") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }); + Ok(CodexAppServerCredential::AuthBridge { + fingerprint: format!("auth-bridge:{:x}", Sha256::digest(&auth_json)), + auth_json, + api_key, + }) +} + +#[cfg(test)] +fn resolve_game_creator_codex_app_server_credential( + llm: &GameCreatorLlmConfig, +) -> Result { + if !llm.api_key.trim().is_empty() { + return Ok(CodexAppServerCredential::AppDataKey { + fingerprint: format!("app-data-key:{:x}", Sha256::digest(llm.api_key.as_bytes())), + }); + } + let source_auth = find_game_creator_codex_auth_path().ok_or_else(|| { + platform_llm::LlmError::InvalidConfig( + "codex_app_server 未配置 API Key,且未找到可桥接的 Codex CLI 登录态;请先登录 Codex CLI 或填写 Agent LLM API Key" + .to_string(), + ) + })?; + read_game_creator_codex_auth_bridge(&source_auth) +} + fn game_creator_codex_app_server_validate_llm_config( llm: &GameCreatorLlmConfig, ) -> Result<(), platform_llm::LlmError> { @@ -1867,7 +2143,7 @@ fn configure_game_creator_codex_app_server_command( configure_game_creator_codex_app_server_command_for_mode( command, llm, - CodexAppServerWorkspaceMode::DirectHome, + CodexAppServerWorkspaceMode::ToolHost, None, None, false, @@ -1962,9 +2238,9 @@ fn configure_game_creator_codex_app_server_command_for_mode( )); } if workspace_mode != CodexAppServerWorkspaceMode::DirectProject { - // DirectHome retains its passive, read-only contract. DirectProject - // deliberately leaves Codex's native tools enabled and relies on the - // app-server sandbox. + // Legacy ToolHost and DirectHome retain their passive, read-only + // contract. DirectProject deliberately leaves Codex's native tools + // enabled and relies on the app-server sandbox. let disabled_features = [ "apps", "browser_use", @@ -2033,8 +2309,10 @@ fn configure_game_creator_codex_app_server_command_for_mode( command.arg("--disable").arg(feature); } if !direct_native_process_tools { - // 非 DirectProject 模式没有原生工具授权:工作区沙箱能读到同 uid 文件与父进程状态, - // 在凭据没有经工具桥代理前保持关闭。 + // OAuth-style auth bridges still require a raw auth.json in the + // app-server process. The workspace sandbox can read same-uid + // files and parent process state, so native process tools remain + // closed until that credential is brokered too. command.arg("--disable").arg("shell_tool"); command.arg("--disable").arg("unified_exec"); } @@ -2128,6 +2406,8 @@ fn normalize_codex_private_runtime_path(path: std::path::PathBuf) -> std::path:: fn prepare_isolated_game_creator_codex_home( root: &std::path::Path, + credential: &CodexAppServerCredential, + bridge_through_provider_proxy: bool, ) -> Result { let isolated_home = root.join("codex-home"); std::fs::create_dir(&isolated_home).map_err(|error| { @@ -2141,6 +2421,30 @@ fn prepare_isolated_game_creator_codex_home( )) }, )?; + let CodexAppServerCredential::AuthBridge { auth_json, .. } = credential else { + return Ok(isolated_home); + }; + if bridge_through_provider_proxy { + return Ok(isolated_home); + } + let target_auth = isolated_home.join("auth.json"); + crate::write_game_creator_private_file(&target_auth, auth_json, "隔离 Codex 登录态").map_err( + |error| { + platform_llm::LlmError::Transport(format!( + "桥接 Codex 登录态到隔离 app-server 失败:{}", + crate::sanitize_diagnostic_message(&error, Some(root)) + )) + }, + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&target_auth, std::fs::Permissions::from_mode(0o600)).map_err( + |error| { + platform_llm::LlmError::Transport(format!("收紧隔离 Codex 登录态权限失败:{error}")) + }, + )?; + } Ok(isolated_home) } @@ -2200,6 +2504,20 @@ fn trust_isolated_game_creator_codex_workspace( } impl CodexAppServerConnection { + async fn acquire( + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + ) -> Result { + Self::acquire_at_workspace( + snapshot, + llm, + None, + CodexAppServerWorkspaceMode::ToolHost, + None, + ) + .await + } + async fn acquire_at_workspace( snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, @@ -2217,14 +2535,16 @@ impl CodexAppServerConnection { Ok::<_, String>((executable, version)) }) .await - .map_err(|_| platform_llm::LlmError::InvalidConfig("Codex 执行器身份核验中断".into()))? + .map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!("Codex 执行器身份核验中断:{error}")) + })? .map_err(platform_llm::LlmError::InvalidConfig)?; if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { execution::validate_approval_version(&codex_cli_version) .map_err(platform_llm::LlmError::InvalidConfig)?; } let mut effective_llm = llm.clone(); - let credential = if llm.custom_enabled { + let mut credential = if llm.custom_enabled { crate::config::validate_custom_llm_connection(llm) .map_err(platform_llm::LlmError::InvalidConfig)?; CodexAppServerCredential::AppDataKey { @@ -2258,7 +2578,7 @@ impl CodexAppServerConnection { } else { #[cfg(test)] { - CodexAppServerCredential::app_data_key(&llm.api_key) + resolve_game_creator_codex_app_server_credential(llm)? } #[cfg(not(test))] { @@ -2272,6 +2592,7 @@ impl CodexAppServerConnection { credential.fingerprint(), workspace_mode, ); + let auth_route_key = key.clone(); let direct_root = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let turn_id = direct_client_turn_id .filter(|id| !id.trim().is_empty()) @@ -2286,8 +2607,10 @@ impl CodexAppServerConnection { platform_llm::LlmError::InvalidRequest("Direct 连接缺少项目目录".into()) })? .canonicalize() - .map_err(|_| { - platform_llm::LlmError::InvalidRequest("Direct 项目目录不可用".into()) + .map_err(|error| { + platform_llm::LlmError::InvalidRequest(format!( + "Direct 项目目录不可用:{error}" + )) })?; let bind_root = root.clone(); let bind_executable = executable.clone(); @@ -2301,7 +2624,9 @@ impl CodexAppServerConnection { session.bind_codex_executor(&bind_executable, &bind_version) }) .await - .map_err(|_| platform_llm::LlmError::InvalidRequest("Direct 执行器绑定中断".into()))? + .map_err(|error| { + platform_llm::LlmError::InvalidRequest(format!("Direct 执行器绑定中断:{error}")) + })? .map_err(platform_llm::LlmError::InvalidRequest)?; Some(root) } else { @@ -2387,24 +2712,34 @@ impl CodexAppServerConnection { } } } + let oauth_handoff = direct_root + .as_ref() + .map(|root| { + model_catalog::OAuthHandoff::prepare(root, &auth_route_key, &mut credential) + }) + .transpose() + .map_err(platform_llm::LlmError::InvalidConfig)? + .flatten(); let connection = match workspace_override { Some(workspace) => { - Box::pin(Self::spawn_with_executable_and_credential( + Box::pin(Self::spawn_with_executable_credential_and_auth_handoff( &effective_llm, &credential, executable.as_os_str(), Some(workspace), workspace_mode, + oauth_handoff.clone(), )) .await? } None => { - Box::pin(Self::spawn_with_executable_and_credential( + Box::pin(Self::spawn_with_executable_credential_and_auth_handoff( &effective_llm, &credential, executable.as_os_str(), None, workspace_mode, + oauth_handoff, )) .await? } @@ -2424,13 +2759,13 @@ impl CodexAppServerConnection { llm: &GameCreatorLlmConfig, executable: &std::ffi::OsStr, ) -> Result { - let credential = CodexAppServerCredential::app_data_key(&llm.api_key); + let credential = resolve_game_creator_codex_app_server_credential(llm)?; Self::spawn_with_executable_and_credential_at_workspace( llm, &credential, executable, None, - CodexAppServerWorkspaceMode::DirectHome, + CodexAppServerWorkspaceMode::ToolHost, ) .await } @@ -2443,22 +2778,24 @@ impl CodexAppServerConnection { workspace_override: Option<&std::path::Path>, workspace_mode: CodexAppServerWorkspaceMode, ) -> Result { - Box::pin(Self::spawn_with_executable_and_credential( + Box::pin(Self::spawn_with_executable_credential_and_auth_handoff( llm, credential, executable, workspace_override, workspace_mode, + None, )) .await } - async fn spawn_with_executable_and_credential( + async fn spawn_with_executable_credential_and_auth_handoff( llm: &GameCreatorLlmConfig, credential: &CodexAppServerCredential, executable: &std::ffi::OsStr, workspace_override: Option<&std::path::Path>, workspace_mode: CodexAppServerWorkspaceMode, + oauth_handoff: Option, ) -> Result { let working_dir = tempfile::Builder::new() .prefix("genarrative-agc-codex-app-server-") @@ -2508,7 +2845,11 @@ impl CodexAppServerConnection { }; let remote_control_disable_reason = credential.remote_control_disable_reason(direct_provider_route.is_some()); - let isolated_codex_home = prepare_isolated_game_creator_codex_home(&private_runtime_dir)?; + let isolated_codex_home = prepare_isolated_game_creator_codex_home( + &private_runtime_dir, + credential, + direct_provider_route.is_some(), + )?; let isolated_workspace = private_runtime_dir.join("workspace"); if workspace_override.is_none() { std::fs::create_dir(&isolated_workspace).map_err(|error| { @@ -2705,16 +3046,43 @@ impl CodexAppServerConnection { } configure_game_creator_codex_cli_process(&mut command); if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + let source = model_catalog::source_for_credential(credential, provider_proxy.is_some()) + .map_err(platform_llm::LlmError::InvalidConfig)?; let cancel = tool_bridge_root .as_deref() .and_then(|root| super::direct_execution::current(root).ok()) .map(|session| session.cancel_flag()); + if let Some(handoff) = oauth_handoff.as_ref() { + handoff + .mark_active() + .map_err(platform_llm::LlmError::InvalidConfig)?; + } let captured = Box::pin(model_catalog::capture( &command, &isolated_codex_home, + source, cancel, )) .await; + let capture_exit_uncertain = captured.as_ref().err().is_some_and(|error| { + matches!( + error.as_str(), + "model-catalog-process-owner-unavailable" + | "model-catalog-process-exit-unconfirmed" + ) + }); + if let Some(handoff) = oauth_handoff.as_ref().filter(|_| !capture_exit_uncertain) { + let handoff = handoff.clone(); + let private_home = isolated_codex_home.clone(); + tokio::task::spawn_blocking(move || handoff.remember(&private_home)) + .await + .map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!( + "私有 OAuth 轮换状态保存中断:{error}" + )) + })? + .map_err(platform_llm::LlmError::InvalidConfig)?; + } let catalog = captured.map_err(platform_llm::LlmError::InvalidConfig)?; command.arg("-c").arg(format!( "model_catalog_json={}", @@ -2726,6 +3094,13 @@ impl CodexAppServerConnection { "Codex app-server 不可用,请安装并登录 Codex CLI:{error}" )) })?; + if let Some(handoff) = oauth_handoff.as_ref() { + if let Err(error) = handoff.mark_active() { + let _ = child.start_kill(); + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await; + return Err(platform_llm::LlmError::InvalidConfig(error)); + } + } // 归属失败时绝不发送 initialize,受控模型和工具尚未获得执行入口。 let process_tree = match OwnedProcessTree::attach(&child) { Ok(tree) => tree, @@ -2764,6 +3139,7 @@ impl CodexAppServerConnection { workspace_path, workspace_mode, direct_project_root: tool_bridge_root, + oauth_handoff, client_mcp_server_ids_by_name, client_mcp_connection_id: client_mcp_connection_id.clone(), client_mcp_startup_statuses: Mutex::new(HashMap::new()), @@ -3102,10 +3478,6 @@ impl CodexAppServerConnection { } } - // 生产入口(非 Direct 的 app-server 回合)。`#[cfg(unix)]` 的三个 app-server 用例在**测试构建**里也会 - // 直接调用它(见下方 read-only protocol / cancel / terminal-unknown 三个用例),因此测试态只在 unix 保留; - // Windows 测试构建用不到它,继续排除以维持 zero-warning 口径。 - #[cfg(any(not(test), unix))] async fn run_turn( &self, snapshot: &AgentRuntimeProviderRequestSnapshot, @@ -3999,9 +4371,6 @@ impl From for RunFailure { impl RunFailure { /// 压回 `LlmError`:只给拿不到"继续返修批次"这条控制流的入口用(非 Direct 的 `run_turn`)。 - /// - /// cfg 与 `run_turn` 保持一致:unix 的测试构建会经由 `run_turn` 调用到它。 - #[cfg(any(not(test), unix))] fn into_llm_error(self) -> platform_llm::LlmError { match self { Self::Failed(error) => error, @@ -4487,10 +4856,15 @@ async fn read_game_creator_codex_app_server_stdout( } let message = match serde_json::from_slice::(&buffer) { Ok(message) => message, - Err(_) => { + Err(error) => { + let raw = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &String::from_utf8_lossy(&buffer), + 480, + ); fail_game_creator_codex_app_server_connection( &inner, - "Codex app-server 返回无效 JSON-RPC".to_string(), + format!("Codex app-server 返回无效 JSON-RPC:{error};原文={raw}"), ) .await; return; @@ -4644,10 +5018,7 @@ async fn read_game_creator_codex_app_server_stdout( let result = if let Some(error) = message.get("error") { Err(format!( "Codex app-server JSON-RPC 失败:{}", - error - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("未知错误") + game_creator_codex_app_server_json_rpc_error_detail(error) )) } else { Ok(message @@ -5030,6 +5401,12 @@ async fn shutdown_game_creator_codex_app_server_inner( .as_ref() .is_ok_and(ProcessTreeExitProof::owned_scope_retired) { + if let Some(handoff) = inner.oauth_handoff.as_ref() { + let handoff = handoff.clone(); + let private_home = inner._working_dir.path().join("codex-home"); + // 凭据续传失败只阻止下一回合,不把本轮已确认的进程退出改写成失败。 + let _ = tokio::task::spawn_blocking(move || handoff.remember(&private_home)).await; + } forget_retired_direct_executor(inner); } result @@ -5045,6 +5422,17 @@ fn release_client_mcp_connection_for_inner(inner: &Arc) { ); } +pub(in crate::agent) async fn request_game_creator_agent_codex_app_server( + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, +) -> Result { + CodexAppServerConnection::acquire(snapshot, llm) + .await? + .run_turn(snapshot, llm, request, None) + .await +} + /// Direct product chat path: a Codex app-server turn in the selected project /// workspace. The pool keeps one ephemeral thread per project so follow-up /// messages retain Codex context while still bypassing the Supervisor/runtime @@ -5106,9 +5494,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( observer, ) .await - .map_err(|detail| { - TurnError::from_model_call(&crate::agent::claude_code_failure_to_llm_error(detail)) - }); + .map_err(crate::agent::claude_code_failure_to_turn_error); } game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| { TurnError::EnvironmentNotReady(EnvironmentNotReady { @@ -5127,9 +5513,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map(|state| state.client_turn_id) }) .await - .map_err(|_| { + .map_err(|error| { TurnError::HostStateUnavailable(HostStateUnavailable { - detail: "宿主 CLI 回合身份读取中断".to_string(), + detail: format!("宿主 CLI 回合身份读取中断:{error}"), }) })? .map_err(|detail| TurnError::HostStateUnavailable(HostStateUnavailable { detail }))?; @@ -5255,6 +5641,21 @@ pub(crate) async fn direct_game_creator_home_codex_chat( .map_err(|error| error.to_string()) } +pub(in crate::agent) async fn stream_game_creator_agent_codex_app_server( + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, + mut on_agent_message_delta: F, +) -> Result +where + F: FnMut(&platform_llm::LlmStreamDelta) + Send, +{ + CodexAppServerConnection::acquire(snapshot, llm) + .await? + .run_turn(snapshot, llm, request, Some(&mut on_agent_message_delta)) + .await +} + pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result<(), String> { tauri::async_runtime::block_on(async { let mut connections = game_creator_codex_app_server_pool() @@ -5321,6 +5722,22 @@ pub(crate) fn build_direct_codex_history_prompt( #[cfg(test)] mod tests { + #[test] + fn json_rpc_error_keeps_structured_detail_and_redacts_secret() { + let detail = + super::game_creator_codex_app_server_json_rpc_error_detail(&serde_json::json!({ + "code": -32001, + "data": { + "reason": "out of memory (ENOMEM)", + "api_key": "provider-secret", + }, + })); + + assert!(detail.contains("code=-32001"), "{detail}"); + assert!(detail.contains("out of memory (ENOMEM)"), "{detail}"); + assert!(detail.contains("api_key"), "{detail}"); + assert!(!detail.contains("provider-secret"), "{detail}"); + } use super::*; #[test] @@ -5330,18 +5747,19 @@ mod tests { let credential = CodexAppServerCredential::AppDataKey { fingerprint: "not-executed".into(), }; - let spawn = CodexAppServerConnection::spawn_with_executable_and_credential( + let spawn = CodexAppServerConnection::spawn_with_executable_credential_and_auth_handoff( &llm, &credential, std::ffi::OsStr::new("not-executed"), None, - CodexAppServerWorkspaceMode::DirectHome, + CodexAppServerWorkspaceMode::ToolHost, + None, ); let acquire = CodexAppServerConnection::acquire_at_workspace( &snapshot, &llm, None, - CodexAppServerWorkspaceMode::DirectHome, + CodexAppServerWorkspaceMode::ToolHost, None, ); let direct = direct_game_creator_codex_chat_at_with_optional_observer( @@ -6244,6 +6662,18 @@ mod tests { assert!(std::path::Path::new(staged_path).is_file()); } + #[tokio::test] + async fn app_server_invalid_base64_preserves_decoder_detail() { + let temp = tempfile::tempdir().expect("temp dir"); + let error = + stage_codex_app_server_image(temp.path(), "data:image/png;base64,not-base64", 0) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("多模态图片 base64 内容无效"), "{error}"); + assert!(error.contains("Invalid"), "{error}"); + } + #[test] fn app_server_multimodal_validation_rejects_system_images() { let request = LlmRunRequest::new(vec![LlmMessage::multimodal( @@ -6746,52 +7176,84 @@ mod tests { } }); let error = game_creator_codex_app_server_failed_turn_error(&failed_turn); - assert_eq!( - error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:context-window-exceeded".to_string() - ) - ); let visible = error.to_string(); + assert!(visible.starts_with("codex-app-server-error:context-window-exceeded")); assert!(!visible.contains(&secret)); assert!(!visible.contains("provider.example")); assert!(!visible.contains("victim")); } + #[test] + fn codex_app_server_failed_turn_keeps_nested_error_info_details() { + let failed_turn = serde_json::json!({ + "status": "failed", + "error": { + "codexErrorInfo": { + "httpConnectionFailed": { + "httpStatusCode": 400, + "message": "provider rejected the request" + }, + "responseStreamConnectionFailed": { + "retryable": false + } + } + } + }); + let visible = game_creator_codex_app_server_failed_turn_error(&failed_turn).to_string(); + assert!(visible.contains("httpStatusCode"), "{visible}"); + assert!(visible.contains("400"), "{visible}"); + assert!( + visible.contains("provider rejected the request"), + "{visible}" + ); + } + + #[test] + fn codex_app_server_failed_turn_keeps_machine_only_error_info_value() { + let failed_turn = serde_json::json!({ + "status": "failed", + "error": { "codexErrorInfo": "other" } + }); + let visible = game_creator_codex_app_server_failed_turn_error(&failed_turn).to_string(); + assert!(visible.contains("fields=codexErrorInfo"), "{visible}"); + assert!(visible.contains("codexErrorInfo=\"other\""), "{visible}"); + } + + #[test] + fn codex_app_server_failed_turn_keeps_non_object_error_body() { + let failed_turn = serde_json::json!({ + "status": "failed", + "error": "provider returned a structured failure body" + }); + let visible = game_creator_codex_app_server_failed_turn_error(&failed_turn).to_string(); + assert!( + visible.contains("provider returned a structured failure body"), + "{visible}" + ); + } + #[test] fn codex_app_server_failed_turn_maps_stable_categories_and_http_status() { - for (info, expected) in [ + for (info, expected_prefix) in [ ( serde_json::json!("usageLimitExceeded"), - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:usage-limit-exceeded".to_string(), - ), + "codex-app-server-error:usage-limit-exceeded", ), ( serde_json::json!("unauthorized"), - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:unauthorized".to_string(), - ), + "codex-app-server-error:unauthorized", ), ( serde_json::json!({"httpConnectionFailed":{"httpStatusCode":413}}), - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:request-too-large".to_string(), - ), + "codex-app-server-error:request-too-large", ), ( serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}), - platform_llm::LlmError::Upstream { - status_code: 429, - message: "Codex app-server 连接上游失败".to_string(), - }, + "上游返回 429", ), ( serde_json::json!({"responseStreamDisconnected":{"httpStatusCode":null}}), - platform_llm::LlmError::Connectivity { - attempts: 1, - message: "Codex app-server 连接失败".to_string(), - }, + "Codex app-server 连接失败", ), ] { let turn = serde_json::json!({ @@ -6802,17 +7264,15 @@ mod tests { "codexErrorInfo": info } }); - assert_eq!( - game_creator_codex_app_server_failed_turn_error(&turn), - expected - ); + let actual = game_creator_codex_app_server_failed_turn_error(&turn).to_string(); + assert!(actual.contains(expected_prefix), "{actual}"); } - assert_eq!( - game_creator_codex_app_server_failed_turn_error( - &serde_json::json!({"status":"failed","error":null}) - ), - platform_llm::LlmError::InvalidRequest("codex-app-server-error:other".to_string()) + let missing_error = game_creator_codex_app_server_failed_turn_error( + &serde_json::json!({"status":"failed","error":null}), ); + assert!(missing_error + .to_string() + .contains("status=failed error=missing")); } #[test] @@ -6830,12 +7290,9 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:unauthorized".to_string() - ) - ); + assert!(error + .to_string() + .starts_with("codex-app-server-error:unauthorized")); } } @@ -6855,12 +7312,9 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:request-too-large".to_string(), - ) - ); + assert!(error + .to_string() + .starts_with("codex-app-server-error:request-too-large")); } } @@ -6873,12 +7327,9 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - stream_error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:stream-required".to_string() - ) - ); + assert!(stream_error + .to_string() + .starts_with("codex-app-server-error:stream-required")); let timeout_error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({ "status": "failed", "error": { @@ -6886,13 +7337,12 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - timeout_error, - platform_llm::LlmError::Connectivity { - attempts: 1, - message: "Codex app-server 上游请求超时".to_string() - } - ); + assert!(timeout_error + .to_string() + .contains("Codex app-server 上游请求超时")); + assert!(timeout_error + .to_string() + .contains("provider request timed out")); } #[test] @@ -7081,7 +7531,7 @@ mod tests { let proxy = start_codex_provider_proxy(base, key, false).await.unwrap(); for mode in [ CodexAppServerWorkspaceMode::DirectProject, - CodexAppServerWorkspaceMode::DirectHome, + CodexAppServerWorkspaceMode::ToolHost, ] { let mut command = tokio::process::Command::new("fixture"); configure_game_creator_codex_app_server_command_for_mode( @@ -7398,24 +7848,26 @@ while IFS= read -r line; do :; done let mut base = test_llm(); let snapshot = test_snapshot(); base.api_key = "key-a".to_string(); - let base_credential = CodexAppServerCredential::app_data_key(&base.api_key); + let base_credential = + resolve_game_creator_codex_app_server_credential(&base).expect("base credential"); let mut changed = base.clone(); changed.api_key = "key-b".to_string(); - let changed_credential = CodexAppServerCredential::app_data_key(&changed.api_key); + let changed_credential = + resolve_game_creator_codex_app_server_credential(&changed).expect("changed credential"); assert_ne!( game_creator_codex_app_server_pool_key( &base, "codex-cli 0.155.1", &snapshot, base_credential.fingerprint(), - CodexAppServerWorkspaceMode::DirectHome, + CodexAppServerWorkspaceMode::ToolHost, ), game_creator_codex_app_server_pool_key( &changed, "codex-cli 0.155.1", &snapshot, changed_credential.fingerprint(), - CodexAppServerWorkspaceMode::DirectHome, + CodexAppServerWorkspaceMode::ToolHost, ) ); changed = base.clone(); @@ -7426,14 +7878,14 @@ while IFS= read -r line; do :; done "codex-cli 0.155.1", &snapshot, base_credential.fingerprint(), - CodexAppServerWorkspaceMode::DirectHome, + CodexAppServerWorkspaceMode::ToolHost, ), game_creator_codex_app_server_pool_key( &changed, "codex-cli 0.155.1", &snapshot, base_credential.fingerprint(), - CodexAppServerWorkspaceMode::DirectHome, + CodexAppServerWorkspaceMode::ToolHost, ) ); assert_ne!( @@ -7442,14 +7894,14 @@ while IFS= read -r line; do :; done "codex-cli 0.155.1", &snapshot, base_credential.fingerprint(), - CodexAppServerWorkspaceMode::DirectHome, + CodexAppServerWorkspaceMode::ToolHost, ), game_creator_codex_app_server_pool_key( &base, "codex-cli 0.148.0", &snapshot, base_credential.fingerprint(), - CodexAppServerWorkspaceMode::DirectHome, + CodexAppServerWorkspaceMode::ToolHost, ) ); let mut other_node = snapshot.clone(); @@ -7460,14 +7912,14 @@ while IFS= read -r line; do :; done "codex-cli 0.155.1", &snapshot, base_credential.fingerprint(), - CodexAppServerWorkspaceMode::DirectHome, + CodexAppServerWorkspaceMode::ToolHost, ), game_creator_codex_app_server_pool_key( &base, "codex-cli 0.155.1", &other_node, base_credential.fingerprint(), - CodexAppServerWorkspaceMode::DirectHome, + CodexAppServerWorkspaceMode::ToolHost, ) ); } @@ -7485,6 +7937,65 @@ while IFS= read -r line; do :; done api_key.remote_control_disable_reason(true), Some("provider-proxy-auth") ); + + let auth_bridge = CodexAppServerCredential::AuthBridge { + fingerprint: "auth-bridge".to_string(), + auth_json: br#"{"tokens":{"access_token":"fixture"}}"#.to_vec(), + api_key: None, + }; + assert_eq!(auth_bridge.remote_control_disable_reason(false), None); + assert_eq!( + auth_bridge.remote_control_disable_reason(true), + Some("provider-proxy-auth") + ); + } + + #[test] + fn codex_app_server_auth_bridge_snapshot_drives_pool_and_isolated_home() { + let temp = tempfile::tempdir().expect("temp dir"); + let source_auth = temp.path().join("source-auth.json"); + std::fs::write( + &source_auth, + br#"{"tokens":{"access_token":"first-secret"}}"#, + ) + .expect("write first auth"); + let first = read_game_creator_codex_auth_bridge(&source_auth).expect("first snapshot"); + std::fs::write( + &source_auth, + br#"{"tokens":{"access_token":"second-secret"}}"#, + ) + .expect("write second auth"); + let second = read_game_creator_codex_auth_bridge(&source_auth).expect("second snapshot"); + let llm = GameCreatorLlmConfig { + api_key: String::new(), + ..test_llm() + }; + let snapshot = test_snapshot(); + assert_ne!( + game_creator_codex_app_server_pool_key( + &llm, + "codex-cli 0.155.1", + &snapshot, + first.fingerprint(), + CodexAppServerWorkspaceMode::ToolHost, + ), + game_creator_codex_app_server_pool_key( + &llm, + "codex-cli 0.155.1", + &snapshot, + second.fingerprint(), + CodexAppServerWorkspaceMode::ToolHost, + ) + ); + + let isolated = temp.path().join("isolated"); + std::fs::create_dir(&isolated).expect("create isolated root"); + let home = prepare_isolated_game_creator_codex_home(&isolated, &first, false) + .expect("prepare auth bridge"); + assert_eq!( + std::fs::read(home.join("auth.json")).expect("read bridged auth"), + br#"{"tokens":{"access_token":"first-secret"}}"# + ); } #[test] @@ -7498,10 +8009,29 @@ while IFS= read -r line; do :; done configured_credential.direct_provider_route(&configured_llm), Some(("https://example.invalid/v1", "fixture-secret")) ); + let source_auth = temp.path().join("source-auth.json"); + std::fs::write( + &source_auth, + br#"{"OPENAI_API_KEY":"fixture-provider-secret"}"#, + ) + .expect("write api key auth"); + let credential = + read_game_creator_codex_auth_bridge(&source_auth).expect("read api key auth bridge"); + let llm = GameCreatorLlmConfig { + api_key: String::new(), + ..test_llm() + }; + assert_eq!( + credential.direct_provider_route(&llm), + Some(( + GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, + "fixture-provider-secret" + )) + ); let isolated = temp.path().join("direct-isolated"); std::fs::create_dir(&isolated).expect("create direct isolated root"); - let home = prepare_isolated_game_creator_codex_home(&isolated) + let home = prepare_isolated_game_creator_codex_home(&isolated, &credential, true) .expect("prepare brokered direct home"); assert!(!home.join("auth.json").exists()); } @@ -8464,4 +8994,23 @@ while IFS= read -r line; do :; done assert!(sibling_child_live); assert!(sibling_still_pooled); } + + #[tokio::test] + #[ignore = "requires an installed and authenticated Codex CLI and consumes one model turn"] + async fn codex_app_server_real_smoke_uses_existing_local_auth() { + let mut llm = GameCreatorLlmConfig::default(); + llm.api_key.clear(); + if let Ok(model) = std::env::var("AGC_CODEX_APP_SERVER_SMOKE_MODEL") { + llm.model = model; + } + let request = LlmRunRequest::single_turn( + "只回复指定文本,不使用任何工具。", + "请只回复 CODEX_APP_SERVER_SMOKE_OK", + ); + let response = request_game_creator_agent_codex_app_server(&test_snapshot(), &llm, request) + .await + .expect("real Codex app-server request"); + assert_eq!(response.text.trim(), "CODEX_APP_SERVER_SMOKE_OK"); + assert!(response.tool_calls.is_empty()); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs index f8592786e..8687ca23f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs @@ -1,5 +1,7 @@ //! 每个 Direct 用户回合冻结 SDK 自己解析的完整目录;只移除原生串行补丁注册。 -use super::OwnedProcessTree; +mod auth_handoff; +use super::{CodexAppServerCredential, OwnedProcessTree}; +pub(super) use auth_handoff::OAuthHandoff; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::collections::HashSet; @@ -7,7 +9,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::io::{AsyncRead, AsyncReadExt}; use tokio::process::Command; @@ -16,6 +18,38 @@ const CAPTURE_TIMEOUT: Duration = Duration::from_secs(20); /// 与构建期写入侧车清单的版本同源,避免两处固定版本漂移。 const VERSION: &str = super::super::codex_cli::codex_bundle::VERSION; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum CatalogSource { + Bundled, + Authenticated, +} + +pub(super) fn source_for_credential( + credential: &CodexAppServerCredential, + proxied: bool, +) -> Result { + if proxied { + return Ok(CatalogSource::Bundled); + } + let CodexAppServerCredential::AuthBridge { auth_json, .. } = credential else { + return Ok(CatalogSource::Bundled); + }; + let auth: Value = serde_json::from_slice(auth_json) + .map_err(|error| format!("model-catalog-auth-invalid: 认证桥接内容无效:{error}"))?; + // 与 SDK resolved_mode 一样,显式 auth_mode 优先;旧文件才根据 API Key 判定。 + let api_key_mode = match auth.get("auth_mode").and_then(Value::as_str) { + Some(mode) => mode == "apikey", + None => auth + .get("OPENAI_API_KEY") + .is_some_and(|value| !value.is_null()), + }; + Ok(if api_key_mode { + CatalogSource::Bundled + } else { + CatalogSource::Authenticated + }) +} + pub(super) fn direct_turn_pool_key(route_key: &str, client_turn_id: &str) -> String { format!( "{route_key}:turn:{:x}", @@ -71,7 +105,7 @@ async fn read_bounded(mut stream: impl AsyncRead + Unpin, limit: usize) -> Resul let count = stream .read(&mut buffer) .await - .map_err(|_| "model-catalog-read-failed")?; + .map_err(|error| format!("model-catalog-read-failed: {error}"))?; if count == 0 { return Ok(output); } @@ -98,36 +132,51 @@ async fn export_catalog( if cancel.is_some_and(|flag| flag.load(Ordering::Acquire)) { return Err("model-catalog-cancelled".into()); } - let mut child = command.spawn().map_err(|_| "model-catalog-spawn-failed")?; + let mut child = command + .spawn() + .map_err(|error| format!("model-catalog-spawn-failed: {error}"))?; let tree = match OwnedProcessTree::attach(&child) { Ok(tree) => tree, - Err(_) => { + Err(error) => { let _ = child.start_kill(); let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; - return Err("model-catalog-process-owner-unavailable".into()); + return Err(format!("model-catalog-process-owner-unavailable: {error}")); } }; let result = if let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) { let collected = async { - let (stdout, _, status) = tokio::try_join!( + let (stdout, stderr, status) = tokio::try_join!( read_bounded(stdout, MAX_CATALOG_BYTES), read_bounded(stderr, 64 * 1024), async { child .wait() .await - .map_err(|_| "model-catalog-wait-failed".to_string()) + .map_err(|error| format!("model-catalog-wait-failed: {error}")) }, )?; if !status.success() { - return Err("model-catalog-export-failed".into()); + let detail = String::from_utf8_lossy(&stderr).trim().to_string(); + let detail = crate::sanitize_diagnostic_message( + &detail, + Some(Path::new("__agc_no_project_root__")), + ); + return Err(if detail.is_empty() { + format!("model-catalog-export-failed: exitStatus={status}") + } else { + format!( + "model-catalog-export-failed: exitStatus={status}; stderr={}", + detail.chars().take(1200).collect::() + ) + }); } - serde_json::from_slice(&stdout).map_err(|_| "model-catalog-json-invalid".into()) + serde_json::from_slice(&stdout) + .map_err(|error| format!("model-catalog-json-invalid: {error}")) }; tokio::select! { biased; _ = cancelled(cancel) => Err("model-catalog-cancelled".into()), - result = tokio::time::timeout(CAPTURE_TIMEOUT, collected) => result.unwrap_or_else(|_| Err("model-catalog-timeout".into())), + result = tokio::time::timeout(CAPTURE_TIMEOUT, collected) => result.unwrap_or_else(|_| Err(format!("model-catalog-timeout: exceeded {} ms", CAPTURE_TIMEOUT.as_millis()))), } } else { Err("model-catalog-stdio-unavailable".into()) @@ -135,7 +184,7 @@ async fn export_catalog( let proof = tree .shutdown(&mut child) .await - .map_err(|_| "model-catalog-process-exit-unconfirmed")?; + .map_err(|error| format!("model-catalog-process-exit-unconfirmed: {error}"))?; if !proof.main_process_exited || !proof.observed_tree_empty { return Err("model-catalog-process-exit-unconfirmed".into()); } @@ -168,6 +217,69 @@ fn validate_models(catalog: &Value) -> Result<&Vec, String> { Ok(models) } +fn without_legacy_instructions(models: &[Value]) -> Vec { + models + .iter() + .cloned() + .map(|mut model| { + if let Some(model) = model.as_object_mut() { + model.remove("base_instructions"); + } + model + }) + .collect() +} + +fn verify_authenticated_source( + cache: &Value, + exported: &Value, + bundled: &Value, + started_ms: i64, + completed_ms: i64, +) -> Result<(), String> { + if cache.get("client_version").and_then(Value::as_str) != Some(VERSION) { + return Err("model-catalog-source-version-mismatch".into()); + } + let fetched = cache + .get("fetched_at") + .and_then(Value::as_str) + .and_then(|text| chrono::DateTime::parse_from_rfc3339(text).ok()) + .map(|time| time.timestamp_millis()) + .ok_or("model-catalog-source-time-invalid")?; + if fetched < started_ms.saturating_sub(1000) || fetched > completed_ms.saturating_add(1000) { + return Err("model-catalog-source-stale".into()); + } + let raw = cache + .get("models") + .and_then(Value::as_array) + .ok_or("model-catalog-source-models-invalid")?; + let actual = without_legacy_instructions(validate_models(exported)?); + let raw = without_legacy_instructions(raw); + if !raw.is_empty() && raw == actual { + return Ok(()); + } + // SDK 对空目录或非 ChatGPT 可见目录会与 bundled 合并,仍必须逐字段相等。 + let mut merged = without_legacy_instructions(validate_models(bundled)?); + for model in raw { + let slug = model + .get("slug") + .and_then(Value::as_str) + .ok_or("model-catalog-source-model-invalid")?; + if let Some(index) = merged + .iter() + .position(|entry| entry.get("slug").and_then(Value::as_str) == Some(slug)) + { + merged[index] = model; + } else { + merged.push(model); + } + } + if merged != actual { + return Err("model-catalog-source-content-mismatch".into()); + } + Ok(()) +} + fn derive_catalog(original: &Value) -> Result { validate_models(original)?; let mut derived = original.clone(); @@ -193,22 +305,82 @@ fn derive_catalog(original: &Value) -> Result { Ok(derived) } +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(i64::MAX as u128) as i64 +} + fn catalog_io_error(code: &str, error: &str, private_root: &Path) -> String { let detail = crate::sanitize_diagnostic_message(error, Some(private_root)); format!("{code}: {}", detail.chars().take(1200).collect::()) } +/// 只接收本回合可信 SDK 在新私有 HOME 中创建/替换、且子进程已退出的固定工件。 +/// Windows 提升权限 token 的默认 owner 可能仍为 Administrators;先拒绝链接,再初始化归属。 +fn harden_sdk_private_artifact(home: &Path, name: &str) -> Result<(), String> { + if !matches!(name, "models_cache.json" | "auth.json") { + return Err("model-catalog-artifact-not-owned".into()); + } + crate::prepare_game_creator_private_path_for_read(home, true, "当前回合 SDK 私有目录")?; + let path = home.join(name); + let (file, _) = + crate::project::open_project_snapshot_regular_file(&path, "当前回合 SDK 私有工件")?; + crate::harden_new_game_creator_private_path(&path, false, "当前回合 SDK 私有工件")?; + drop(file); + crate::prepare_game_creator_private_path_for_read(&path, false, "当前回合 SDK 私有工件")?; + Ok(()) +} + pub(super) async fn capture( app_server_command: &Command, isolated_home: &Path, + source: CatalogSource, cancel: Option>, ) -> Result { - let original = Box::pin(export_catalog( + let cache_path = isolated_home.join("models_cache.json"); + if source == CatalogSource::Authenticated + && cache_path + .try_exists() + .map_err(|error| format!("model-catalog-source-unavailable: {error}"))? + { + return Err("model-catalog-source-not-fresh: 当前回合必须使用新隔离目录".into()); + } + let bundled = Box::pin(export_catalog( capture_command(app_server_command, true)?, cancel.as_ref(), )) .await?; - validate_models(&original)?; + validate_models(&bundled)?; + let started_ms = now_ms(); + let (original, cache_evidence) = if source == CatalogSource::Authenticated { + let original = Box::pin(export_catalog( + capture_command(app_server_command, false)?, + cancel.as_ref(), + )) + .await?; + harden_sdk_private_artifact(isolated_home, "models_cache.json").map_err(|error| { + catalog_io_error("model-catalog-source-unconfirmed", &error, isolated_home) + })?; + let cache_text = crate::read_game_creator_private_file_to_string( + &cache_path, + "模型目录来源", + MAX_CATALOG_BYTES as u64, + ) + .map_err(|error| { + format!("model-catalog-source-unconfirmed: OAuth 目录未取得本回合有效远端来源:{error}") + })?; + let cache: Value = serde_json::from_str(&cache_text) + .map_err(|error| format!("model-catalog-source-invalid: {error}"))?; + verify_authenticated_source(&cache, &original, &bundled, started_ms, now_ms())?; + let evidence = json!({"cacheSha256":format!("{:x}", Sha256::digest(cache_text.as_bytes())), + "fetchedAt":cache.get("fetched_at"),"etagSha256":cache.get("etag").and_then(Value::as_str).map(|etag|format!("{:x}",Sha256::digest(etag.as_bytes())))}); + (original, evidence) + } else { + (bundled, Value::Null) + }; let derived = derive_catalog(&original)?; if cancel .as_ref() @@ -216,8 +388,10 @@ pub(super) async fn capture( { return Err("model-catalog-cancelled".into()); } - let original_bytes = serde_json::to_vec(&original).map_err(|_| "model-catalog-json-invalid")?; - let derived_bytes = serde_json::to_vec(&derived).map_err(|_| "model-catalog-json-invalid")?; + let original_bytes = serde_json::to_vec(&original) + .map_err(|error| format!("model-catalog-json-invalid: {error}"))?; + let derived_bytes = serde_json::to_vec(&derived) + .map_err(|error| format!("model-catalog-json-invalid: {error}"))?; if derived_bytes.len() > MAX_CATALOG_BYTES { return Err("model-catalog-output-limit".into()); } @@ -225,13 +399,14 @@ pub(super) async fn capture( crate::write_game_creator_private_file(&path, &derived_bytes, "当前回合模型目录") .map_err(|error| catalog_io_error("model-catalog-write-failed", &error, isolated_home))?; let receipt = json!({"schemaVersion":"agc-direct-model-catalog.v1","codexVersion":VERSION, - "source":"bundled", + "source":if source == CatalogSource::Bundled {"bundled"} else {"authenticated-fresh-cache"}, "originalSha256":format!("{:x}",Sha256::digest(&original_bytes)), "derivedSha256":format!("{:x}",Sha256::digest(&derived_bytes)), - "changedField":"apply_patch_tool_type","modelCount":derived["models"].as_array().unwrap().len(),"cache":Value::Null}); + "changedField":"apply_patch_tool_type","modelCount":derived["models"].as_array().unwrap().len(),"cache":cache_evidence}); crate::write_game_creator_private_file( &isolated_home.join("direct-model-catalog-receipt.json"), - &serde_json::to_vec(&receipt).map_err(|_| "model-catalog-receipt-invalid")?, + &serde_json::to_vec(&receipt) + .map_err(|error| format!("model-catalog-receipt-invalid: {error}"))?, "模型目录来源回执", ) .map_err(|error| { @@ -266,6 +441,56 @@ mod tests { assert!(derive_catalog(&duplicate).is_err()); } + #[test] + fn oauth_cache_requires_current_version_timestamp_and_exact_metadata() { + let original = catalog(); + let mut cache = json!({"client_version":VERSION,"fetched_at":"2026-09-20T00:00:00Z","models":original["models"]}); + let time = chrono::DateTime::parse_from_rfc3339("2026-09-20T00:00:00Z") + .unwrap() + .timestamp_millis(); + verify_authenticated_source(&cache, &original, &original, time, time + 100).unwrap(); + assert!(verify_authenticated_source( + &cache, + &original, + &original, + time + 300_000, + time + 301_000 + ) + .is_err()); + cache["client_version"] = json!("0.148.0"); + assert!( + verify_authenticated_source(&cache, &original, &original, time, time + 100).is_err() + ); + cache["client_version"] = json!(VERSION); + cache["models"][0]["context_window"] = json!(1); + assert!( + verify_authenticated_source(&cache, &original, &original, time, time + 100).is_err() + ); + } + + #[test] + fn auth_bridge_respects_explicit_mode_and_legacy_api_key() { + for (auth, expected) in [ + (json!({"OPENAI_API_KEY":"fake"}), CatalogSource::Bundled), + ( + json!({"auth_mode":"chatgpt","OPENAI_API_KEY":"fake","tokens":{}}), + CatalogSource::Authenticated, + ), + (json!({"auth_mode":"apikey"}), CatalogSource::Bundled), + ] { + let credential = CodexAppServerCredential::AuthBridge { + auth_json: serde_json::to_vec(&auth).unwrap(), + api_key: None, + fingerprint: "fixture".into(), + }; + assert_eq!(source_for_credential(&credential, false).unwrap(), expected); + assert_eq!( + source_for_credential(&credential, true).unwrap(), + CatalogSource::Bundled + ); + } + } + #[test] fn each_client_turn_partitions_the_connection_without_leaking_its_identifier() { let first = direct_turn_pool_key("route", "first-private-turn"); @@ -306,12 +531,30 @@ mod tests { ); } + #[tokio::test] + async fn oauth_rejects_an_existing_cache_before_starting_a_process() { + let home = tempfile::tempdir().unwrap(); + std::fs::write(home.path().join("models_cache.json"), b"{}").unwrap(); + let mut command = Command::new(home.path().join("must-never-execute")); + command.args(["app-server", "--stdio"]); + let error = capture(&command, home.path(), CatalogSource::Authenticated, None) + .await + .unwrap_err(); + assert!(error.starts_with("model-catalog-source-not-fresh")); + assert!(!home.path().join("direct-model-catalog.json").exists()); + } + #[test] fn catalog_futures_do_not_embed_large_buffers_in_the_main_call_chain() { let mut command = Command::new("not-executed"); command.args(["app-server", "--stdio"]); let export = export_catalog(capture_command(&command, true).unwrap(), None); - let capture = capture(&command, Path::new("not-read"), None); + let capture = capture( + &command, + Path::new("not-read"), + CatalogSource::Bundled, + None, + ); let export_bytes = std::mem::size_of_val(&export); let capture_bytes = std::mem::size_of_val(&capture); assert!( @@ -334,6 +577,17 @@ mod tests { assert!(detail.contains("owner 不属于当前用户")); assert!(!detail.contains("C:\\private")); } + + #[test] + fn sdk_private_artifact_rejects_hardlinks_before_initializing_ownership() { + let home = tempfile::tempdir().unwrap(); + let outside = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(outside.path(), b"outside").unwrap(); + std::fs::hard_link(outside.path(), home.path().join("models_cache.json")).unwrap(); + assert!(harden_sdk_private_artifact(home.path(), "models_cache.json").is_err()); + assert_eq!(std::fs::read(outside.path()).unwrap(), b"outside"); + assert!(harden_sdk_private_artifact(home.path(), "other-file").is_err()); + } } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs new file mode 100644 index 000000000..3db0ac57b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs @@ -0,0 +1,371 @@ +//! 仅在宿主进程私有内存中延续同一 OAuth 来源的轮换凭据,绝不回写用户 auth.json。 +use super::{source_for_credential, CatalogSource, CodexAppServerCredential}; +use base64::Engine as _; +use serde_json::Value; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +const MAX_AUTH_BYTES: u64 = 1024 * 1024; +const MAX_SCOPES: usize = 128; + +#[derive(Clone, PartialEq, Eq)] +struct OAuthIdentity { + mode: String, + account_id: String, + user_id: Option, +} + +struct Entry { + route_key: String, + identity: OAuthIdentity, + generation: String, + latest: Result, String>, +} + +static HANDOFFS: OnceLock>> = OnceLock::new(); + +fn handoffs() -> &'static Mutex> { + HANDOFFS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn oauth_identity(auth: &Value) -> Result { + let mode = auth + .get("auth_mode") + .and_then(Value::as_str) + .unwrap_or("chatgpt"); + if !matches!(mode, "chatgpt" | "chatgptAuthTokens") { + return Err("model-catalog-auth-handoff-mode: 当前认证不是可延续的 OAuth 身份".into()); + } + let token = auth + .pointer("/tokens/id_token") + .and_then(Value::as_str) + .ok_or("model-catalog-auth-handoff-identity: OAuth 身份标记缺失")?; + let payload = token + .split('.') + .nth(1) + .ok_or("model-catalog-auth-handoff-identity")?; + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload.trim_end_matches('=')) + .map_err(|error| format!("model-catalog-auth-handoff-identity:{error}"))?; + let claims: Value = serde_json::from_slice(&payload) + .map_err(|error| format!("model-catalog-auth-handoff-identity:{error}"))?; + let claims_auth = &claims["https://api.openai.com/auth"]; + let token_account = auth + .pointer("/tokens/account_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()); + let claims_account = claims_auth + .get("chatgpt_account_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()); + if token_account + .zip(claims_account) + .is_some_and(|(left, right)| left != right) + { + return Err("model-catalog-auth-handoff-identity: OAuth 账户标记不一致".into()); + } + let account_id = token_account + .or(claims_account) + .filter(|value| value.len() <= 1024) + .ok_or("model-catalog-auth-handoff-identity: OAuth 账户标记缺失")? + .to_string(); + let user_id = claims_auth + .get("chatgpt_user_id") + .or_else(|| claims_auth.get("user_id")) + .or_else(|| claims.get("sub")) + .and_then(Value::as_str) + .map(str::to_string); + Ok(OAuthIdentity { + mode: mode.into(), + account_id, + user_id, + }) +} + +fn oauth_bytes(bytes: &[u8]) -> Result<(OAuthIdentity, Vec), String> { + if bytes.len() > MAX_AUTH_BYTES as usize { + return Err("model-catalog-auth-handoff-size".into()); + } + let mut auth: Value = serde_json::from_slice(bytes) + .map_err(|error| format!("model-catalog-auth-handoff-invalid:{error}"))?; + let identity = oauth_identity(&auth)?; + // 显式 OAuth 文件可能还残留一个 API Key;私有轮换缓存从不携带它。 + auth["OPENAI_API_KEY"] = Value::Null; + let bytes = serde_json::to_vec(&auth) + .map_err(|error| format!("model-catalog-auth-handoff-invalid:{error}"))?; + Ok((identity, bytes)) +} + +#[derive(Clone)] +pub(in super::super) struct OAuthHandoff { + root: PathBuf, + route_key: String, + identity: OAuthIdentity, + generation: String, +} + +impl OAuthHandoff { + /// route_key 已包含路由、项目身份和原始认证来源的 fingerprint;不以轮换后的 token 重新派生。 + pub(in super::super) fn prepare( + root: &Path, + route_key: &str, + credential: &mut CodexAppServerCredential, + ) -> Result, String> { + if source_for_credential(credential, false)? != CatalogSource::Authenticated { + return Ok(None); + } + let CodexAppServerCredential::AuthBridge { + auth_json, api_key, .. + } = credential + else { + return Ok(None); + }; + let (identity, mut seed) = oauth_bytes(auth_json)?; + let mut entries = handoffs() + .lock() + .map_err(|_| "model-catalog-auth-handoff-unavailable")?; + if let Some(previous) = entries + .get(root) + .filter(|entry| entry.route_key == route_key && entry.identity == identity) + { + seed = previous.latest.clone()?; + } + if !entries.contains_key(root) && entries.len() >= MAX_SCOPES { + return Err("model-catalog-auth-handoff-capacity".into()); + } + let generation = uuid::Uuid::new_v4().to_string(); + entries.insert( + root.to_path_buf(), + Entry { + route_key: route_key.into(), + identity: identity.clone(), + generation: generation.clone(), + latest: Ok(seed.clone()), + }, + ); + *auth_json = seed; + *api_key = None; + Ok(Some(Self { + root: root.into(), + route_key: route_key.into(), + identity, + generation, + })) + } + + /// 有可能轮换 token 的 SDK 实例开始工作后,未取得其退出结果前不能重用旧凭据。 + pub(in super::super) fn mark_active(&self) -> Result<(), String> { + let mut entries = handoffs() + .lock() + .map_err(|_| "model-catalog-auth-handoff-unavailable")?; + let entry = entries + .get_mut(&self.root) + .filter(|entry| { + entry.generation == self.generation + && entry.route_key == self.route_key + && entry.identity == self.identity + }) + .ok_or("model-catalog-auth-handoff-owner-changed")?; + entry.latest = + Err("model-catalog-auth-handoff-pending: 上一私有 OAuth 实例的轮换结果尚未确认".into()); + Ok(()) + } + + /// 只在目录捕获进程/模型进程已退出后调用。迟到的旧实例不能覆盖下一回合的轮换结果。 + pub(in super::super) fn remember(&self, isolated_home: &Path) -> Result<(), String> { + let result = super::harden_sdk_private_artifact(isolated_home, "auth.json") + .and_then(|()| { + crate::read_game_creator_private_file_to_string( + &isolated_home.join("auth.json"), + "私有 OAuth 轮换状态", + MAX_AUTH_BYTES, + ) + }) + .map_err(|error| { + format!("model-catalog-auth-handoff-read: 无法确认私有 OAuth 轮换状态:{error}") + }) + .and_then(|text| oauth_bytes(text.as_bytes())) + .and_then(|(identity, bytes)| { + if identity != self.identity { + Err("model-catalog-auth-handoff-identity: OAuth 轮换后的身份改变".into()) + } else { + Ok(bytes) + } + }); + let mut entries = handoffs() + .lock() + .map_err(|_| "model-catalog-auth-handoff-unavailable")?; + if let Some(entry) = entries.get_mut(&self.root).filter(|entry| { + entry.route_key == self.route_key + && entry.identity == self.identity + && entry.generation == self.generation + }) { + entry.latest = result.clone(); + return result.map(|_| ()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn auth(account: &str, token: &str) -> Vec { + let claims = json!({"https://api.openai.com/auth":{"chatgpt_account_id":account,"chatgpt_user_id":"fixture-user"}}); + let id_token = format!( + "e30.{}.fixture", + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&claims).unwrap()) + ); + serde_json::to_vec(&json!({"auth_mode":"chatgpt","OPENAI_API_KEY":"must-not-cache-api-key", "tokens":{ + "account_id":account,"id_token":id_token,"access_token":token,"refresh_token":format!("refresh-{token}")}})).unwrap() + } + + fn credential(bytes: Vec) -> CodexAppServerCredential { + CodexAppServerCredential::AuthBridge { + auth_json: bytes, + api_key: Some("must-not-cache-api-key".into()), + fingerprint: "source-fixture".into(), + } + } + + fn token(credential: &CodexAppServerCredential) -> String { + let CodexAppServerCredential::AuthBridge { auth_json, .. } = credential else { + panic!() + }; + let value: Value = serde_json::from_slice(auth_json).unwrap(); + assert!(value["OPENAI_API_KEY"].is_null()); + value["tokens"]["access_token"].as_str().unwrap().into() + } + + #[test] + fn same_source_rotation_is_carried_without_writing_the_source_or_caching_api_keys() { + let root = tempfile::tempdir().unwrap(); + let original = auth("account-a", "original"); + let source_file = root.path().join("user-auth-source.json"); + std::fs::write(&source_file, &original).unwrap(); + let private = tempfile::tempdir().unwrap(); + let handoff = OAuthHandoff::prepare( + root.path(), + "same-route-and-source", + &mut credential(original.clone()), + ) + .unwrap() + .unwrap(); + std::fs::write( + private.path().join("auth.json"), + auth("account-a", "rotated"), + ) + .unwrap(); + handoff.remember(private.path()).unwrap(); + let mut next = credential(original.clone()); + OAuthHandoff::prepare(root.path(), "same-route-and-source", &mut next).unwrap(); + assert_eq!(token(&next), "rotated"); + assert_eq!(std::fs::read(source_file).unwrap(), original); + } + + #[test] + fn new_identity_route_or_source_never_inherits_a_previous_rotation() { + for (new_account, new_scope) in [ + ("account-b", "route-source-a"), + ("account-a", "route-b-source-a"), + ("account-a", "route-a-source-b"), + ] { + let root = tempfile::tempdir().unwrap(); + let private = tempfile::tempdir().unwrap(); + let prior = OAuthHandoff::prepare( + root.path(), + "route-source-a", + &mut credential(auth("account-a", "original")), + ) + .unwrap() + .unwrap(); + std::fs::write( + private.path().join("auth.json"), + auth("account-a", "rotated"), + ) + .unwrap(); + prior.remember(private.path()).unwrap(); + let mut next = credential(auth(new_account, "new-source")); + OAuthHandoff::prepare(root.path(), new_scope, &mut next).unwrap(); + assert_eq!(token(&next), "new-source"); + } + } + + #[test] + fn late_old_instance_cannot_overwrite_new_rotation_and_changed_account_fails_closed() { + let root = tempfile::tempdir().unwrap(); + let private = tempfile::tempdir().unwrap(); + let old = OAuthHandoff::prepare( + root.path(), + "scope", + &mut credential(auth("account-a", "first")), + ) + .unwrap() + .unwrap(); + let newer = OAuthHandoff::prepare( + root.path(), + "scope", + &mut credential(auth("account-a", "first")), + ) + .unwrap() + .unwrap(); + std::fs::write(private.path().join("auth.json"), auth("account-a", "newer")).unwrap(); + newer.remember(private.path()).unwrap(); + std::fs::write( + private.path().join("auth.json"), + auth("account-a", "old-late"), + ) + .unwrap(); + old.remember(private.path()).unwrap(); + let mut next = credential(auth("account-a", "first")); + let current = OAuthHandoff::prepare(root.path(), "scope", &mut next) + .unwrap() + .unwrap(); + assert_eq!(token(&next), "newer"); + std::fs::write( + private.path().join("auth.json"), + auth("account-b", "wrong-account"), + ) + .unwrap(); + assert!(current.remember(private.path()).is_err()); + assert!(OAuthHandoff::prepare( + root.path(), + "scope", + &mut credential(auth("account-a", "first")) + ) + .is_err()); + } + + #[test] + fn an_abandoned_instance_cannot_replay_the_pre_rotation_token() { + let root = tempfile::tempdir().unwrap(); + let private = tempfile::tempdir().unwrap(); + let owner = OAuthHandoff::prepare( + root.path(), + "scope", + &mut credential(auth("account-a", "first")), + ) + .unwrap() + .unwrap(); + owner.mark_active().unwrap(); + assert!(OAuthHandoff::prepare( + root.path(), + "scope", + &mut credential(auth("account-a", "first")) + ) + .is_err()); + std::fs::write( + private.path().join("auth.json"), + auth("account-a", "confirmed-rotation"), + ) + .unwrap(); + owner.remember(private.path()).unwrap(); + let mut next = credential(auth("account-a", "first")); + OAuthHandoff::prepare(root.path(), "scope", &mut next).unwrap(); + assert_eq!(token(&next), "confirmed-rotation"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/real_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/real_tests.rs index bbed13535..e7f95d74f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/real_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/real_tests.rs @@ -1,4 +1,8 @@ use super::*; +use base64::Engine as _; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::atomic::AtomicUsize; fn isolated_command(home: &Path) -> Command { let executable = crate::agent::game_creator_codex_cli_executable_path().unwrap(); @@ -25,6 +29,115 @@ fn isolated_command(home: &Path) -> Command { command } +struct LocalModels { + endpoint: String, + requests: Arc, + stop: Arc, + worker: Option>, +} + +impl LocalModels { + fn new(status: u16, body: Value) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = format!("http://{}/v1", listener.local_addr().unwrap()); + listener.set_nonblocking(true).unwrap(); + let requests = Arc::new(AtomicUsize::new(0)); + let counted = Arc::clone(&requests); + let stop = Arc::new(AtomicBool::new(false)); + let stopping = Arc::clone(&stop); + let body = serde_json::to_vec(&body).unwrap(); + let worker = std::thread::spawn(move || { + while !stopping.load(Ordering::Acquire) { + match listener.accept() { + Ok((mut stream, _)) => { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut buffer = [0u8; 4096]; + let count = stream.read(&mut buffer).unwrap_or_default(); + if !buffer[..count].starts_with(b"GET /v1/models?") { + continue; + } + counted.fetch_add(1, Ordering::AcqRel); + let headers = format!("HTTP/1.1 {status} Fixture\r\nContent-Type: application/json\r\nContent-Length: {}\r\nETag: \"fixture-catalog\"\r\nConnection: close\r\n\r\n", body.len()); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(&body); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(_) => break, + } + } + }); + Self { + endpoint, + requests, + stop, + worker: Some(worker), + } + } +} + +impl Drop for LocalModels { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(worker) = self.worker.take() { + worker.join().unwrap(); + } + } +} + +fn fake_oauth_command(home: &Path, endpoint: &str) -> Command { + let jwt = |body: Value| { + format!( + "{}.{}.fixture", + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(br#"{"alg":"none","typ":"JWT"}"#), + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&body).unwrap()) + ) + }; + let seconds = now_ms() / 1000; + let fetched = chrono::DateTime::from_timestamp(seconds, 0) + .unwrap() + .to_rfc3339(); + let auth = json!({"auth_mode":"chatgpt","OPENAI_API_KEY":null,"tokens":{ + "id_token":jwt(json!({"exp":seconds+3600,"https://api.openai.com/auth":{"chatgpt_plan_type":"plus","chatgpt_account_id":"local-fixture","chatgpt_user_id":"local-fixture"}})), + "access_token":jwt(json!({"exp":seconds+3600})),"refresh_token":"local-fake-refresh-token","account_id":"local-fixture"},"last_refresh":fetched}); + crate::write_game_creator_private_file( + &home.join("auth.json"), + &serde_json::to_vec(&auth).unwrap(), + "假 OAuth 测试身份", + ) + .unwrap(); + let mut command = isolated_command(home); + command + .args([ + "-c", + "cli_auth_credentials_store=\"file\"", + "-c", + "model_provider=\"fixture\"", + "-c", + "model_providers.fixture.name=\"Local OAuth fixture\"", + "-c", + "model_providers.fixture.wire_api=\"responses\"", + "-c", + "model_providers.fixture.requires_openai_auth=true", + "-c", + "model_providers.fixture.request_max_retries=0", + ]) + .arg("-c") + .arg(format!( + "model_providers.fixture.base_url={}", + serde_json::to_string(endpoint).unwrap() + )); + command +} + #[tokio::test] #[ignore = "runs verified bundled Codex without Provider or personal configuration"] async fn real_bundled_catalog_roundtrip_changes_only_patch_registration() { @@ -33,7 +146,9 @@ async fn real_bundled_catalog_roundtrip_changes_only_patch_registration() { let baseline = export_catalog(capture_command(&command, true).unwrap(), None) .await .unwrap(); - let path = capture(&command, home.path(), None).await.unwrap(); + let path = capture(&command, home.path(), CatalogSource::Bundled, None) + .await + .unwrap(); let derived: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); assert_eq!(derived, derive_catalog(&baseline).unwrap()); let mut reloaded = capture_command(&command, false).unwrap(); @@ -46,14 +161,84 @@ async fn real_bundled_catalog_roundtrip_changes_only_patch_registration() { assert!(!home.path().join("models_cache.json").exists()); } +#[tokio::test] +#[ignore = "runs bundled Codex with fabricated OAuth and loopback-only models endpoint"] +async fn real_oauth_catalog_preserves_remote_metadata_and_rejects_successful_fallback() { + let baseline_home = tempfile::tempdir().unwrap(); + let baseline = export_catalog( + capture_command(&isolated_command(baseline_home.path()), true).unwrap(), + None, + ) + .await + .unwrap(); + let mut remote = baseline["models"][0].clone(); + remote["slug"] = json!("oauth-fixture-current"); + remote["context_window"] = json!(123456); + remote["max_context_window"] = json!(234567); + let server = LocalModels::new(200, json!({"models":[remote]})); + let home = tempfile::tempdir().unwrap(); + let command = fake_oauth_command(home.path(), &server.endpoint); + let path = capture(&command, home.path(), CatalogSource::Authenticated, None) + .await + .unwrap(); + let derived: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(derived["models"].as_array().unwrap().len(), 1); + assert_eq!(derived["models"][0]["slug"], "oauth-fixture-current"); + assert_eq!(derived["models"][0]["context_window"], 123456); + assert!(derived["models"][0]["apply_patch_tool_type"].is_null()); + // SDK 对目录拉取有自带的瞬时重试策略(不受 fixture provider 的 request_max_retries 约束), + // 负载高时可能重试一次;这里只守住「没有重复成倍拉取」的上界,来源真伪由下面的字段断言证明。 + let successful_requests = server.requests.load(Ordering::Acquire); + assert!( + (1..=3).contains(&successful_requests), + "目录拉取次数异常:{successful_requests}" + ); + let cache: Value = + serde_json::from_slice(&std::fs::read(home.path().join("models_cache.json")).unwrap()) + .unwrap(); + assert_eq!( + cache["models"][0]["apply_patch_tool_type"], "freeform", + "原始缓存不得被派生逻辑改写" + ); + + let unavailable = LocalModels::new(503, json!({"error":"local fixture unavailable"})); + let failed_home = tempfile::tempdir().unwrap(); + let failed = capture( + &fake_oauth_command(failed_home.path(), &unavailable.endpoint), + failed_home.path(), + CatalogSource::Authenticated, + None, + ) + .await + .unwrap_err(); + assert!( + failed.starts_with("model-catalog-source-unconfirmed"), + "{failed}" + ); + let failed_requests = unavailable.requests.load(Ordering::Acquire); + assert!( + (1..=3).contains(&failed_requests), + "失败目录拉取次数异常:{failed_requests}" + ); + assert!(!failed_home + .path() + .join("direct-model-catalog.json") + .exists()); +} + #[tokio::test] async fn cancelled_catalog_capture_cannot_spawn_or_write_a_snapshot() { let home = tempfile::tempdir().unwrap(); let mut command = Command::new(home.path().join("must-never-execute")); command.args(["app-server", "--stdio"]); - let error = capture(&command, home.path(), Some(Arc::new(AtomicBool::new(true)))) - .await - .unwrap_err(); + let error = capture( + &command, + home.path(), + CatalogSource::Bundled, + Some(Arc::new(AtomicBool::new(true))), + ) + .await + .unwrap_err(); assert_eq!(error, "model-catalog-cancelled"); assert!(!home.path().join("direct-model-catalog.json").exists()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs index d0703aa3d..54fc35f92 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs @@ -30,7 +30,7 @@ impl ProcessTreeExitProof { } } -pub(super) struct OwnedProcessTree { +pub(crate) struct OwnedProcessTree { pid: u32, #[cfg(unix)] start_identity: String, @@ -41,20 +41,26 @@ pub(super) struct OwnedProcessTree { impl OwnedProcessTree { /// 调用方只能在此成功后发送 initialize,保证所有受控模型执行都在归属内。 - pub(super) fn attach(child: &Child) -> Result { + pub(crate) fn attach(child: &Child) -> Result { + Self::attach_with_role(child, "Codex") + } + + /// 具名角色版本:Claude Code sidecar 与 Codex app-server 都是受控执行器, + /// 共用同一套 Job Object 归属与退出证明,只是 Job 名字要能区分来源便于排障。 + pub(crate) fn attach_with_role(child: &Child, role: &str) -> Result { let pid = child.id().ok_or("app-server-process-owner-missing")?; #[cfg(unix)] let start_identity = crate::process_identity::external_agent_runner_process_start_identity(pid) - .map_err(|_| "app-server-process-identity-unknown")? + .map_err(|error| format!("app-server-process-identity-unknown:{error}"))? .filter(|value| !value.is_empty()) - .ok_or("app-server-process-identity-unknown")?; + .ok_or_else(|| "app-server-process-identity-unknown:启动身份为空".to_string())?; #[cfg(windows)] let job = crate::process_session::WindowsProcessJob::assign_tokio_named( child, - &format!("Local\\AGCCodex-{}", uuid::Uuid::new_v4()), + &format!("Local\\AGC{role}-{}", uuid::Uuid::new_v4()), ) - .map_err(|_| "app-server-process-job-unavailable")?; + .map_err(|error| format!("app-server-process-job-unavailable:{error}"))?; #[cfg(not(any(windows, unix)))] return Err("app-server-process-control-unsupported".into()); #[cfg(any(windows, unix))] @@ -68,7 +74,7 @@ impl OwnedProcessTree { }) } - pub(super) async fn shutdown(&self, child: &mut Child) -> Result { + pub(crate) async fn shutdown(&self, child: &mut Child) -> Result { let mut stored = self.exit_proof.lock().await; if let Some(proof) = stored.as_ref() { return proof.clone(); @@ -78,7 +84,7 @@ impl OwnedProcessTree { result } - pub(super) async fn recorded_exit_proof(&self) -> Result { + pub(crate) async fn recorded_exit_proof(&self) -> Result { self.exit_proof .lock() .await @@ -91,16 +97,26 @@ impl OwnedProcessTree { return Err("app-server-process-owner-mismatch".into()); } let deadline = tokio::time::Instant::now() + STOP_TIMEOUT; - if self.terminate_owned_tree().is_err() { + if let Err(error) = self.terminate_owned_tree() { // 只清理仍持有的直属 Child 句柄;不能据此报告子树已回收。 - let _ = child.start_kill(); - let _ = tokio::time::timeout_at(deadline, child.wait()).await; - return Err("app-server-process-tree-termination-uncertain".into()); + let kill_error = child + .start_kill() + .err() + .map(|error| format!(";直属进程终止失败:{error}")) + .unwrap_or_default(); + let wait_error = match tokio::time::timeout_at(deadline, child.wait()).await { + Ok(Ok(_)) => String::new(), + Ok(Err(error)) => format!(";等待直属进程退出失败:{error}"), + Err(_) => ";等待直属进程退出超时".to_string(), + }; + return Err(format!( + "app-server-process-tree-termination-uncertain:{error}{kill_error}{wait_error}" + )); } tokio::time::timeout_at(deadline, child.wait()) .await - .map_err(|_| "app-server-process-exit-timeout")? - .map_err(|_| "app-server-process-exit-unconfirmed")?; + .map_err(|_| "app-server-process-exit-timeout:等待直属进程退出超过 5 秒".to_string())? + .map_err(|error| format!("app-server-process-exit-unconfirmed:{error}"))?; loop { if self.observed_tree_empty()? { break; @@ -127,7 +143,7 @@ impl OwnedProcessTree { { self.job .is_empty() - .map_err(|_| "app-server-process-tree-state-unknown".into()) + .map_err(|error| format!("app-server-process-tree-state-unknown:{error}")) } #[cfg(unix)] { @@ -145,7 +161,10 @@ impl OwnedProcessTree { if std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { Ok(true) } else { - Err("app-server-process-tree-state-unknown".into()) + Err(format!( + "app-server-process-tree-state-unknown:{}", + std::io::Error::last_os_error() + )) } } #[cfg(not(any(windows, unix)))] @@ -159,7 +178,7 @@ impl OwnedProcessTree { { self.job .terminate() - .map_err(|_| "app-server-process-tree-termination-uncertain".into()) + .map_err(|error| format!("app-server-process-tree-termination-uncertain:{error}")) } #[cfg(unix)] { @@ -169,7 +188,7 @@ impl OwnedProcessTree { // leader 已消失或 PID 被复用时不盲杀 PGID;保留不确定状态。 let current = crate::process_identity::external_agent_runner_process_start_identity(self.pid) - .map_err(|_| "app-server-process-identity-unknown")?; + .map_err(|error| format!("app-server-process-identity-unknown:{error}"))?; if current.as_deref() != Some(self.start_identity.as_str()) { return Err("app-server-process-owner-mismatch".into()); } @@ -177,7 +196,10 @@ impl OwnedProcessTree { if result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { Ok(()) } else { - Err("app-server-process-tree-termination-uncertain".into()) + Err(format!( + "app-server-process-tree-termination-uncertain:{}", + std::io::Error::last_os_error() + )) } } #[cfg(not(any(windows, unix)))] @@ -270,7 +292,9 @@ fn linux_process_group_members( impl Drop for OwnedProcessTree { fn drop(&mut self) { // Drop 只做应急回收,永远不伪造完成证明;正式终态必须 await shutdown。 - let _ = self.terminate_owned_tree(); + if let Err(error) = self.terminate_owned_tree() { + app_log!("agent.codex_app_server.process_tree.drop_cleanup_failed error={error}"); + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs index 745a9c1c2..4cdb326c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs @@ -805,7 +805,10 @@ impl TurnError { native: native_kind(&detail), } } - LlmError::Upstream { status_code, .. } if *status_code == 409 => { + LlmError::Upstream { + status_code, + message, + } if *status_code == 409 && is_mud_points_upstream_message(message) => { ModelCallKind::PaidCreditsInsufficient } LlmError::Upstream { status_code, .. } => ModelCallKind::UpstreamFailed { @@ -836,6 +839,15 @@ impl TurnError { } } +fn is_mud_points_upstream_message(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + message.contains("泥点余额不足") + || message.contains("可消费泥点不足") + || normalized.contains("insufficient_mud_points") + || normalized.contains("insufficient-mud-points") + || normalized.contains("mud points insufficient") +} + /// 桥:深层尚未 typed 的字符串错误落进 [`TurnError::Unclassified`]。 /// /// 只给"这一轮已经开始"的层用。调用级(权限、校验、并发)必须显式构造对应变体。 @@ -1232,9 +1244,24 @@ mod tests { // 没有字段可读的变体只带判别键。 assert_eq!( - serde_json::to_value(TurnFailure::HostDropped).expect("serialize"), + serde_json::to_value(TurnFailure::HostDropped(crate::agent::HostDropped { + detail: None, + })) + .expect("serialize"), serde_json::json!({ "type": "hostDropped" }) ); + let historical: TurnFailure = + serde_json::from_value(serde_json::json!({ "type": "hostDropped" })) + .expect("old failure remains readable"); + assert!( + matches!(historical, TurnFailure::HostDropped(payload) if payload.detail.is_none()) + ); + let current: TurnFailure = serde_json::from_value(serde_json::json!({ + "type": "hostDropped", "detail": "宿主任务提前退出:IPC EPIPE", + })) + .expect("current failure remains readable"); + assert!(matches!(current, TurnFailure::HostDropped(payload) + if payload.detail.as_deref() == Some("宿主任务提前退出:IPC EPIPE"))); } /// 平台层 `LlmError` 到 typed 分类的映射,逐条对齐改造前的判据。 @@ -1361,6 +1388,27 @@ mod tests { assert!(!projected.is_model_repairable()); } + #[test] + fn non_payment_upstream_409_keeps_the_upstream_status_and_detail() { + let projected = TurnError::from_model_call(&LlmError::Upstream { + status_code: 409, + message: "Claude Code 返回冲突(HTTP 409):session already active".into(), + }); + match projected { + TurnError::ModelCallFailed(payload) => { + assert_eq!( + payload.kind, + ModelCallKind::UpstreamFailed { + status_code: 409, + native: None, + } + ); + assert!(payload.detail.contains("session already active")); + } + other => panic!("expected upstream 409 failure, got {other:?}"), + } + } + #[test] fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() { let rate_limited = TurnError::from_model_call(&LlmError::Upstream { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index f8cff66a2..7fb2b7dda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -3,12 +3,15 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use sha2::{Digest, Sha256}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; #[path = "../../build_support/codex_bundle.rs"] pub(crate) mod codex_bundle; const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex"; const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; +const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024; +const GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES: usize = 256 * 1024; #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -146,10 +149,10 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result(&value) - .map_err(|_| "内置 Codex CLI 完整性清单无效".to_string()) + .map_err(|error| format!("内置 Codex CLI 完整性清单无效:{error}")) })?; if manifest.schema_version != codex_bundle::SCHEMA || manifest.platform != layout.platform @@ -164,9 +167,9 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result 120 { return Err("返回了无法识别的版本信息".to_string()); @@ -276,6 +279,13 @@ pub(crate) fn game_creator_codex_cli_executable_path() -> Result, +} + pub(crate) fn game_creator_codex_cli_version_identity() -> Result { let executable = game_creator_codex_cli_executable_path()?; game_creator_codex_cli_version_at(&executable) @@ -417,6 +427,488 @@ pub(in crate::agent) fn configure_game_creator_codex_cli_process( crate::configure_windows_background_tokio_command(command, true); } +pub(in crate::agent) async fn terminate_game_creator_codex_cli_process_tree( + child: &mut tokio::process::Child, +) { + if let Some(process_id) = child.id() { + #[cfg(unix)] + unsafe { + libc::kill(-(process_id as i32), libc::SIGKILL); + } + #[cfg(windows)] + { + let mut command = tokio::process::Command::new("taskkill"); + command + .args(["/PID", &process_id.to_string(), "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + crate::configure_windows_background_tokio_command(&mut command, false); + let _ = command.status().await; + } + } + let _ = child.kill().await; + let _ = child.wait().await; +} + +async fn read_game_creator_codex_cli_output( + mut reader: R, + max_bytes: usize, +) -> Result, String> +where + R: AsyncRead + Unpin, +{ + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 8 * 1024]; + let mut exceeded = false; + loop { + let count = reader + .read(&mut buffer) + .await + .map_err(|error| format!("读取 Codex CLI Agent 输出失败:{error}"))?; + if count == 0 { + break; + } + let remaining = max_bytes.saturating_sub(bytes.len()); + bytes.extend_from_slice(&buffer[..count.min(remaining)]); + exceeded |= count > remaining; + } + if exceeded { + return Err(format!("Codex CLI Agent 输出超过 {max_bytes} 字节上限")); + } + Ok(bytes) +} + +async fn summarize_game_creator_codex_cli_stderr( + mut reader: R, + max_bytes: usize, +) -> Result +where + R: AsyncRead + Unpin, +{ + let mut byte_len = 0_usize; + let mut sha256 = Sha256::new(); + let mut preview = Vec::new(); + let mut buffer = [0_u8; 8 * 1024]; + loop { + let count = reader + .read(&mut buffer) + .await + .map_err(|error| format!("读取 Codex CLI Agent stderr 失败:{error}"))?; + if count == 0 { + break; + } + byte_len = byte_len.saturating_add(count); + sha256.update(&buffer[..count]); + let remaining = max_bytes.saturating_sub(preview.len()); + preview.extend_from_slice(&buffer[..count.min(remaining)]); + } + let detail = if preview.is_empty() { + None + } else { + let text = String::from_utf8_lossy(&preview); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + text.trim(), + 480, + ); + (!detail.trim().is_empty()).then_some(detail) + }; + Ok(CodexCliStderrSummary { + byte_len, + sha256: format!("{:x}", sha256.finalize()), + classification: if byte_len == 0 { + "empty" + } else if byte_len > max_bytes { + "oversized" + } else { + "nonempty" + }, + detail, + }) +} + +fn parse_game_creator_codex_cli_response( + stdout: &[u8], + request: &LlmRunRequest, +) -> Result { + let stdout = std::str::from_utf8(stdout).map_err(|error| { + platform_llm::LlmError::Deserialize(format!("Codex CLI Agent JSONL 不是 UTF-8:{error}")) + })?; + let mut response_id = None; + let mut final_message = None; + let mut usage = None; + let mut completed = false; + let mut last_event_detail = None; + for (index, line) in stdout.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let event = serde_json::from_str::(line).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + line, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent JSONL 第 {} 行无效:{error};原文={detail}", + index.saturating_add(1) + )) + })?; + last_event_detail = Some(crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + line, + 480, + )); + match event.get("type").and_then(serde_json::Value::as_str) { + Some("thread.started") => { + response_id = event + .get("thread_id") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + } + Some("item.completed") + if event + .pointer("/item/type") + .and_then(serde_json::Value::as_str) + == Some("agent_message") => + { + final_message = event + .pointer("/item/text") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + } + Some("turn.completed") => { + completed = true; + let prompt_tokens = event + .pointer("/usage/input_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let completion_tokens = event + .pointer("/usage/output_tokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let total_tokens = prompt_tokens.saturating_add(completion_tokens); + if total_tokens > 0 { + usage = Some(platform_llm::LlmTokenUsage { + prompt_tokens, + completion_tokens, + total_tokens, + }); + } + } + Some("turn.failed") => { + let mut detail = event + .get("error") + .or_else(|| event.get("message")) + .map(serde_json::Value::to_string) + .unwrap_or_default(); + if detail.trim().is_empty() { + detail = serde_json::to_string(&event).unwrap_or_default(); + } + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + detail.trim_matches('"'), + 480, + ); + return Err(platform_llm::LlmError::Transport(if detail.is_empty() { + "Codex CLI Agent 返回失败终态".to_string() + } else { + format!("Codex CLI Agent 返回失败终态:{detail}") + })); + } + _ => {} + } + } + if !completed { + return Err(platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent 缺少 turn.completed 终态;最后事件={}", + last_event_detail + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or_else(|| "<无有效事件>".to_string()) + ))); + } + let text = final_message + .filter(|text| !text.trim().is_empty()) + .ok_or(platform_llm::LlmError::EmptyResponse)?; + let tool_calls = if request.function_tools.is_empty() { + Vec::new() + } else { + let envelope = serde_json::from_str::(&text).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &text, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent structured output JSON 无效:{error};原文={detail}" + )) + })?; + let calls = envelope + .get("toolCalls") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + let detail = + serde_json::to_string(&envelope).unwrap_or_else(|_| "<不可序列化>".to_string()); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent structured output 缺少 toolCalls;原文={detail}" + )) + })?; + calls + .iter() + .enumerate() + .map(|(index, call)| { + let name = call + .get("name") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + let detail = serde_json::to_string(call) + .unwrap_or_else(|_| "<不可序列化>".to_string()); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent tool call 第 {} 项缺少 name;原文={detail}", + index.saturating_add(1) + )) + })?; + let arguments = call + .get("arguments") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent tool call 第 {} 项缺少 arguments;name={name}", + index.saturating_add(1) + )) + })?; + if !request.function_tools.iter().any(|tool| tool.name == name) { + return Err(platform_llm::LlmError::Deserialize( + "Codex CLI Agent 返回未广告函数".to_string(), + )); + } + Ok(platform_llm::LlmToolCall { + id: format!("codex-cli-call-{}", index.saturating_add(1)), + name: name.to_string(), + arguments: arguments.to_string(), + }) + }) + .collect::, _>>()? + }; + Ok(platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: "codex-cli".to_string(), + text: if tool_calls.is_empty() { + text + } else { + String::new() + }, + reasoning: String::new(), + finish_reason: Some("stop".to_string()), + response_id, + usage, + tool_calls, + responses_output: Vec::new(), + }) +} + +async fn request_game_creator_agent_codex_cli_with_executable( + executable: &std::ffi::OsStr, + request: LlmRunRequest, +) -> Result { + let prompt = render_game_creator_codex_cli_prompt(&request) + .map_err(platform_llm::LlmError::InvalidRequest)?; + let temp_dir = tempfile::Builder::new() + .prefix("genarrative-agc-codex-cli-") + .tempdir() + .map_err(|error| { + platform_llm::LlmError::Transport(format!("创建 Codex CLI Agent 临时目录失败:{error}")) + })?; + let schema_path = if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { + let path = temp_dir.path().join("tool-output.schema.json"); + let content = serde_json::to_vec(&schema).map_err(|error| { + platform_llm::LlmError::InvalidRequest(format!( + "序列化 Codex CLI Agent output schema 失败:{error}" + )) + })?; + std::fs::write(&path, content).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "写入 Codex CLI Agent output schema 失败:{error}" + )) + })?; + Some(path) + } else { + None + }; + + let mut command = tokio::process::Command::new(executable); + command + .arg("exec") + .arg("--json") + .arg("--ephemeral") + .arg("--ignore-user-config") + .arg("--ignore-rules") + .arg("--skip-git-repo-check") + .arg("--sandbox") + .arg("read-only") + .arg("--disable") + .arg("shell_tool") + .arg("--config") + .arg("approval_policy=\"never\"") + .arg("--cd") + .arg(temp_dir.path()); + if let Some(effort) = game_creator_codex_cli_reasoning_effort(&request) { + command + .arg("--config") + .arg(format!("model_reasoning_effort=\"{effort}\"")); + } + if let Some(path) = schema_path.as_ref() { + command.arg("--output-schema").arg(path); + } + command + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + game_creator_codex_cli_minimal_environment(&mut command); + configure_game_creator_codex_cli_process(&mut command); + + let mut child = command.spawn().map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!( + "Codex CLI Agent 不可用,请安装并登录 Codex CLI:{error}" + )) + })?; + let mut stdin = child.stdin.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex CLI Agent stdin 未建立".to_string()) + })?; + // 短生命周期 CLI 可能在 prompt 写完前退出;继续采集退出状态和脱敏 + // stderr,确保调用方拿到可行动的进程错误,而不是平台相关的 BrokenPipe。 + let mut stdin_error = None; + if let Err(error) = stdin.write_all(prompt.as_bytes()).await { + if error.kind() == std::io::ErrorKind::BrokenPipe { + stdin_error = Some(format!("写入 Codex CLI Agent prompt 失败:{error}")); + } else { + return Err(platform_llm::LlmError::Transport(format!( + "写入 Codex CLI Agent prompt 失败:{error}" + ))); + } + } + if let Err(error) = stdin.shutdown().await { + if error.kind() == std::io::ErrorKind::BrokenPipe { + stdin_error.get_or_insert_with(|| format!("关闭 Codex CLI Agent stdin 失败:{error}")); + } else { + return Err(platform_llm::LlmError::Transport(format!( + "关闭 Codex CLI Agent stdin 失败:{error}" + ))); + } + } + drop(stdin); + + let stdout = child.stdout.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex CLI Agent stdout 未建立".to_string()) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + platform_llm::LlmError::Transport("Codex CLI Agent stderr 未建立".to_string()) + })?; + let stdout_task = tokio::spawn(read_game_creator_codex_cli_output( + stdout, + GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES, + )); + let stderr_task = tokio::spawn(summarize_game_creator_codex_cli_stderr( + stderr, + GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES, + )); + let timeout_ms = request + .request_timeout_ms + .unwrap_or(GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS) + .max(1); + let wait = + tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), child.wait()).await; + let (status, timed_out) = match wait { + Ok(Ok(status)) => (Some(status), false), + Ok(Err(error)) => { + terminate_game_creator_codex_cli_process_tree(&mut child).await; + return Err(platform_llm::LlmError::Transport(format!( + "等待 Codex CLI Agent 退出失败:{error}" + ))); + } + Err(_) => { + terminate_game_creator_codex_cli_process_tree(&mut child).await; + (None, true) + } + }; + let stdout = stdout_task + .await + .map_err(|error| { + platform_llm::LlmError::Transport(format!( + "Codex CLI Agent stdout 读取任务失败:{error}" + )) + })? + .map_err(platform_llm::LlmError::Transport)?; + let stderr = stderr_task + .await + .map_err(|error| { + platform_llm::LlmError::Transport(format!( + "Codex CLI Agent stderr 读取任务失败:{error}" + )) + })? + .map_err(platform_llm::LlmError::Transport)?; + if timed_out { + return Err(platform_llm::LlmError::Timeout { attempts: 1 }); + } + let status = status.expect("non-timeout Codex CLI wait has exit status"); + if stderr.classification == "oversized" { + return Err(platform_llm::LlmError::Transport(format!( + "Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}{}", + GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES, + stderr.classification, + stderr.byte_len, + stderr.sha256, + stderr + .detail + .as_deref() + .map(|detail| format!(";stderrDetail={detail}")) + .unwrap_or_default() + ))); + } + if !status.success() { + return Err(platform_llm::LlmError::Transport(format!( + "Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={}{};请检查 Codex CLI 登录状态、网络和本机配置", + status + .code() + .map(|code| code.to_string()) + .unwrap_or_else(|| "none".to_string()), + stderr.classification, + stderr.byte_len, + stderr.sha256, + stderr + .detail + .as_deref() + .map(|detail| format!(";stderrDetail={detail}")) + .unwrap_or_default() + ))); + } + if let Some(error) = stdin_error { + return Err(platform_llm::LlmError::Transport(error)); + } + parse_game_creator_codex_cli_response(&stdout, &request) +} + +pub(in crate::agent) async fn request_game_creator_agent_codex_cli( + request: LlmRunRequest, +) -> Result { + let executable = + game_creator_codex_cli_executable_path().map_err(platform_llm::LlmError::InvalidConfig)?; + request_game_creator_agent_codex_cli_with_executable(executable.as_os_str(), request).await +} + #[cfg(test)] mod tests { use super::*; @@ -712,4 +1204,260 @@ mod tests { .get_envs() .any(|(name, value)| name == "CODEX_API_KEY" && value.is_some())); } + + #[tokio::test] + async fn codex_cli_mode_bounds_unterminated_output_and_keeps_sanitized_stderr() { + let output = vec![b'x'; 33]; + let error = read_game_creator_codex_cli_output(output.as_slice(), 32) + .await + .expect_err("unterminated output over the cap must fail"); + assert!(error.contains("超过 32 字节上限")); + + let stderr = b"provider connection refused"; + let summary = summarize_game_creator_codex_cli_stderr(stderr.as_slice(), 256) + .await + .expect("summarize stderr"); + assert_eq!(summary.classification, "nonempty"); + assert_eq!(summary.byte_len, stderr.len()); + assert_eq!(summary.sha256, format!("{:x}", Sha256::digest(stderr))); + assert_eq!( + summary.detail.as_deref(), + Some("provider connection refused") + ); + } + + #[test] + fn codex_cli_mode_maps_structured_agent_message_to_existing_tool_calls() { + let stdout = concat!( + "{\"type\":\"thread.started\",\"thread_id\":\"thread-1\"}\n", + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"{\\\"toolCalls\\\":[{\\\"name\\\":\\\"runtime_tool_file_read\\\",\\\"arguments\\\":\\\"{\\\\\\\"reason\\\\\\\":\\\\\\\"检查\\\\\\\",\\\\\\\"input\\\\\\\":{\\\\\\\"path\\\\\\\":\\\\\\\"game/index.html\\\\\\\"}}\\\"}]}\"}}\n", + "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":7}}\n" + ); + let response = parse_game_creator_codex_cli_response(stdout.as_bytes(), &tool_request()) + .expect("parse response"); + assert_eq!(response.response_id.as_deref(), Some("thread-1")); + assert_eq!(response.tool_calls.len(), 1); + assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read"); + assert_eq!(response.usage.expect("usage").total_tokens, 19); + } + + #[test] + fn codex_cli_mode_maps_plain_final_message_and_requires_terminal_event() { + let request = LlmRunRequest::single_turn("系统", "任务"); + let stdout = concat!( + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"完成\"}}\n", + "{\"type\":\"turn.completed\",\"usage\":{}}\n" + ); + let response = parse_game_creator_codex_cli_response(stdout.as_bytes(), &request) + .expect("parse response"); + assert_eq!(response.text, "完成"); + assert!(response.tool_calls.is_empty()); + assert!(matches!( + parse_game_creator_codex_cli_response( + b"{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"x\"}}\n", + &request + ), + Err(platform_llm::LlmError::Deserialize(_)) + )); + + let recovered_stdout = concat!( + "{\"type\":\"error\",\"message\":\"Reconnecting\"}\n", + "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"恢复成功\"}}\n", + "{\"type\":\"turn.completed\",\"usage\":{}}\n" + ); + assert_eq!( + parse_game_creator_codex_cli_response(recovered_stdout.as_bytes(), &request) + .expect("短暂 error 事件后完成") + .text, + "恢复成功" + ); + } + + #[test] + fn codex_cli_mode_preserves_failed_terminal_and_invalid_json_details() { + let request = LlmRunRequest::single_turn("系统", "任务"); + let failed = parse_game_creator_codex_cli_response( + b"{\"type\":\"turn.failed\",\"error\":{\"message\":\"HTTP 502 upstream overloaded\",\"api_key\":\"fixture-secret\"}}\n", + &request, + ) + .expect_err("failed terminal must be returned as an error"); + let failed_text = failed.to_string(); + assert!(failed_text.contains("HTTP 502 upstream overloaded")); + assert!(!failed_text.contains("fixture-secret")); + + let failed_without_message = parse_game_creator_codex_cli_response( + b"{\"type\":\"turn.failed\",\"code\":\"EPIPE\",\"status\":\"closed\"}\n", + &request, + ) + .expect_err("failed terminal without message must retain event detail"); + let failed_without_message = failed_without_message.to_string(); + assert!(failed_without_message.contains("EPIPE")); + assert!(failed_without_message.contains("closed")); + + let invalid = parse_game_creator_codex_cli_response( + b"{not-json Authorization: Bearer fixture-secret}\n", + &request, + ) + .expect_err("invalid JSONL must preserve a bounded safe line detail"); + let invalid_text = invalid.to_string(); + assert!(invalid_text.contains("JSONL 第 1 行无效")); + assert!(invalid_text.contains("原文=")); + assert!(!invalid_text.contains("fixture-secret")); + } + + #[test] + fn codex_cli_mode_preserves_missing_terminal_and_structured_output_details() { + let request = tool_request(); + let missing_terminal = parse_game_creator_codex_cli_response( + b"{\"type\":\"item.completed\",\"item\":{\"type\":\"error\",\"message\":\"IPC EPIPE\"}}\n", + &request, + ) + .expect_err("missing terminal must retain the last protocol event"); + let missing_terminal = missing_terminal.to_string(); + assert!(missing_terminal.contains("缺少 turn.completed 终态")); + assert!(missing_terminal.contains("IPC EPIPE")); + + let malformed_event = serde_json::json!({ + "type": "item.completed", + "item": { "type": "agent_message", "text": r#"{"toolCalls":["# } + }); + let malformed_stdout = format!( + "{}\n{{\"type\":\"turn.completed\"}}\n", + serde_json::to_string(&malformed_event).expect("serialize malformed event") + ); + let malformed_structured = + parse_game_creator_codex_cli_response(malformed_stdout.as_bytes(), &request) + .expect_err("malformed structured output must retain parse detail"); + let malformed_structured = malformed_structured.to_string(); + assert!(malformed_structured.contains("structured output JSON 无效")); + assert!(malformed_structured.contains("line")); + assert!(malformed_structured.contains("toolCalls")); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_cli_mode_builds_ephemeral_json_exec_with_prompt_on_stdin() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex"); + std::fs::write( + &executable, + r#"#!/bin/sh +prompt="$(cat)" +case "$prompt" in + *"CODEx_STDIN_MARKER"*) ;; + *) exit 41 ;; +esac +has_exec=0 +has_json=0 +has_ephemeral=0 +has_ignore_user_config=0 +has_read_only=0 +has_shell_disabled=0 +previous="" +for argument in "$@"; do + [ "$argument" = "exec" ] && has_exec=1 + [ "$argument" = "--json" ] && has_json=1 + [ "$argument" = "--ephemeral" ] && has_ephemeral=1 + [ "$argument" = "--ignore-user-config" ] && has_ignore_user_config=1 + [ "$previous:$argument" = "--sandbox:read-only" ] && has_read_only=1 + [ "$previous:$argument" = "--disable:shell_tool" ] && has_shell_disabled=1 + previous="$argument" +done +[ "$has_exec$has_json$has_ephemeral$has_ignore_user_config$has_read_only$has_shell_disabled" = "111111" ] || exit 42 +printf '%s\n' '{"type":"thread.started","thread_id":"fake-thread"}' +printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"fixture-ok"}}' +printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens":2}}' +"#, + ) + .expect("write fake codex"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake codex"); + + let request = LlmRunRequest::single_turn("系统", "CODEx_STDIN_MARKER"); + let response = + request_game_creator_agent_codex_cli_with_executable(executable.as_os_str(), request) + .await + .expect("fake codex request"); + assert_eq!(response.text, "fixture-ok"); + assert_eq!(response.response_id.as_deref(), Some("fake-thread")); + assert_eq!(response.usage.expect("usage").total_tokens, 5); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_cli_mode_preserves_sanitized_process_stderr_on_failure() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-failure"); + std::fs::write( + &executable, + "#!/bin/sh\nprintf '%s\\n' 'Authorization: Bearer secret-auth-detail; out of memory (ENOMEM)' >&2\nexit 43\n", + ) + .expect("write fake codex"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake codex"); + + let error = request_game_creator_agent_codex_cli_with_executable( + executable.as_os_str(), + LlmRunRequest::single_turn("系统", "任务"), + ) + .await + .expect_err("fake Codex should fail") + .to_string(); + assert!(error.contains("code=43")); + assert!(error.contains("stderrClass=nonempty")); + assert!(error.contains("stderrBytes=")); + assert!(error.contains("stderrSha256=")); + assert!(error.contains("out of memory (ENOMEM)")); + assert!(error.contains("[redacted-secret]")); + assert!(error.contains("登录状态")); + assert!(!error.contains("Bearer secret-auth-detail")); + } + + #[cfg(unix)] + #[tokio::test] + async fn codex_cli_mode_enforces_runtime_request_timeout() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-timeout"); + std::fs::write(&executable, "#!/bin/sh\ncat >/dev/null\nsleep 30\n") + .expect("write fake codex"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake codex"); + + let error = request_game_creator_agent_codex_cli_with_executable( + executable.as_os_str(), + LlmRunRequest::single_turn("系统", "任务").with_request_timeout_ms(20), + ) + .await + .expect_err("fake Codex should time out"); + assert_eq!(error, platform_llm::LlmError::Timeout { attempts: 1 }); + } + + #[tokio::test] + #[ignore = "requires an installed and authenticated Codex CLI"] + async fn codex_cli_real_smoke_uses_existing_local_auth() { + let request = LlmRunRequest::single_turn( + "只回复指定文本,不使用任何工具。", + "请只回复 CODEX_CLI_SMOKE_OK", + ); + let response = request_game_creator_agent_codex_cli(request) + .await + .expect("real Codex CLI request"); + assert_eq!(response.text.trim(), "CODEX_CLI_SMOKE_OK"); + assert!(response.tool_calls.is_empty()); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs index bdc90be02..4d45af75d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs @@ -147,11 +147,11 @@ fn normalize_codex_provider_upstream(value: &str) -> Result { Ok(value.to_string()) } -fn proxy_error(status: StatusCode, message: &'static str) -> Response { +fn proxy_error(status: StatusCode, message: impl Into) -> Response { Response::builder() .status(status) .header("content-type", "text/plain; charset=utf-8") - .body(Body::from(message)) + .body(message.into()) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -175,18 +175,19 @@ fn is_codex_account_limit_header(name: &HeaderName) -> bool { name.as_str().to_ascii_lowercase().starts_with("x-codex-") } -fn parallel_direct_request(body: &[u8]) -> Result, &'static str> { - let mut value: serde_json::Value = - serde_json::from_slice(body).map_err(|_| "provider request JSON invalid")?; +fn parallel_direct_request(body: &[u8]) -> Result, String> { + let mut value: serde_json::Value = serde_json::from_slice(body) + .map_err(|error| format!("provider request JSON invalid:{error}"))?; let object = value .as_object_mut() - .ok_or("provider request must be an object")?; + .ok_or_else(|| "provider request must be an object".to_string())?; // SDK 的未知模型回退目录默认关闭并行。Direct 的宿主已负责许可、依赖和冲突, // 因此明确请求标准 Responses 并行能力;不伪造模型名称或工具的只读标记。 object.insert("parallel_tool_calls".into(), serde_json::Value::Bool(true)); - let bytes = serde_json::to_vec(&value).map_err(|_| "provider request JSON invalid")?; + let bytes = serde_json::to_vec(&value) + .map_err(|error| format!("provider request JSON serialization failed:{error}"))?; if bytes.len() > CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES { - return Err("provider request too large"); + return Err("provider request too large".to_string()); } Ok(bytes) } @@ -233,15 +234,21 @@ async fn proxy_codex_provider_request( let (parts, body) = request.into_parts(); let body = match to_bytes(body, CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES).await { Ok(body) => body, - Err(_) => return proxy_error(StatusCode::PAYLOAD_TOO_LARGE, "provider request too large"), + Err(error) => { + return proxy_error( + StatusCode::PAYLOAD_TOO_LARGE, + format!("provider request body read failed:{error}"), + ) + } }; let body = if state.parallel_tool_calls { match tokio::task::spawn_blocking(move || parallel_direct_request(&body)).await { Ok(Ok(bytes)) => axum::body::Bytes::from(bytes), - _ => { + Ok(Err(error)) => return proxy_error(StatusCode::BAD_REQUEST, error), + Err(error) => { return proxy_error( - StatusCode::BAD_REQUEST, - "provider request JSON invalid or oversized", + StatusCode::INTERNAL_SERVER_ERROR, + format!("provider request rewrite task failed:{error}"), ) } } @@ -264,10 +271,10 @@ async fn proxy_codex_provider_request( } let upstream_authorization = match format!("Bearer {}", state.upstream_bearer_token).parse() { Ok(value) => value, - Err(_) => { + Err(error) => { return proxy_error( StatusCode::INTERNAL_SERVER_ERROR, - "provider proxy credential invalid", + format!("provider proxy credential invalid:{error}"), ) } }; @@ -281,7 +288,12 @@ async fn proxy_codex_provider_request( .await { Ok(response) => response, - Err(_) => return proxy_error(StatusCode::BAD_GATEWAY, "provider upstream unavailable"), + Err(error) => { + return proxy_error( + StatusCode::BAD_GATEWAY, + format!("provider upstream unavailable:{error}"), + ) + } }; let status = upstream.status(); let upstream_headers = upstream.headers().clone(); @@ -310,7 +322,12 @@ async fn proxy_codex_provider_request( } response .body(Body::from_stream(stream)) - .unwrap_or_else(|_| proxy_error(StatusCode::BAD_GATEWAY, "provider response invalid")) + .unwrap_or_else(|error| { + proxy_error( + StatusCode::BAD_GATEWAY, + format!("provider response invalid:{error}"), + ) + }) } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 3f8c99995..40afd5dad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -1247,8 +1247,17 @@ async fn finish_design_command( // panic 负载可能包含路径或内容片段,公开文案固定;位置和负载由 panic hook 写进私有 // design_debug(task-local 提供项目根),不进入用户可见消息。 -fn design_panic_error(_payload: Box) -> String { - DESIGN_PANIC_PUBLIC_ERROR.to_string() +fn design_panic_error(payload: Box) -> String { + let raw = payload + .downcast_ref::<&str>() + .map(|value| (*value).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_default(); + let detail = redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 600); + if detail.trim().is_empty() { + return DESIGN_PANIC_PUBLIC_ERROR.to_string(); + } + format!("策划运行发生内部错误:{detail};本轮已中断,可直接重试;若反复出现请反馈。") } #[cfg(test)] @@ -1680,7 +1689,9 @@ pub(crate) async fn continue_design_agent_session( input, capture, |event| { - let _ = app.emit("design-agent-update", event); + if let Err(error) = app.emit("design-agent-update", event) { + app_log!("design_agent.update.emit_failed detail={error}"); + } }, ) .await @@ -1707,7 +1718,9 @@ pub(crate) async fn decide_design_phase( approved, capture, |event| { - let _ = app.emit("design-agent-update", event); + if let Err(error) = app.emit("design-agent-update", event) { + app_log!("design_agent.update.emit_failed detail={error}"); + } }, ) .await @@ -2808,10 +2821,9 @@ mod tests { .expect("panic 必须转成可恢复视图而不是向上传播"); assert!(!view.running); assert!(view.can_retry); - assert_eq!( - view.session.last_error.as_deref(), - Some(DESIGN_PANIC_PUBLIC_ERROR) - ); + let last_error = view.session.last_error.as_deref().expect("panic detail"); + assert!(last_error.contains("注入的策划工具 panic"), "{last_error}"); + assert!(last_error.contains("本轮已中断"), "{last_error}"); let session = read_design_session(&root) .expect("read session") .expect("session exists"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 5dc6c824c..796dbdd21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -499,7 +499,7 @@ pub(crate) fn list_design_workspace_files( Ok(entries) => entries .collect::, _>>() .map_err(|error| format!("读取工作区失败:{error}"))?, - Err(_) => continue, + Err(error) => return Err(format!("读取工作区目录失败:{error}")), }; entries.sort_by_key(|entry| entry.file_name()); for entry in entries { @@ -551,7 +551,7 @@ pub(crate) fn import_design_workspace_file( ) -> Result { let root = PathBuf::from(project_path.trim()); let relative_path = import_design_workspace_file_at(&root, &file_name, &bytes)?; - let _ = app.emit( + if let Err(error) = app.emit( "design-agent-update", serde_json::json!({ "projectPath": root.to_string_lossy(), @@ -561,7 +561,9 @@ pub(crate) fn import_design_workspace_file( "text": null, "view": null, }), - ); + ) { + app_log!("design_agent.workspace.emit_failed detail={error}"); + } Ok(relative_path) } @@ -881,9 +883,12 @@ fn search_design_text( return Ok(()); } if path.is_file() { - let Ok(text) = fs::read_to_string(path) else { - return Ok(()); - }; + let text = fs::read_to_string(path).map_err(|error| { + format!( + "搜索工作区文件失败:{}:{error}", + design_tool_location(root, path) + ) + })?; let display = design_tool_location(root, path); for (index, line) in text.lines().enumerate() { if line.contains(query) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs index 5cd95eab2..c8e365157 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs @@ -39,7 +39,7 @@ pub(super) async fn update_plan( input: &Value, ) -> Result { let mut plan: DirectPlanInput = serde_json::from_value(input.clone()) - .map_err(|_| "direct-plan-invalid: 计划只接受explanation与plan(step,status)")?; + .map_err(|error| format!("direct-plan-invalid: 计划参数解析失败:{error}"))?; if plan.plan.is_empty() || plan.plan.len() > 32 { return Err("direct-plan-invalid: 计划需包含1到32步".into()); } @@ -58,11 +58,12 @@ pub(super) async fn update_plan( } *explanation = truncate_agent_runtime_text(explanation, 1200); } - let value = serde_json::to_value(plan).map_err(|_| "direct-plan-invalid")?; + let value = + serde_json::to_value(plan).map_err(|error| format!("direct-plan-invalid:{error}"))?; let session = Arc::clone(session); tokio::task::spawn_blocking(move || session.update_plan(value)) .await - .map_err(|_| "direct-plan-worker-exited")? + .map_err(|error| format!("direct-plan-worker-exited:{error}"))? } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -199,13 +200,13 @@ struct InitialDelivery { fn initial_delivery_path(root: &Path) -> Result { let root = root .canonicalize() - .map_err(|_| "delivery-project-unavailable")?; + .map_err(|error| format!("delivery-project-unavailable:{error}"))?; let host = crate::game_creator_runtime_config_dir() .ok_or("delivery-host-unavailable: 缺少客户端私有目录")?; crate::ensure_game_creator_private_directory_tree(&host, "客户端交付目录")?; let host = host .canonicalize() - .map_err(|_| "delivery-host-unavailable")?; + .map_err(|error| format!("delivery-host-unavailable:{error}"))?; if host.starts_with(&root) { return Err("delivery-host-invalid: 权威交付状态不能位于项目内".into()); } @@ -220,14 +221,14 @@ fn initial_delivery_path(root: &Path) -> Result { fn read_initial_delivery(path: &Path, project_id: &str) -> Result, String> { if !path .try_exists() - .map_err(|_| "delivery-initial-state-unavailable")? + .map_err(|error| format!("delivery-initial-state-unavailable:{error}"))? { return Ok(None); } let value: InitialDelivery = serde_json::from_str( &crate::read_game_creator_private_file_to_string(path, "首次交付状态", 16 * 1024)?, ) - .map_err(|_| "delivery-initial-state-invalid")?; + .map_err(|error| format!("delivery-initial-state-invalid:{error}"))?; if value.schema_version != "agc-initial-web-delivery.v1" || value.project_id != project_id { return Err("delivery-initial-state-identity".into()); } @@ -249,7 +250,7 @@ fn is_new_web_delivery(root: &Path) -> Result { let receipt = resolve_local_project_path(root, ".agent/runtime/web-scaffold-preparation.json")?; if !receipt .try_exists() - .map_err(|_| "delivery-scaffold-receipt-unavailable")? + .map_err(|error| format!("delivery-scaffold-receipt-unavailable:{error}"))? { return Ok(false); } @@ -257,12 +258,12 @@ fn is_new_web_delivery(root: &Path) -> Result { let mut bytes = Vec::new(); file.take(16 * 1024 + 1) .read_to_end(&mut bytes) - .map_err(|_| "delivery-scaffold-receipt-unavailable")?; + .map_err(|error| format!("delivery-scaffold-receipt-unavailable:{error}"))?; if bytes.len() > 16 * 1024 { return Err("delivery-scaffold-receipt-invalid".into()); } - let receipt: Value = - serde_json::from_slice(&bytes).map_err(|_| "delivery-scaffold-receipt-invalid")?; + let receipt: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("delivery-scaffold-receipt-invalid:{error}"))?; let expected: BTreeMap<_, _> = crate::project::trusted_web_scaffold_files() .into_iter() .map(|(path, content)| (path, format!("{:x}", Sha256::digest(content.as_bytes())))) @@ -270,7 +271,8 @@ fn is_new_web_delivery(root: &Path) -> Result { if receipt["schemaVersion"] != "agc-web-scaffold-preparation.v1" || receipt["projectId"] != project.project_id || receipt["templateHashes"] - != serde_json::to_value(expected).map_err(|_| "delivery-scaffold-template-invalid")? + != serde_json::to_value(expected) + .map_err(|error| format!("delivery-scaffold-template-invalid:{error}"))? { return Err("delivery-scaffold-receipt-invalid".into()); } @@ -281,7 +283,7 @@ fn is_new_web_delivery(root: &Path) -> Result { project_id: project.project_id, completed: false, }) - .map_err(|_| "delivery-initial-state-invalid")?, + .map_err(|error| format!("delivery-initial-state-invalid:{error}"))?, "首次交付状态", )?; Ok(true) @@ -302,7 +304,7 @@ fn mark_initial_delivered(root: &Path, ledger: &ExecutionLedger) -> Result<(), S project_id: ledger.project_id.clone(), completed: true, }) - .map_err(|_| "delivery-initial-state-invalid")?, + .map_err(|error| format!("delivery-initial-state-invalid:{error}"))?, "首次交付状态", ) } @@ -343,7 +345,7 @@ fn evidence_file_hash(root: &Path, relative: &str) -> Result { loop { let count = file .read(&mut buffer) - .map_err(|_| "delivery-artifact-read")?; + .map_err(|error| format!("delivery-artifact-read:{error}"))?; if count == 0 { break; } @@ -409,8 +411,8 @@ fn assess(root: &Path, ledger: &ExecutionLedger) -> Result { checks: vec![json!({"id":"contract","passed":false,"detail":"尚未登记本轮验收合同"})], }); }; - let contract: FrozenContract = - serde_json::from_value(value.clone()).map_err(|_| "delivery-frozen-contract-invalid")?; + let contract: FrozenContract = serde_json::from_value(value.clone()) + .map_err(|error| format!("delivery-frozen-contract-invalid:{error}"))?; if contract.schema_version != CONTRACT_SCHEMA { return Err("delivery-frozen-contract-invalid".into()); } @@ -449,8 +451,8 @@ fn assess(root: &Path, ledger: &ExecutionLedger) -> Result { }) } -pub(super) fn terminal_report(session: &Arc) -> Option { - session.snapshot().ok()?.terminal_report +pub(super) fn terminal_report(session: &Arc) -> Result, String> { + session.snapshot().map(|snapshot| snapshot.terminal_report) } pub(super) async fn status(root: &Path, session: &Arc) -> Result { @@ -477,7 +479,7 @@ pub(super) async fn try_seal(root: &Path, session: &Arc) -> Re session.begin_sealing(ledger.revision) }) .await - .map_err(|_| "delivery-seal-worker-exited")? + .map_err(|error| format!("delivery-seal-worker-exited:{error}"))? } pub(super) async fn finish_sealing( @@ -503,7 +505,7 @@ pub(super) async fn finish_sealing( session.complete(report.clone())?; if mark_initial_delivered(&root,&ledger).is_err() { app_log!("首次交付标记未写入,后续保持更严格的新项目验收要求"); } Ok(Some(report)) - }).await.map_err(|_| "delivery-finalize-worker-exited")? + }).await.map_err(|error| format!("delivery-finalize-worker-exited:{error}"))? } /// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。 @@ -514,15 +516,28 @@ pub(super) async fn review_reply( root: &Path, session: &Arc, ) -> Result, TurnError> { - if let Some(report) = terminal_report(session) { + let existing_report = terminal_report(session).map_err(|error| { + TurnError::turn_failed( + FailureStage::CodeGeneration, + format!("读取交付终态失败:{error}"), + ) + })?; + if let Some(report) = existing_report { return Ok(Some(report)); } let ledger = session.snapshot()?; if ledger.contract.is_none() { let finished = session.finish_without_contract(); // 关闭失败时优先呈现已持久化的终态报告,保留真实失败原因。 - if let Some(report) = terminal_report(session) { - return Ok(Some(report)); + match terminal_report(session) { + Ok(Some(report)) => return Ok(Some(report)), + Ok(None) => {} + Err(error) => { + return Err(TurnError::turn_failed( + FailureStage::CodeGeneration, + format!("读取交付终态失败:{error}"), + )) + } } finished?; return Ok(None); @@ -531,16 +546,23 @@ pub(super) async fn review_reply( if let Some(report) = finish_sealing(root, session).await? { return Ok(Some(report)); } - if let Some(report) = terminal_report(session) { + let terminal_report = terminal_report(session).map_err(|error| { + TurnError::turn_failed( + FailureStage::CodeGeneration, + format!("读取交付终态失败:{error}"), + ) + })?; + if let Some(report) = terminal_report { return Ok(Some(report)); } } let review_session = Arc::clone(session); let review = tokio::task::spawn_blocking(move || review_session.record_delivery_review()) .await - .map_err(|_| "delivery-review-worker-exited")??; + .map_err(|error| format!("delivery-review-worker-exited:{error}"))??; let assessment = status(root, session).await?; - let detail = serde_json::to_string(&assessment).map_err(|_| "delivery-review-invalid")?; + let detail = serde_json::to_string(&assessment) + .map_err(|error| format!("delivery-review-invalid:{error}"))?; if review >= ledger.max_runs { let missing = assessment .pointer("/assessment/checks") @@ -567,7 +589,7 @@ pub(super) async fn review_reply( let saved = report.clone(); tokio::task::spawn_blocking(move || session.interrupt(saved)) .await - .map_err(|_| "delivery-review-worker-exited")??; + .map_err(|error| format!("delivery-review-worker-exited:{error}"))??; return Ok(Some(report)); } Err(TurnError::ReviewRequired { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs index 59d53da20..fe3066e91 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs @@ -78,10 +78,16 @@ pub(crate) fn emit_direct_active_turns_changed() { let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; }; - let _ = app.emit( + if let Err(error) = app.emit( DIRECT_ACTIVE_TURNS_CHANGED_EVENT, serde_json::json!({ "revision": revision }), - ); + ) { + app_log!( + "agent.direct_events.emit_failed event={} revision={} detail={error}", + DIRECT_ACTIVE_TURNS_CHANGED_EVENT, + revision + ); + } } #[cfg(test)] @@ -93,14 +99,19 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; }; - let _ = app.emit( + if let Err(error) = app.emit( "game-creator-agent-progress", GameCreatorAgentProgressEvent { project_path: root.to_string_lossy().into_owned(), stage: stage.to_string(), message: message.to_string(), }, - ); + ) { + app_log!( + "agent.direct_events.emit_failed event=game-creator-agent-progress stage={} detail={error}", + stage + ); + } } #[derive(Clone)] @@ -209,7 +220,11 @@ pub(crate) fn start_game_creator_manifest_invalidation_event_sink( if envelope.token != expected_token { continue; } - let _ = app.emit("game-creator-manifest-invalidated", envelope.event); + if let Err(error) = app.emit("game-creator-manifest-invalidated", envelope.event) { + app_log!( + "agent.direct_events.emit_failed event=game-creator-manifest-invalidated detail={error}" + ); + } } }) .map_err(|error| format!("启动 manifest 失效事件接收端失败:{error}"))?; @@ -351,7 +366,11 @@ pub(crate) fn emit_game_creator_manifest_invalidated(root: &Path, agent_id: &str agent_id: agent_id.to_string(), }; if let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() { - let _ = app.emit("game-creator-manifest-invalidated", event); + if let Err(error) = app.emit("game-creator-manifest-invalidated", event) { + app_log!( + "agent.direct_events.emit_failed event=game-creator-manifest-invalidated detail={error}" + ); + } return; } let _ = relay_game_creator_manifest_invalidation(root, agent_id); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs index 8ea601335..64a699fa5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs @@ -171,11 +171,11 @@ struct CodexExecutorIdentity { fn executor_digest(path: &Path) -> Result { use std::io::Read; - let mut file = - File::open(path).map_err(|_| "direct-execution-executor: 无法读取已选择的执行器")?; + let mut file = File::open(path) + .map_err(|error| format!("direct-execution-executor: 无法读取已选择的执行器:{error}"))?; let before = file .metadata() - .map_err(|_| "direct-execution-executor: 执行器身份不可用")?; + .map_err(|error| format!("direct-execution-executor: 执行器身份不可用:{error}"))?; if !before.is_file() || before.len() > 512 * 1024 * 1024 { return Err("direct-execution-executor: 执行器类型或大小无效".into()); } @@ -185,7 +185,7 @@ fn executor_digest(path: &Path) -> Result { loop { let read = file .read(&mut buffer) - .map_err(|_| "direct-execution-executor: 执行器读取失败")?; + .map_err(|error| format!("direct-execution-executor: 执行器读取失败:{error}"))?; if read == 0 { break; } @@ -197,7 +197,7 @@ fn executor_digest(path: &Path) -> Result { } let after = file .metadata() - .map_err(|_| "direct-execution-executor: 执行器身份不可用")?; + .map_err(|error| format!("direct-execution-executor: 执行器身份不可用:{error}"))?; if bytes != before.len() || after.len() != before.len() || before.modified().ok() != after.modified().ok() @@ -421,7 +421,7 @@ fn closed_error(phase: ExecutionPhase) -> String { pub(super) fn current(root: &Path) -> Result, String> { let root = root .canonicalize() - .map_err(|_| "direct-execution-project: 项目不可用")?; + .map_err(|error| format!("direct-execution-project: 项目不可用:{error}"))?; sessions() .lock() .map_err(|_| "direct-execution-state: 会话状态不可用")? @@ -468,7 +468,7 @@ pub(super) async fn begin( ) }) .await - .map_err(|_| "direct-execution-start: 宿主状态初始化退出")??; + .map_err(|error| format!("direct-execution-start: 宿主状态初始化退出:{error}"))??; let root = session.root.clone(); { let mut registry = sessions() @@ -504,7 +504,7 @@ pub(super) fn open_with_analytics_at( config.validate()?; let root = root .canonicalize() - .map_err(|_| "direct-execution-project: 项目不可用")?; + .map_err(|error| format!("direct-execution-project: 项目不可用:{error}"))?; if turn.is_empty() || turn.len() > 512 || request_hash.len() != 64 { return Err("direct-execution-identity: 回合身份无效".into()); } @@ -512,17 +512,17 @@ pub(super) fn open_with_analytics_at( return Err("direct-execution-host: 权威状态不得写入模型项目目录".into()); } crate::ensure_game_creator_private_directory_tree(host, "宿主执行状态目录") - .map_err(|_| "direct-execution-host: 无法准备私有执行状态目录")?; + .map_err(|error| format!("direct-execution-host: 无法准备私有执行状态目录:{error}"))?; let host = host .canonicalize() - .map_err(|_| "direct-execution-host: 状态目录不可用")?; + .map_err(|error| format!("direct-execution-host: 状态目录不可用:{error}"))?; if host.starts_with(&root) { return Err("direct-execution-host: 权威状态不得写入模型项目目录".into()); } let project_key = hash(root.to_string_lossy().as_bytes()); let directory = host.join(&project_key); crate::ensure_game_creator_private_directory_tree(&directory, "宿主项目执行状态") - .map_err(|_| "direct-execution-host: 无法准备私有项目执行状态")?; + .map_err(|error| format!("direct-execution-host: 无法准备私有项目执行状态:{error}"))?; let key = hash(turn.as_bytes()); let lock_path = directory.join(format!("{key}.lock")); if lock_path.exists() { @@ -535,7 +535,7 @@ pub(super) fn open_with_analytics_at( .create(true) .truncate(false) .open(&lock_path) - .map_err(|_| "direct-execution-owner: 无法打开执行归属锁")?; + .map_err(|error| format!("direct-execution-owner: 无法打开执行归属锁:{error}"))?; owner .try_lock() .map_err(|_| "direct-execution-active: 同一回合仍由其他执行器持有")?; @@ -728,7 +728,7 @@ impl ExecutionSession { } next.revision = data.ledger.revision.saturating_add(1); let bytes = serde_json::to_vec(&next) - .map_err(|_| "direct-execution-persistence: 状态序列化失败")?; + .map_err(|error| format!("direct-execution-persistence: 状态序列化失败:{error}"))?; if bytes.len() > MAX_STATE_BYTES { return Err("direct-execution-capacity: 宿主状态超过保留上限".into()); } @@ -786,7 +786,10 @@ impl ExecutionSession { } pub(super) fn update_plan(&self, plan: Value) -> Result { if !plan.is_object() - || serde_json::to_vec(&plan).map_err(|_| "计划格式无效")?.len() > 32 * 1024 + || serde_json::to_vec(&plan) + .map_err(|error| format!("计划格式无效:{error}"))? + .len() + > 32 * 1024 { return Err("direct-plan-invalid: 计划必须为有界对象".into()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs index a4d2ba17c..85f841ffd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs @@ -191,42 +191,60 @@ fn validate_argv(executable: &Path, patch: &str) -> Result<(), String> { Ok(()) } -fn target_fingerprints(targets: &[PatchTarget]) -> BTreeMap> { - targets - .iter() - .map(|target| { - let digest = (|| { - match std::fs::symlink_metadata(&target.absolute) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Some("missing".into()) - } - Ok(_) => {} - Err(_) => return None, - } +fn target_fingerprints( + targets: &[PatchTarget], +) -> Result>, String> { + let mut fingerprints = BTreeMap::new(); + for target in targets { + let digest = match std::fs::symlink_metadata(&target.absolute) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some("missing".into()), + Ok(_) => { let (mut file, metadata) = - open_project_snapshot_regular_file(&target.absolute, "补丁指纹").ok()?; + open_project_snapshot_regular_file(&target.absolute, "补丁指纹").map_err( + |error| format!("补丁指纹读取失败:{}:{error}", target.relative), + )?; if metadata.len() > TARGET_HASH_MAX_BYTES { - return None; + return Err(format!( + "补丁指纹读取失败:目标文件 {} 大小 {} bytes,超过 {} bytes 上限", + target.relative, + metadata.len(), + TARGET_HASH_MAX_BYTES + )); } let mut digest = Sha256::new(); let mut buffer = [0u8; 16 * 1024]; let mut total = 0u64; loop { - let count = file.read(&mut buffer).ok()?; + let count = file.read(&mut buffer).map_err(|error| { + format!( + "补丁指纹读取失败:{}:读取文件正文失败:{error}", + target.relative + ) + })?; if count == 0 { break; } total += count as u64; if total > TARGET_HASH_MAX_BYTES { - return None; + return Err(format!( + "补丁指纹读取失败:目标文件 {} 在读取期间超过 {} bytes 上限", + target.relative, TARGET_HASH_MAX_BYTES + )); } digest.update(&buffer[..count]); } Some(format!("file:{:x}", digest.finalize())) - })(); - (target.relative.clone(), digest) - }) - .collect() + } + Err(error) => { + return Err(format!( + "补丁指纹检查失败:{}:读取文件元数据失败:{error}", + target.relative + )); + } + }; + fingerprints.insert(target.relative.clone(), digest); + } + Ok(fingerprints) } fn analytics_patch_changes( @@ -278,7 +296,7 @@ fn run_transaction( let targets = collect_targets(root, &parsed.hunks)?; let executable = session.codex_executor()?; validate_argv(&executable, &parsed.patch)?; - let before = target_fingerprints(&targets); + let before = target_fingerprints(&targets)?; let operation = session.admit(EffectKind::Write, None)?; let process = runtime.block_on(crate::command_exec::run_owned_codex_patch_at( root, @@ -323,7 +341,7 @@ fn run_transaction( ) } }; - let after = target_fingerprints(&targets); + let after = target_fingerprints(&targets)?; let changed_paths = targets .iter() .filter(|target| { @@ -436,6 +454,44 @@ mod tests { (temp, root.canonicalize().unwrap()) } + #[test] + fn target_fingerprints_keep_missing_distinct_and_report_read_failures() { + let (_temp, root) = project(); + let missing = PatchTarget { + relative: "game/missing.txt".into(), + absolute: root.join("game/missing.txt"), + }; + let fingerprints = target_fingerprints(std::slice::from_ref(&missing)).unwrap(); + assert_eq!( + fingerprints.get("game/missing.txt"), + Some(&Some("missing".into())) + ); + + let directory = root.join("game/fingerprint-directory"); + std::fs::create_dir_all(&directory).unwrap(); + let directory_target = PatchTarget { + relative: "game/fingerprint-directory".into(), + absolute: directory, + }; + let error = target_fingerprints(std::slice::from_ref(&directory_target)).unwrap_err(); + assert!(error.contains("补丁指纹读取失败"), "{error}"); + assert!( + error.contains("打开补丁指纹失败") || error.contains("必须是普通文件"), + "{error}" + ); + + let oversized = root.join("game/fingerprint-oversized.bin"); + let file = std::fs::File::create(&oversized).unwrap(); + file.set_len(TARGET_HASH_MAX_BYTES + 1).unwrap(); + let oversized_target = PatchTarget { + relative: "game/fingerprint-oversized.bin".into(), + absolute: oversized, + }; + let error = target_fingerprints(std::slice::from_ref(&oversized_target)).unwrap_err(); + assert!(error.contains("超过"), "{error}"); + assert!(error.contains("补丁指纹读取失败"), "{error}"); + } + #[test] fn parser_paths_include_every_move_source_and_destination_before_any_write() { let (_temp, root) = project(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs index cf06f63b0..b4c30424b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs @@ -52,7 +52,9 @@ struct ContextIdentity { fn context_identity(root: &Path) -> Result { let manifest = read_existing_manifest_for_project(root)?; let revision = read_game_creator_agent_runtime_project_revision(root)?.revision; - let canonical = root.canonicalize().map_err(|_| "项目上下文目录无法解析")?; + let canonical = root + .canonicalize() + .map_err(|error| format!("项目上下文目录无法解析:{error}"))?; let active = list_active_turns()?.into_iter().find(|turn| { Path::new(&turn.project_path).canonicalize().ok().as_ref() == Some(&canonical) }); @@ -194,7 +196,7 @@ pub(super) fn external_read_is_protected(display: &str) -> bool { } /// O_NOFOLLOW 只保护末段;项目外路径还必须逐段拒绝目录链接与 Windows 重解析点。 -pub(super) fn reject_external_read_links(path: &Path) -> Result<(), &'static str> { +pub(super) fn reject_external_read_links(path: &Path) -> Result<(), String> { let mut current = PathBuf::new(); for component in path.components() { current.push(component.as_os_str()); @@ -202,9 +204,15 @@ pub(super) fn reject_external_read_links(path: &Path) -> Result<(), &'static str if matches!(component, std::path::Component::Prefix(_)) { continue; } - let metadata = std::fs::symlink_metadata(¤t).map_err(|_| "file-not-found")?; + let metadata = std::fs::symlink_metadata(¤t).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + "file-not-found".to_string() + } else { + format!("读取路径元数据失败:{error}") + } + })?; if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { - return Err("linked-path"); + return Err("linked-path".to_string()); } } Ok(()) @@ -237,50 +245,53 @@ fn normalize_requests(root: &Path, arguments: &Value) -> Result Ok(files) } -fn bounded_bytes(root: &Path, raw: &str) -> Result, &'static str> { - let target = resolve_game_agent_read_path(root, raw).map_err(|_| "unsafe-project-path")?; +fn bounded_bytes(root: &Path, raw: &str) -> Result, String> { + let target = + resolve_game_agent_read_path(root, raw).map_err(|_| "unsafe-project-path".to_string())?; let path = if let Some(relative) = target.project_relative.as_deref() { if relative.is_empty() { - return Err("not-safe-regular-file"); + return Err("not-safe-regular-file".to_string()); } - reject_agent_runtime_private_control_path(relative).map_err(|_| "private-control-path")?; - reject_sensitive_project_file_read(relative).map_err(|_| "sensitive-path")?; + reject_agent_runtime_private_control_path(relative) + .map_err(|_| "private-control-path".to_string())?; + reject_sensitive_project_file_read(relative).map_err(|_| "sensitive-path".to_string())?; if should_skip_project_snapshot_path(relative) { - return Err("excluded-project-path"); + return Err("excluded-project-path".to_string()); } // 检查每段目录,避免父目录 junction/symlink 绕过叶子 O_NOFOLLOW。 let mut component = root.to_path_buf(); for segment in relative.split('/') { component.push(segment); - let metadata = std::fs::symlink_metadata(&component).map_err(|_| "file-not-found")?; + let metadata = std::fs::symlink_metadata(&component) + .map_err(|error| format!("file-not-found:{error}"))?; if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { - return Err("linked-path"); + return Err("linked-path".to_string()); } } component } else { if external_read_is_protected(&target.display) { - return Err("sensitive-path"); + return Err("sensitive-path".to_string()); } reject_external_read_links(&target.absolute)?; target.absolute }; let (file, metadata) = open_project_snapshot_regular_file(&path, "项目批量读取") - .map_err(|_| "not-safe-regular-file")?; + .map_err(|error| format!("not-safe-regular-file:{error}"))?; if metadata.len() > MAX_FILE_BYTES as u64 { - return Err("file-too-large"); + return Err("file-too-large".to_string()); } read_bounded(file) } -fn read_bounded(reader: impl Read) -> Result, &'static str> { +fn read_bounded(reader: impl Read) -> Result, String> { let mut bytes = Vec::new(); reader .take(MAX_FILE_BYTES as u64 + 1) .read_to_end(&mut bytes) - .map_err(|_| "file-read-failed")?; + .map_err(|error| format!("file-read-failed:{error}"))?; if bytes.len() > MAX_FILE_BYTES { - return Err("file-grew-over-limit"); + return Err("file-grew-over-limit".to_string()); } Ok(bytes) } @@ -320,7 +331,7 @@ struct ReadResult { } fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult { - let error = |code| ReadResult { + let error = |code: String| ReadResult { value: json!({"path":input.path,"status":"error","code":code}), source_hash: None, }; @@ -331,16 +342,16 @@ fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult let source_hash = sha256(&bytes); let text = match std::str::from_utf8(&bytes) { Ok(text) if !text.contains('\0') => text, - _ => return error("not-utf8-text"), + _ => return error("not-utf8-text".to_string()), }; let lines: Vec<_> = text.split_inclusive('\n').collect(); let safe_text = safe_source_text(root, text); let safe_lines: Vec<_> = safe_text.split_inclusive('\n').collect(); if safe_lines.len() != lines.len() { - return error("redaction-line-boundary-error"); + return error("redaction-line-boundary-error".to_string()); } if input.start_line > lines.len().saturating_add(1) { - return error("line-out-of-range"); + return error("line-out-of-range".to_string()); } let start = input.start_line - 1; let requested_count = input.max_lines.min(lines.len().saturating_sub(start)); @@ -361,7 +372,7 @@ fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult value, source_hash: Some(source_hash), }, - None => error("line-exceeds-response-budget"), + None => error("line-exceeds-response-budget".to_string()), } } @@ -414,7 +425,7 @@ where context_identity(&identity_root) }) .await - .map_err(|_| "项目上下文读取任务中断")??; + .map_err(|error| format!("项目上下文读取任务中断:{error}"))??; let item_budget = MAX_ITEM_BYTES.min(response_budget.saturating_sub(4096) / files.len()); let reader = Arc::new(reader); let root = root.to_path_buf(); @@ -425,7 +436,7 @@ where let path = request.path.clone(); let item = tokio::task::spawn_blocking(move || reader(&root, &request, item_budget)) .await - .map_err(|_| "文件批量读取任务中断".to_string())?; + .map_err(|error| format!("文件批量读取任务中断:{error}"))?; Ok::<_, String>((index, path, item)) } })) @@ -460,10 +471,10 @@ where .collect::>() }) .await - .map_err(|_| "批量读取复核任务中断")?; + .map_err(|error| format!("批量读取复核任务中断:{error}"))?; let after = tokio::task::spawn_blocking(move || context_identity(&root)) .await - .map_err(|_| "项目上下文复核任务中断")??; + .map_err(|error| format!("项目上下文复核任务中断:{error}"))??; let stale = before.project_id != after.project_id || before.revision != after.revision || before.turn_id != after.turn_id @@ -480,7 +491,7 @@ where "status":if stale {"stale"} else {"ready"},"activity":before.activity,"turnStatus":before.status, "capturedAt":unix_millis().min(u128::from(u64::MAX)) as u64,"atomicSnapshot":false,"files":files}); if serde_json::to_vec(&result) - .map_err(|_| "上下文序列化失败")? + .map_err(|error| format!("上下文序列化失败:{error}"))? .len() > response_budget { @@ -530,7 +541,7 @@ pub(super) async fn prefetch_turn_input( .collect::>() }) .await - .map_err(|_| "项目预取扫描任务中断")?; + .map_err(|error| format!("项目预取扫描任务中断:{error}"))?; if files.is_empty() { return Ok(None); } @@ -571,7 +582,7 @@ mod tests { let mut reader = std::io::Cursor::new(vec![b'a'; MAX_FILE_BYTES * 2]); assert_eq!( read_bounded(&mut reader).unwrap_err(), - "file-grew-over-limit" + "file-grew-over-limit".to_string() ); assert_eq!(reader.position(), MAX_FILE_BYTES as u64 + 1); } @@ -595,7 +606,7 @@ mod tests { ] { assert_eq!( bounded_bytes(&root, &path.to_string_lossy()), - Err("linked-path") + Err("linked-path".to_string()) ); } std::fs::write(base.join("ordinary.txt"), "ordinary").unwrap(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs index c41b19de0..d0d7c0e76 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs @@ -3,7 +3,7 @@ use serde_json::Value; use std::collections::BTreeMap; use std::path::Path; -#[derive(Default)] +#[derive(Default, Debug)] pub(crate) struct DirectProjectHistoryAccumulator { text_by_item_id: BTreeMap, } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 028c13f8f..7d9d099f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2091,7 +2091,7 @@ fn record_direct_codex_failure_facts( "未能保存项目诊断" }; // sidecar 照写,但引用不进用户可见文案。 - let _ = persist_agent_runtime_error( + if let Err(error) = persist_agent_runtime_error( root, client_turn_id, "direct-codex", @@ -2103,10 +2103,27 @@ fn record_direct_codex_failure_facts( serde_json::json!({ "legacyDiagnosticWritten": diagnostic_written, }), - ) - .ok(); + ) { + // 失败载荷仍然会带着原始 detail 返回前端;这里额外保留统一错误事件落盘失败的 + // OS/JSON/时钟正文,不能让诊断写入失败也被 `.ok()` 吞掉。 + app_log!( + "agent.runtime.error_persist_failed source=direct-codex stage={} code={} detail={}", + stage, + error_code, + error + ); + } + let safe_detail = redact_agent_runtime_error(root, &detail, 420) + .split_whitespace() + .collect::>() + .join(" "); + let detail_suffix = if safe_detail.trim().is_empty() { + "详情:未提供具体错误正文".to_string() + } else { + format!("详情:{safe_detail}") + }; let public_text = format!( - "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}", + "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{};{}", stage, error_code, retryable, @@ -2115,6 +2132,7 @@ fn record_direct_codex_failure_facts( .unwrap_or("未提供可安全展示的详细原因"), recovery_hint, diagnostics_suffix, + detail_suffix, ); // 失败也进错误上报池:命令入队化之后前端 catch 只剩"入队失败",池不能只靠前端填。 // 两条通道同时上报也不会变成两条——池按 fingerprint 合并同一份文案。 @@ -2549,6 +2567,53 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { .collect() } +/// 完成判定前的输入完整性检查。 +/// +/// 运行态的“是否引用平台素材”是业务判定;源码读取、清单解析和已登记图片读取则是 +/// 宿主 I/O。后者失败时不能把权限、损坏 JSON 或磁盘错误伪装成“没有引用素材”,否则 +/// 回合会继续走同一条通用返修提示,用户看不到真正原因。 +fn validate_direct_completion_inputs(root: &Path) -> Result<(), String> { + let entry = agent_runtime_game_entry_relative_path(root); + let source_paths = direct_codex_game_outputs(root) + .into_iter() + .map(|(relative_path, _, _)| relative_path) + .chain(direct_npm_source_paths(root)) + .collect::>(); + for relative_path in source_paths { + let path = root.join(&relative_path); + if !path.exists() { + continue; + } + if !path.is_file() { + return Err(format!("读取游戏源码失败:{} 不是文件", relative_path)); + } + std::fs::read_to_string(&path) + .map_err(|error| format!("读取游戏源码 {} 失败:{error}", relative_path))?; + } + if !root.join(&entry).is_file() { + return Ok(()); + } + let manifest = + read_manifest_for_project(root).map_err(|error| format!("读取项目清单失败:{error}"))?; + for asset in manifest.assets.iter().filter(|asset| { + asset.media_type.starts_with("image/") + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + // 完整图集可以合法地没有切片文件;切片是可选的运行时投影,不能让它的 + // 缺失覆盖真正的 spritesheet 完整性判断。 + && !asset.local_path.starts_with("assets/art-spritesheet-slices/") + }) { + let Some(relative_path) = direct_normalized_project_asset_path(&asset.local_path) else { + continue; + }; + let path = root.join(&relative_path); + let bytes = std::fs::read(&path) + .map_err(|error| format!("读取已登记平台素材 {} 失败:{error}", relative_path))?; + validate_platform_art_png_bytes_with_limits(&bytes, &format!("平台素材 {relative_path}")) + .map_err(|error| format!("校验已登记平台素材 {} 失败:{error}", relative_path))?; + } + Ok(()) +} + fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec { let Ok(manifest) = read_manifest_for_project(root) else { return Vec::new(); @@ -2667,6 +2732,9 @@ fn direct_game_output_completion_error(root: &Path) -> Option { if !root.join(entry).is_file() { return Some(format!("Codex 返回后未找到 {entry},项目未进入可运行状态")); } + if let Err(error) = validate_direct_completion_inputs(root) { + return Some(format!("完成判定读取项目文件失败:{error}")); + } if !direct_game_sources_reference_taonier_art_package(root) { return Some( "游戏代码已生成,但未在源码中引用任何已登记的陶泥儿平台图片;不会将项目标记为完成" @@ -2907,7 +2975,15 @@ async fn recover_direct_taonier_spritesheet_read_only_at( access.validate_frozen_session()?; let status = response.status(); if !status.is_success() { - return Err(format!("读取陶泥儿画布资源失败:HTTP {}", status.as_u16())); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "读取陶泥儿画布资源", + status, + &body, + )); } let payload = response .json::() @@ -3546,7 +3622,7 @@ fn direct_codex_generated_source() -> GameCreationAppAssetSource { } } -fn direct_codex_output_fingerprint(root: &Path) -> String { +fn direct_codex_output_fingerprint_checked(root: &Path) -> Result { let mut hasher = Sha256::new(); let mut paths: Vec = direct_codex_game_outputs(root) .into_iter() @@ -3559,16 +3635,27 @@ fn direct_codex_output_fingerprint(root: &Path) -> String { hasher.update(local_path.as_bytes()); hasher.update([0]); let path = root.join(local_path); - match std::fs::read(path) { + match std::fs::read(&path) { Ok(bytes) => { hasher.update([1]); hasher.update((bytes.len() as u64).to_le_bytes()); hasher.update(bytes); } - Err(_) => hasher.update([0]), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => hasher.update([0]), + Err(error) => { + return Err(format!( + "读取直连 Codex 输出文件失败:{}:{error}", + path.display() + )); + } } } - format!("{:x}", hasher.finalize()) + Ok(format!("{:x}", hasher.finalize())) +} + +#[cfg(test)] +fn direct_codex_output_fingerprint(root: &Path) -> String { + direct_codex_output_fingerprint_checked(root).expect("读取直连 Codex 输出指纹") } fn direct_npm_source_paths(root: &Path) -> Vec { @@ -4107,7 +4194,7 @@ fn sync_direct_codex_project_file_projection_at( root: &Path, previous_output_fingerprint: Option<&str>, ) -> Result<(), String> { - let current_output_fingerprint = direct_codex_output_fingerprint(root); + let current_output_fingerprint = direct_codex_output_fingerprint_checked(root)?; let outputs_changed = previous_output_fingerprint .map(|previous| previous != current_output_fingerprint) .unwrap_or(true); @@ -4490,7 +4577,9 @@ async fn run_direct_game_creator_turn_inner( direct_turn_trace("run-analytics-accepted"); // 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。 let result: Result = async { - if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { + let terminal_report = super::direct_delivery::terminal_report(&execution_session) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, format!("读取交付终态失败:{error}")))?; + if let Some(report) = terminal_report { return Ok(report); } // CLI 没有结构化条目;在进入反馈循环前固定原始消息,避免后续反馈以同一 ID @@ -4527,7 +4616,8 @@ async fn run_direct_game_creator_turn_inner( let stream_enabled = load_game_creator_app_config() .map(|config| config.llm.stream) .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; - let previous_output_fingerprint = direct_codex_output_fingerprint(root); + let previous_output_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; let base_system_prompt = build_direct_codex_system_prompt_with_creation_type(root, creation_type) .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; @@ -4630,20 +4720,28 @@ async fn run_direct_game_creator_turn_inner( } // 交付报告的兜底只读一次:guard 与取值各调一次会在两次之间换出不同结果, // 第二次拿到 `None` 时还会把空串当成回复返回。 - Err(error) => match super::direct_delivery::terminal_report(&execution_session) - { - Some(report) => break Ok(report), - None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && error.is_model_repairable() => - { - let detail = - truncate_agent_runtime_text(&error.diagnostic_detail(), 1800); - emitter.emit("running", Some("error-feedback")); - attempt += 1; - feedback_prompt = direct_codex_error_feedback_prompt(&detail); + Err(error) => { + let original_detail = error.diagnostic_detail(); + match super::direct_delivery::terminal_report(&execution_session) { + Ok(Some(report)) => break Ok(report), + Ok(None) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && error.is_model_repairable() => + { + let detail = + truncate_agent_runtime_text(&original_detail, 1800); + emitter.emit("running", Some("error-feedback")); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail); + } + Ok(None) => break Err(error), + Err(report_error) => break Err(TurnError::turn_failed( + FailureStage::CodeGeneration, + format!( + "读取交付终态失败:{report_error};原始回合失败:{original_detail}" + ), + )), } - None => break Err(error), - }, + } } }; reply_result @@ -4691,19 +4789,29 @@ async fn run_direct_game_creator_turn_inner( } // 同上:交付报告只读一次,避免两次调用之间换出不同结果(第二次拿到 `None` // 时还会绕过下面那条"未返回结果"的兜底错误)。 - Err(error) => match super::direct_delivery::terminal_report(&execution_session) - { - Some(report) => break Some(report), - None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && error.is_model_repairable() => - { - let detail = - truncate_agent_runtime_text(&error.diagnostic_detail(), 1800); - attempt += 1; - feedback_prompt = direct_codex_error_feedback_prompt(&detail); + Err(error) => { + let original_detail = error.diagnostic_detail(); + match super::direct_delivery::terminal_report(&execution_session) { + Ok(Some(report)) => break Some(report), + Ok(None) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && error.is_model_repairable() => + { + let detail = + truncate_agent_runtime_text(&original_detail, 1800); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail); + } + Ok(None) => return Err(error), + Err(report_error) => { + return Err(TurnError::turn_failed( + FailureStage::CodeGeneration, + format!( + "读取交付终态失败:{report_error};原始回合失败:{original_detail}" + ), + )) + } } - None => return Err(error), - }, + } } }; response.ok_or_else(|| { @@ -4719,7 +4827,9 @@ async fn run_direct_game_creator_turn_inner( if let Some(emitter) = turn_emitter { emitter.emit("finalizing", Some("response-finalization")); } - if direct_codex_output_fingerprint(root) != previous_output_fingerprint { + let current_output_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::VersionRegistration, error))?; + if current_output_fingerprint != previous_output_fingerprint { emit_direct_game_creator_progress( root, "project.sync", @@ -4861,7 +4971,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( .await .map_err(|error| TurnError::turn_failed(FailureStage::ArtPreparation, error))?; } - let previous_output_fingerprint = direct_codex_output_fingerprint(root); + let previous_output_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; let mut system_prompt = build_direct_codex_system_prompt(root) .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; if prepare_art { @@ -4873,7 +4984,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( } let _initial_reply = direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string()).await?; - let initial_output_fingerprint = direct_codex_output_fingerprint(root); + let initial_output_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; let mut completion_error = direct_game_output_completion_error(root); let mut evidence_attempts = 0_usize; let mut evidence: Option = None; @@ -4904,7 +5016,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( emit_direct_game_creator_progress(root, "codex.review", "正在根据试玩证据自主检查游戏"); let mut reply = direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt).await?; - let repaired_fingerprint = direct_codex_output_fingerprint(root); + let repaired_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; completion_error = direct_game_output_completion_error(root); if completion_error.is_none() && (browser_checked_output_fingerprint.as_deref() != Some(repaired_fingerprint.as_str()) @@ -4935,7 +5048,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( "试玩未通过,正在进行最后一次自主修复", ); reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt).await?; - let final_fingerprint = direct_codex_output_fingerprint(root); + let final_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; completion_error = direct_game_output_completion_error(root); if completion_error.is_none() && browser_checked_output_fingerprint.as_deref() != Some(final_fingerprint.as_str()) @@ -7408,6 +7522,7 @@ mod tests { .starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-unclassified retryable=true summary=")); assert!(error.contains(""), "{error}"); assert!(error.contains(""), "{error}"); + assert!(error.contains("详情:读取陶泥儿画布资源失败"), "{error}"); assert!(!error.contains("authorization=Bearer secret"), "{error}"); assert!(!error.contains("?token=secret"), "{error}"); assert!(!error.contains("provider.example"), "{error}"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 2fbc2d2f6..39167f3a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -160,7 +160,7 @@ mod tests { TurnFailure::TurnInterrupted(payload) => payload.detail.clone(), TurnFailure::SuperErrorFromStringPlusStage(payload) => payload.detail.clone(), TurnFailure::Unclassified(payload) => payload.detail.clone(), - TurnFailure::HostDropped => String::new(), + TurnFailure::HostDropped(payload) => payload.detail.clone().unwrap_or_default(), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index a3506b662..254651faf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1052,15 +1052,15 @@ fn bridge_resource_request_fingerprint(input: &DirectResourceGenerationInput) -> fn bridge_png_content(root: &Path, path: &Path) -> Result { let root = root .canonicalize() - .map_err(|_| "工具桥项目根无法安全解析".to_string())?; + .map_err(|error| format!("工具桥项目根无法安全解析:{error}"))?; let path = path .canonicalize() - .map_err(|_| "工具桥图片不存在".to_string())?; + .map_err(|error| format!("工具桥图片不存在或无法解析:{error}"))?; if !path.starts_with(&root) { return Err("工具桥图片越出当前项目边界".to_string()); } let metadata = - std::fs::symlink_metadata(&path).map_err(|_| "读取工具桥图片失败".to_string())?; + std::fs::symlink_metadata(&path).map_err(|error| format!("读取工具桥图片失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > DIRECT_TOOL_BRIDGE_MAX_IMAGE_BYTES @@ -1070,7 +1070,7 @@ fn bridge_png_content(root: &Path, path: &Path) -> Result { let (mut file, _) = open_project_snapshot_regular_file(&path, "工具桥图片")?; let mut bytes = Vec::new(); file.read_to_end(&mut bytes) - .map_err(|_| "读取工具桥图片失败".to_string())?; + .map_err(|error| format!("读取工具桥图片失败:{error}"))?; if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { return Err("工具桥图片不是有效 PNG".to_string()); } @@ -3436,9 +3436,11 @@ async fn bridge_update_plan( DirectExecutionGateRejection::SessionUnavailable { cause }, )) } - Err(_) => { + Err(error) => { return Err(UpdatePlanError::Gate( - DirectExecutionGateRejection::SessionTaskLost, + DirectExecutionGateRejection::SessionTaskLost { + cause: error.to_string(), + }, )) } }; @@ -3555,8 +3557,20 @@ async fn bridge_web_search_at( cause: error.to_string(), })?; if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); return Err(WebSearchError::HttpStatusNotSuccess { - status: response.status().as_u16(), + status: status.as_u16(), + detail: (!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\"")) + .then_some(detail), }); } if let Some(length) = response @@ -3686,7 +3700,10 @@ async fn bridge_editor_execute( )) } Ok(Err(error)) => Err(error), - Err(_) => Err(EditorExecuteError::ExecutionUnconfirmed { editor }), + Err(error) => Err(EditorExecuteError::ExecutionUnconfirmed { + editor, + cause: error.to_string(), + }), } } @@ -3920,9 +3937,9 @@ async fn bridge_cocos_call( ), error.to_string(), ), - Err(_) => ( + Err(error) => ( true, - "Cocos execute worker 退出,执行结果需要核对".to_string(), + format!("Cocos execute worker 退出:{error};执行结果需要核对"), ), Ok(Ok(_)) => unreachable!(), }; @@ -3985,14 +4002,16 @@ async fn handle_direct_tool_bridge( )), )); } - Err(_) => { + Err(error) => { return Json(compose_direct_tool_outcome( &state, request.tool.as_str(), &diagnostics_arguments, false, Err(ToolCallError::from( - &DirectExecutionGateRejection::PermitTaskLost, + &DirectExecutionGateRejection::PermitTaskLost { + cause: error.to_string(), + }, )), )); } @@ -4185,13 +4204,20 @@ async fn handle_direct_tool_bridge( let finished = tokio::task::spawn_blocking(move || operation.finish(passed, false, None)).await; if !matches!(finished, Ok(Ok(()))) { + // 合并口径:结算调用沿用 master 的 `operation.finish` 形状,但失败时必须把 + // 真实原因带回调用方,不能只剩一句无正文的通用失败(错误收敛分支的意图)。 + let cause = match finished { + Ok(Err(error)) => format!("执行回执结算失败:{error}"), + Err(error) => format!("执行回执结算任务未返回:{error}"), + Ok(Ok(())) => "执行回执结算未确认".to_string(), + }; return Json(compose_direct_tool_outcome( &state, request.tool.as_str(), &diagnostics_arguments, dispatch_denied, Err(ToolCallError::from( - &DirectExecutionGateRejection::ReceiptNotPersisted, + &DirectExecutionGateRejection::ReceiptNotPersisted { cause }, )), )); } @@ -4230,7 +4256,7 @@ async fn start_tool_bridge_for_source( } let root = root .canonicalize() - .map_err(|_| "AGC 工具桥项目目录无法安全解析".to_string())?; + .map_err(|error| format!("AGC 工具桥项目目录无法安全解析:{error}"))?; let route = format!("/tool-{}", uuid::Uuid::new_v4().simple()); let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) .await diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 30cc27e41..ad70c5bde 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -80,11 +80,17 @@ pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option .build() { Ok(runtime) => runtime, - Err(_) => return Some(1), + Err(error) => { + eprintln!("AGC Direct Tools MCP 运行时初始化失败:{error}"); + return Some(1); + } }; Some(match runtime.block_on(run_direct_tools_mcp_stdio()) { Ok(()) => 0, - Err(_) => 1, + Err(error) => { + eprintln!("AGC Direct Tools MCP 运行失败:{error}"); + 1 + } }) } @@ -104,18 +110,27 @@ async fn direct_tools_mcp_specs() -> Value { feature = "godot-editor-execute" )) { - // 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具。 - if let Ok(result) = tokio::time::timeout( + // 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具,但必须把桥失败的 + // HTTP/IPC/解析正文留在应用日志,不能静默变成“工具不存在”。 + match tokio::time::timeout( std::time::Duration::from_secs(5), call_client_tool_bridge("builtin.plugins.tools", &json!({})), ) .await { - if result["isError"] == false { + Ok(result) if result["isError"] == false => { let availability = result .pointer("/content/0/text") .and_then(Value::as_str) - .and_then(|text| serde_json::from_str::(text).ok()); + .and_then(|text| match serde_json::from_str::(text) { + Ok(value) => Some(value), + Err(error) => { + app_log!( + "agent.direct_tools_mcp.plugin_capability_parse_failed detail={error}" + ); + None + } + }); cocos_editor_available = availability .as_ref() .and_then(|v| v["tools"].as_array()) @@ -141,6 +156,13 @@ async fn direct_tools_mcp_specs() -> Value { .any(|tool| tool == crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME) }); } + Ok(result) => app_log!( + "agent.direct_tools_mcp.plugin_capability_bridge_failed detail={}", + result + ), + Err(error) => app_log!( + "agent.direct_tools_mcp.plugin_capability_bridge_timeout timeoutSeconds=5 detail={error}" + ), } } direct_tools_mcp_specs_for_plugins( @@ -927,7 +949,7 @@ fn build_direct_tool_bridge_client() -> Result { .timeout(std::time::Duration::from_secs(1_800)) .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|_| "创建客户端工具桥连接失败".to_string()) + .map_err(|error| format!("创建客户端工具桥连接失败:{error}")) } async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { @@ -939,11 +961,17 @@ async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { .json(&json!({ "tool": tool, "arguments": arguments })) .send() .await - .map_err(|_| "连接客户端受控工具桥失败".to_string())?; - if !response.status().is_success() { - return Err(format!( - "客户端受控工具桥返回 HTTP {}", - response.status().as_u16() + .map_err(|error| format!("连接客户端受控工具桥失败:{error}"))?; + let status = response.status(); + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "客户端受控工具桥请求", + status, + &body, )); } if response @@ -955,12 +983,12 @@ async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { let bytes = response .bytes() .await - .map_err(|_| "读取客户端受控工具桥响应失败".to_string())?; + .map_err(|error| format!("读取客户端受控工具桥响应失败:{error}"))?; if bytes.len() > DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES { return Err("客户端受控工具桥响应超过大小上限".to_string()); } let value = serde_json::from_slice::(&bytes) - .map_err(|_| "客户端受控工具桥响应格式无效".to_string())?; + .map_err(|error| format!("客户端受控工具桥响应格式无效:{error}"))?; if !value.is_object() || !value.get("content").is_some_and(Value::is_array) || !value.get("isError").is_some_and(Value::is_boolean) @@ -1167,22 +1195,18 @@ fn external_mcp_session_id(root: &Path) -> String { format!("mcp-{:x}", Sha256::digest(material.as_bytes())) } -fn external_mcp_project_id(root: &Path) -> String { - std::fs::read(root.join(".agent/manifest.json")) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - .and_then(|value| { - value - .get("projectId") - .and_then(Value::as_str) - .map(str::to_string) - }) - .unwrap_or_else(|| { - format!( - "project-{:x}", - Sha256::digest(root.to_string_lossy().as_bytes()) - ) - }) +fn external_mcp_project_id(root: &Path) -> Result { + let path = root.join(".agent/manifest.json"); + let bytes = std::fs::read(&path).map_err(|error| format!("读取 MCP 项目清单失败:{error}"))?; + let value: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("解析 MCP 项目清单失败:{error}"))?; + value + .get("projectId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| "MCP 项目清单缺少有效 projectId".to_string()) } fn external_mcp_account_id() -> String { @@ -1221,17 +1245,22 @@ fn validate_external_mcp_record_arguments( fn read_external_mcp_journal(root: &Path) -> Result, String> { let path = external_mcp_journal_path(root); - let Ok(bytes) = std::fs::read(&path) else { - return Ok(Vec::new()); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("读取 Codex 返回记录失败:{error}")), }; if bytes.len() as u64 > EXTERNAL_MCP_JOURNAL_MAX_BYTES { return Err("Codex 返回记录超过客户端保留上限".to_string()); } bytes .split(|byte| *byte == b'\n') - .filter(|line| !line.is_empty()) - .map(|line| { - serde_json::from_slice::(line).map_err(|_| "Codex 返回记录格式损坏".to_string()) + .enumerate() + .filter(|(_, line)| !line.is_empty()) + .map(|(line_number, line)| { + serde_json::from_slice::(line).map_err(|error| { + format!("Codex 返回记录格式损坏:第 {} 行:{error}", line_number + 1) + }) }) .collect() } @@ -1293,18 +1322,28 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { ); } } + let received_at = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(duration) => duration.as_millis() as u64, + Err(error) => { + return mcp_tool_result( + format!("读取 Codex 返回记录时间失败:{error}"), + Vec::new(), + true, + ) + } + }; let record = json!({ "recordId": uuid::Uuid::new_v4().to_string(), "recordType": "codex.response", "accountId": external_mcp_account_id(), - "projectId": external_mcp_project_id(root), + "projectId": match external_mcp_project_id(root) { + Ok(project_id) => project_id, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }, "sessionId": external_mcp_session_id(root), "requestId": request_id, "sequence": sequence, - "receivedAt": std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or_default(), + "receivedAt": received_at, "content": redacted, "contentSha256": format!("{:x}", Sha256::digest(redacted.as_bytes())), "summary": external_mcp_response_summary(&redacted), @@ -1321,9 +1360,17 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { ) } }; - let current_size = std::fs::metadata(&path) - .map(|metadata| metadata.len()) - .unwrap_or(0); + let current_size = match std::fs::metadata(&path) { + Ok(metadata) => metadata.len(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0, + Err(error) => { + return mcp_tool_result( + format!("读取 Codex 返回记录大小失败:{error}"), + Vec::new(), + true, + ) + } + }; if current_size.saturating_add(line.len() as u64 + 1) > EXTERNAL_MCP_JOURNAL_MAX_BYTES { return mcp_tool_result( "Codex 返回记录达到客户端保留上限".to_string(), @@ -1361,15 +1408,10 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { } fn external_mcp_session_info(root: &Path) -> Value { - let manifest = std::fs::read(root.join(".agent/manifest.json")) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); - let project_id = manifest - .as_ref() - .and_then(|value| value.get("projectId")) - .and_then(Value::as_str) - .unwrap_or("unknown") - .to_string(); + let project_id = match external_mcp_project_id(root) { + Ok(project_id) => project_id, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; mcp_tool_result( json!({ "status": "bound", @@ -1438,10 +1480,25 @@ async fn run_direct_tools_mcp_blocking_read( ) -> Value { match tokio::task::spawn_blocking(read).await { Ok(result) => result, - Err(_) => mcp_tool_result("MCP 本地资源读取任务未完成".to_string(), Vec::new(), true), + Err(error) => mcp_tool_result( + format!("MCP 本地资源读取任务未完成:{error}"), + Vec::new(), + true, + ), } } +fn direct_tools_mcp_panic_detail(payload: &(dyn std::any::Any + Send)) -> String { + let raw = if let Some(text) = payload.downcast_ref::<&str>() { + (*text).to_string() + } else if let Some(text) = payload.downcast_ref::() { + text.clone() + } else { + "未知 panic 负载".to_string() + }; + crate::agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 480) +} + async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option { let id = request.get("id").cloned(); let method = request.get("method").and_then(Value::as_str)?; @@ -1493,13 +1550,17 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< .and_then(Value::as_str) .unwrap_or_default(); if uri == "agc://skills/index" { - let text = render_agc_skill_pack_index() - .map_err(|_| ()) - .unwrap_or_else(|_| "AGC Skill 索引暂不可用".to_string()); - return Some(mcp_success( - id, - json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), - )); + return match render_agc_skill_pack_index() { + Ok(text) => Some(mcp_success( + id, + json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), + )), + Err(error) => Some(mcp_error( + id, + -32603, + &format!("AGC Skill 索引读取失败:{error}"), + )), + }; } if let Some(resource) = uri.strip_prefix("agc://skills/") { return match read_agc_skill_resource(resource) { @@ -1510,21 +1571,30 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< )) } Ok(_) => Some(mcp_error(id, -32000, "AGC Skill 资源超过响应大小上限")), - Err(_) => Some(mcp_error(id, -32602, "未知或未审核的 AGC Skill 资源")), + Err(error) => Some(mcp_error( + id, + -32603, + &format!("AGC Skill 资源读取失败:{error}"), + )), }; } if uri != "agc://conversation/codex-responses" { Some(mcp_error(id, -32602, "未知资源")) } else { - let text = read_external_mcp_journal(root) - .map(|records| { - records - .iter() - .map(Value::to_string) - .collect::>() - .join("\n") - }) - .unwrap_or_default(); + let text = match read_external_mcp_journal(root) { + Ok(records) => records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"), + Err(error) => { + return Some(mcp_error( + id, + -32603, + &format!("Codex 返回记录资源读取失败:{error}"), + )); + } + }; if text.len() > DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES { return Some(mcp_error(id, -32000, "Codex 返回记录资源超过响应大小上限")); } @@ -1627,7 +1697,7 @@ fn read_bounded_line(reader: &mut R) -> Result>, Stri loop { let available = reader .fill_buf() - .map_err(|_| "读取 MCP 请求失败".to_string())?; + .map_err(|error| format!("读取 MCP 请求失败:{error}"))?; if available.is_empty() { return Ok((!bytes.is_empty()).then_some(bytes)); } @@ -1655,11 +1725,12 @@ fn write_direct_tools_mcp_response( writer: &mut impl Write, response: &Value, ) -> Result<(), String> { - serde_json::to_writer(&mut *writer, response).map_err(|_| "写入 MCP 响应失败".to_string())?; + serde_json::to_writer(&mut *writer, response) + .map_err(|error| format!("写入 MCP 响应失败:序列化失败:{error}"))?; writer .write_all(b"\n") .and_then(|_| writer.flush()) - .map_err(|_| "写入 MCP 响应失败".to_string()) + .map_err(|error| format!("写入 MCP 响应失败:{error}")) } #[cfg(not(test))] @@ -1708,12 +1779,25 @@ where pending.push(async move { match std::panic::AssertUnwindSafe(future).catch_unwind().await { Ok(response) => response, - Err(_) => id.map(|id| mcp_error(id, -32603, "Tool execution interrupted")), + Err(payload) => id.map(|id| { + mcp_error( + id, + -32603, + &format!( + "Tool execution interrupted:{}", + direct_tools_mcp_panic_detail(payload.as_ref()) + ), + ) + }), } }); None } - Err(_) => Some(mcp_error(Value::Null, -32700, "Parse error")), + Err(error) => Some(mcp_error( + Value::Null, + -32700, + &format!("Parse error: {error}"), + )), }, Some(Err(error)) => { terminal_error = Some(error); @@ -1744,7 +1828,7 @@ where #[cfg(not(test))] async fn run_direct_tools_mcp_stdio() -> Result<(), String> { let root = validate_direct_tools_project_root( - &std::env::current_dir().map_err(|_| "读取 MCP 工作目录失败".to_string())?, + &std::env::current_dir().map_err(|error| format!("读取 MCP 工作目录失败:{error}"))?, )?; let (sender, receiver) = tokio::sync::mpsc::channel(DIRECT_TOOLS_MCP_MAX_IN_FLIGHT); std::thread::Builder::new() @@ -1753,7 +1837,7 @@ async fn run_direct_tools_mcp_stdio() -> Result<(), String> { let stdin = std::io::stdin(); read_direct_tools_mcp_requests(BufReader::new(stdin.lock()), sender); }) - .map_err(|_| "启动 MCP 请求读取失败".to_string())?; + .map_err(|error| format!("启动 MCP 请求读取失败:{error}"))?; let stdout = std::io::stdout(); dispatch_direct_tools_mcp_requests(receiver, &mut stdout.lock(), move |request| { let root = root.clone(); @@ -1770,27 +1854,60 @@ fn external_mcp_authorized(headers: &HeaderMap, token: &str) -> bool { .is_some_and(|value| value == token) } +fn external_mcp_http_error( + status: StatusCode, + code: &'static str, + message: &'static str, +) -> axum::response::Response { + ( + status, + Json(json!({ + "error": { + "code": code, + "message": message, + "status": status.as_u16(), + } + })), + ) + .into_response() +} + async fn handle_external_mcp_http_request( AxumState(state): AxumState, headers: HeaderMap, Json(request): Json, -) -> Result { +) -> Result { if !external_mcp_authorized(&headers, &state.token) { - return Err(StatusCode::UNAUTHORIZED); + return Err(external_mcp_http_error( + StatusCode::UNAUTHORIZED, + "mcp-unauthorized", + "客户端 MCP 鉴权失败:Bearer token 缺失、无效或已过期", + )); } let Some(session) = current_platform_session() else { - return Err(StatusCode::UNAUTHORIZED); + return Err(external_mcp_http_error( + StatusCode::UNAUTHORIZED, + "mcp-session-unavailable", + "客户端 MCP 鉴权失败:当前账号会话不存在或已失效", + )); }; if session.user_id != state.session_user_id || session.identity_generation != state.session_identity_generation { - return Err(StatusCode::UNAUTHORIZED); + return Err(external_mcp_http_error( + StatusCode::UNAUTHORIZED, + "mcp-session-changed", + "客户端 MCP 鉴权失败:账号会话已变化,请重新建立 MCP 连接", + )); } // HTTP 有独立入口容量;饱和时在任何工具副作用前拒绝,不与 STDIO 重复取许可。 - let _permit = state - .in_flight - .try_acquire() - .map_err(|_| StatusCode::TOO_MANY_REQUESTS)?; + let _permit = state.in_flight.try_acquire().map_err(|_| { + external_mcp_http_error( + StatusCode::TOO_MANY_REQUESTS, + "mcp-capacity-exhausted", + "客户端受控工具桥并发已满(上限 8),请稍后重试", + ) + })?; // JSON-RPC 通知(没有 `id`)没有响应体:MCP 客户端在 `initialize` 之后一定会发 // `notifications/initialized`,按错误回 400 会被判成握手失败,整个服务器就被标记 // 成 `failed`、一个工具都不广告(真实案例:2026-10-02 的 cc DirectProject 因此 @@ -1804,7 +1921,13 @@ async fn handle_external_mcp_http_request( handle_direct_tools_mcp_request(&state.root, request), ) .await - .ok_or(StatusCode::BAD_REQUEST)?; + .ok_or_else(|| { + external_mcp_http_error( + StatusCode::BAD_REQUEST, + "mcp-bridge-context-missing", + "客户端受控工具桥未绑定:当前请求不在受控工具执行上下文中", + ) + })?; Ok(Json(response).into_response()) } @@ -1863,7 +1986,9 @@ async fn start_external_mcp_loopback_with_mode( .layer(DefaultBodyLimit::max(DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES)) .with_state(state); let task = tokio::spawn(async move { - let _ = axum::serve(listener, app).await; + if let Err(error) = axum::serve(listener, app).await { + app_log!("agent.direct_tools_mcp.server_failed detail={error}"); + } }); let url = format!("http://127.0.0.1:{}{route}", address.port()); let registry = @@ -1886,21 +2011,33 @@ async fn start_external_mcp_loopback_with_mode( #[cfg(not(test))] pub(crate) fn stop_external_mcp_loopback() { if let Some(registry) = EXTERNAL_MCP_SERVER.get() { - if let Ok(mut guard) = registry.lock() { - guard.clear(); + match registry.lock() { + Ok(mut guard) => guard.clear(), + Err(_) => app_log!( + "agent.direct_tools_mcp.registry_unavailable action=stop_all detail=mutex-poisoned" + ), } } } pub(crate) fn stop_external_mcp_loopback_for_root(root: &Path, token: &str) { - let Ok(root) = root.canonicalize() else { - return; + let root = match root.canonicalize() { + Ok(root) => root, + Err(error) => { + app_log!("agent.direct_tools_mcp.stop_root_canonicalize_failed detail={error}"); + return; + } }; if let Some(registry) = EXTERNAL_MCP_SERVER.get() { - if let Ok(mut guard) = registry.lock() { - if guard.get(&root).is_some_and(|server| server.token == token) { - guard.remove(&root); + match registry.lock() { + Ok(mut guard) => { + if guard.get(&root).is_some_and(|server| server.token == token) { + guard.remove(&root); + } } + Err(_) => app_log!( + "agent.direct_tools_mcp.registry_unavailable action=stop_root detail=mutex-poisoned" + ), } } } @@ -1961,7 +2098,17 @@ mod tests { Json(json!({"id":1,"method":"ping"})), ) .await; - assert_eq!(rejected.unwrap_err(), StatusCode::TOO_MANY_REQUESTS); + let rejected = rejected.unwrap_err(); + assert_eq!(rejected.status(), StatusCode::TOO_MANY_REQUESTS); + let rejected_body = axum::body::to_bytes(rejected.into_body(), usize::MAX) + .await + .unwrap(); + let rejected_body: Value = serde_json::from_slice(&rejected_body).unwrap(); + assert_eq!(rejected_body["error"]["code"], "mcp-capacity-exhausted"); + assert_eq!(rejected_body["error"]["status"], 429); + assert!(rejected_body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("并发已满"))); drop(occupied); let response = handle_external_mcp_http_request( AxumState(state.clone()), @@ -1981,6 +2128,44 @@ mod tests { ); } + #[tokio::test] + async fn external_http_mcp_auth_failure_keeps_status_and_actionable_body() { + let _session = crate::platform_session::install_test_platform_session( + "mcp-auth-user", + "mcp-auth-key", + "http://127.0.0.1:9", + ); + let session = current_platform_session().unwrap(); + let temporary = tempfile::tempdir().unwrap(); + let state = ExternalMcpHttpState { + bridge_url: "http://127.0.0.1:9".to_string(), + root: temporary.path().to_path_buf(), + token: "expected-token".to_string(), + session_user_id: session.user_id, + session_identity_generation: session.identity_generation, + in_flight: std::sync::Arc::new(tokio::sync::Semaphore::new( + DIRECT_TOOLS_MCP_MAX_IN_FLIGHT, + )), + }; + let rejected = handle_external_mcp_http_request( + AxumState(state), + HeaderMap::new(), + Json(json!({"id":1,"method":"ping"})), + ) + .await + .unwrap_err(); + assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); + let body = axum::body::to_bytes(rejected.into_body(), usize::MAX) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["error"]["code"], "mcp-unauthorized"); + assert_eq!(body["error"]["status"], 401); + assert!(body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("Bearer token"))); + } + #[tokio::test] async fn mcp_dispatch_runs_mixed_tools_concurrently_with_bounded_out_of_order_responses() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -2205,7 +2390,12 @@ mod tests { .map(|line| serde_json::from_str::(line).unwrap()) .collect::>(); assert_eq!(replies.len(), 2); - assert!(replies.iter().any(|reply| reply["error"]["code"] == -32700)); + assert!(replies.iter().any(|reply| { + reply["error"]["code"] == -32700 + && reply["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("Parse error")) + })); assert!(replies .iter() .any(|reply| reply["id"] == "failed-call" && reply["error"]["code"] == -32603)); @@ -3426,6 +3616,57 @@ mod tests { .is_err()); } + #[test] + fn external_codex_response_journal_distinguishes_missing_from_read_and_parse_errors() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-read-response-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-read-response", "Codex 返回读取测试") + .expect("init project"); + assert!(read_external_mcp_journal(root).unwrap().is_empty()); + + let journal = external_mcp_journal_path(root); + std::fs::create_dir_all(&journal).unwrap(); + let error = read_external_mcp_journal(root).unwrap_err(); + assert!(error.contains("读取 Codex 返回记录失败"), "{error}"); + + std::fs::remove_dir_all(&journal).unwrap(); + std::fs::write(&journal, b"{broken-json\n").unwrap(); + let error = read_external_mcp_journal(root).unwrap_err(); + assert!(error.contains("第 1 行"), "{error}"); + assert!(error.contains("Codex 返回记录格式损坏"), "{error}"); + } + + #[test] + fn external_codex_response_does_not_hide_corrupt_project_manifest() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-manifest-error-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-manifest-error", "MCP 清单错误测试") + .expect("init project"); + std::fs::write(root.join(".agent/manifest.json"), b"{broken-json\n") + .expect("corrupt manifest"); + + let record = external_mcp_record_response( + root, + &json!({ + "requestId": "req-corrupt-manifest", + "sequence": 0, + "content": "这条记录不应伪造 projectId" + }), + ); + assert_eq!(record["isError"], true, "{record}"); + assert!( + record.to_string().contains("解析 MCP 项目清单失败"), + "{record}" + ); + + let session = external_mcp_session_info(root); + assert_eq!(session["isError"], true, "{session}"); + assert!( + session.to_string().contains("解析 MCP 项目清单失败"), + "{session}" + ); + } + #[test] fn recorded_codex_response_only_writes_its_own_journal() { let temporary = crate::tests::canonical_test_tempdir("direct-tools-record-response-"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index b03817add..757807f5b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -79,17 +79,17 @@ fn evidence_hashes(root: &Path, result: &Value) -> Result { return Err("validation-evidence: 证据不属于受控验证目录".into()); } let path = resolve_local_project_path(root, relative)?; - let meta = - std::fs::symlink_metadata(&path).map_err(|_| "validation-evidence: 证据文件丢失")?; + let meta = std::fs::symlink_metadata(&path) + .map_err(|error| format!("validation-evidence: 证据文件丢失:{error}"))?; if !meta.is_file() || meta.file_type().is_symlink() || meta.len() > 16 * 1024 * 1024 { return Err("validation-evidence: 证据类型或大小无效".into()); } let mut bytes = Vec::new(); std::fs::File::open(path) - .map_err(|_| "validation-evidence: 无法读取证据")? + .map_err(|error| format!("validation-evidence: 无法读取证据:{error}"))? .take(16 * 1024 * 1024 + 1) .read_to_end(&mut bytes) - .map_err(|_| "validation-evidence: 无法读取证据")?; + .map_err(|error| format!("validation-evidence: 无法读取证据:{error}"))?; if bytes.len() > 16 * 1024 * 1024 { return Err("validation-evidence: 证据文件在读取时超限".into()); } @@ -212,14 +212,15 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result { let mut bytes = 0u64; let mut visited = 0usize; while let Some(directory) = directories.pop() { - for entry in - std::fs::read_dir(&directory).map_err(|_| "validation-fingerprint: 读取项目失败")? + for entry in std::fs::read_dir(&directory) + .map_err(|error| format!("validation-fingerprint: 读取项目失败:{error}"))? { visited += 1; if visited > 20_000 { return Err("validation-fingerprint: 项目文件数超限".into()); } - let entry = entry.map_err(|_| "validation-fingerprint: 读取目录项失败")?; + let entry = entry + .map_err(|error| format!("validation-fingerprint: 读取目录项失败:{error}"))?; let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); if matches!( name.as_str(), @@ -242,7 +243,7 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result { } let ty = entry .file_type() - .map_err(|_| "validation-fingerprint: 读取类型失败")?; + .map_err(|error| format!("validation-fingerprint: 读取类型失败:{error}"))?; let path = entry.path(); if !include_outputs && name == "dist" @@ -278,14 +279,14 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result { if name.starts_with("game-creator.config") { continue; } - let mut file = - std::fs::File::open(&path).map_err(|_| "validation-fingerprint: 打开输入失败")?; + let mut file = std::fs::File::open(&path) + .map_err(|error| format!("validation-fingerprint: 打开输入失败:{error}"))?; let mut digest = Sha256::new(); let mut buffer = [0u8; 64 * 1024]; loop { let read = file .read(&mut buffer) - .map_err(|_| "validation-fingerprint: 读取输入失败")?; + .map_err(|error| format!("validation-fingerprint: 读取输入失败:{error}"))?; if read == 0 { break; } @@ -358,7 +359,7 @@ async fn start_for_current_turn( reserve(&root, session, &key, &fingerprint, &source, build) }) .await - .map_err(|_| "验证预约任务退出".to_string())? + .map_err(|error| format!("验证预约任务退出:{error}"))? } async fn finish_async( @@ -370,7 +371,7 @@ async fn finish_async( let root = root.to_path_buf(); tokio::task::spawn_blocking(move || finish(&root, &reservation, result, passed)) .await - .map_err(|_| "验证回执任务退出".to_string())? + .map_err(|error| format!("验证回执任务退出:{error}"))? } #[derive(Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 66db24954..ac6f5426f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -722,8 +722,8 @@ pub(crate) fn classify_external_generation_initial_response( )) } _ => Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成返回未识别的成功状态 HTTP {}", - status.as_u16() + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {}", + format_external_http_error("平台图片生成", status, &payload.to_string()) )), } } @@ -765,7 +765,7 @@ async fn accepted_generation_is_authoritatively_failed_once( ), ) .await - .map_err(|_| "查询平台图片生成任务超时".to_string())??; + .map_err(|_| format!("查询平台图片生成任务超时(3000 ms);operationId={operation_id}"))??; access.validate_frozen_session()?; let generation = platform_generation_status_data(&payload); match json_string_field(generation, "status").as_deref() { @@ -795,7 +795,10 @@ pub(crate) async fn external_editor_json_request( })?; let status = response.status(); if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(format_external_http_error(action, status, &body)); } @@ -934,6 +937,27 @@ fn collect_external_http_error_fields(value: &serde_json::Value, output: &mut Ve } } +fn summarize_external_generation_failure(generation: &serde_json::Value) -> String { + let source = generation.get("error").unwrap_or(generation); + let mut fields = Vec::new(); + collect_external_http_error_fields(source, &mut fields); + let detail = if fields.is_empty() { + match source { + serde_json::Value::String(value) => value.trim().to_string(), + _ => serde_json::to_string(source).unwrap_or_default(), + } + } else { + fields.join(";") + }; + let detail = redact_secret_tokens(&redact_absolute_path_tokens(&detail)); + let detail = detail.trim().chars().take(600).collect::(); + if detail.is_empty() { + "平台图片生成任务返回 failed,但响应缺少 error 详情".to_string() + } else { + detail + } +} + pub(crate) fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 { external_editor_response_data(payload) .get("pollAfterMs") @@ -1063,8 +1087,7 @@ async fn wait_for_external_generation_result_inner( return Ok(result); } Some("failed") => { - let error = json_string_field(generation, "error") - .unwrap_or_else(|| "服务器未返回错误信息".to_string()); + let error = summarize_external_generation_failure(generation); return Err(format!( "平台图片生成任务失败:{error};operationId={operation_id}" )); @@ -1131,9 +1154,14 @@ pub(crate) async fn submit_external_generation_request( ) })?; if response.status().is_server_error() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 HTTP {},服务端是否已产生副作用未知", - response.status().as_u16() + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 {},服务端是否已产生副作用未知", + format_external_http_error("平台图片生成", status, &body) )); } Ok(response) @@ -1161,11 +1189,14 @@ async fn resume_prepared_external_generation_at( let status = response.status(); if !status.is_success() { post_submit_session?; - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 HTTP {};原生成账本已保留", - status.as_u16() + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 {};原生成账本已保留", + format_external_http_error("External Editor prepared 恢复提交", status, &body) )); } let submission_payload_result = response @@ -1384,7 +1415,10 @@ pub(crate) async fn prepare_external_canvas_generation_context( return Err("绑定素材目录不存在,无法继续生成".to_string()); } if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(format_external_http_error( "读取绑定素材目录", @@ -1737,7 +1771,7 @@ fn read_validated_platform_art_reference_at( } let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?; let decoded = image::load_from_memory(&bytes) - .map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?; + .map_err(|error| format!("参考素材不是可解析图片:{}:{error}", source.local_path))?; Ok((bytes, decoded)) } @@ -1881,11 +1915,11 @@ async fn upload_manifest_asset_remote_reference_at( }; let upload_url = validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) - .map_err(|_| "当前账号参考图上传地址不安全".to_string())?; + .map_err(|error| format!("当前账号参考图上传地址无效:{error}"))?; let upload_client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await - .map_err(|_| "无法创建当前账号参考图上传客户端".to_string())?; + .map_err(|error| format!("无法创建当前账号参考图上传客户端:{error}"))?; access.validate_frozen_session()?; let form = ticket .form_fields @@ -1898,17 +1932,23 @@ async fn upload_manifest_asset_remote_reference_at( // 请求发出前就结束生命周期。 .file_name(file_name.clone()) .mime_str(&source.media_type) - .map_err(|_| "参考图媒体类型不能用于上传".to_string())?; + .map_err(|error| format!("参考图媒体类型不能用于上传:{error}"))?; let upload_response = upload_client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传当前账号参考图失败".to_string())?; + .map_err(|error| format!("上传当前账号参考图失败:{error}"))?; if upload_response.status().as_u16() != ticket.success_action_status { - return Err(format!( - "上传当前账号参考图失败:HTTP {}", - upload_response.status().as_u16() + let status = upload_response.status(); + let body = upload_response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "上传当前账号参考图", + status, + &body, )); } access.validate_frozen_session()?; @@ -3270,7 +3310,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at if !status.is_success() { post_submit_session?; binding_access.validate_frozen_session()?; - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); binding_access.validate_frozen_session()?; if external_generation_submit_rejection_is_definitive(status) { @@ -8277,6 +8320,21 @@ mod canvas_generation_tests { ColorType, ImageEncoder, }; + #[test] + fn failed_generation_without_error_field_keeps_response_envelope_detail() { + let detail = summarize_external_generation_failure(&serde_json::json!({ + "status": "failed", + "code": "provider-rejected", + "message": "上游拒绝请求", + "operationId": "private-operation-id", + "api_key": "private-key" + })); + assert!(detail.contains("provider-rejected"), "{detail}"); + assert!(detail.contains("上游拒绝请求"), "{detail}"); + assert!(!detail.contains("private-operation-id"), "{detail}"); + assert!(!detail.contains("private-key"), "{detail}"); + } + #[test] fn generation_kind_catalog_accepts_only_canonical_manifest_kinds() { assert_eq!( @@ -12491,6 +12549,7 @@ mod canvas_generation_tests { .await; server.join().expect("join accepted failure fixture"); assert!(error.contains("平台图片生成任务失败"), "{error}"); + assert!(error.contains("provider rejected request"), "{error}"); assert!(request_receiver .recv_timeout(Duration::from_secs(1)) .expect("failed operation request") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/llm_request.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/llm_request.rs index d47ccfef5..4faf3fca1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/llm_request.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/llm_request.rs @@ -5,7 +5,48 @@ //! 多 pass 循环编排已随 Agent Runtime 退役删除,本模块只剩请求与错误面。 use super::*; +use std::path::Path; +pub(crate) struct AgentProgressEmitter<'a> { + app: &'a tauri::AppHandle, + project_path: String, +} + +impl<'a> AgentProgressEmitter<'a> { + #[cfg(not(test))] + pub(crate) fn new(app: &'a tauri::AppHandle, project_path: &str) -> Self { + Self { + app, + project_path: project_path.to_string(), + } + } + + fn emit(&self, stage: &str, message: &str) { + if let Err(error) = self.app.emit( + "game-creator-agent-progress", + GameCreatorAgentProgressEvent { + project_path: self.project_path.clone(), + stage: stage.to_string(), + message: message.to_string(), + }, + ) { + app_log!( + "agent.generation.progress.emit_failed stage={} detail={error}", + stage + ); + } + } +} + +pub(crate) fn emit_agent_progress( + progress: Option<&AgentProgressEmitter<'_>>, + stage: &str, + message: &str, +) { + if let Some(progress) = progress { + progress.emit(stage, message); + } +} pub(crate) async fn request_game_creator_llm_text( client: &LlmClient, llm: &GameCreatorLlmConfig, @@ -62,12 +103,30 @@ pub(crate) fn game_creator_agent_llm_error_public_summary( platform_llm::LlmError::Deserialize(_) => ("deserialize".to_string(), None), }; let raw = error.to_string(); + let detail = redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 1200); let http_status = http_status .map(|status| format!(" httpStatus={status}")) .unwrap_or_default(); format!( - "kind={kind}{http_status} fingerprint={:x} chars={}", + "kind={kind}{http_status} detail={detail} fingerprint={:x} chars={}", Sha256::digest(raw.as_bytes()), raw.chars().count() ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_summary_keeps_safe_error_detail() { + let summary = + game_creator_agent_llm_error_public_summary(&platform_llm::LlmError::Upstream { + status_code: 502, + message: "upstream overloaded; operationId=private-op; api_key=private-key".into(), + }); + assert!(summary.contains("kind=upstream-502"), "{summary}"); + assert!(summary.contains("upstream overloaded"), "{summary}"); + assert!(!summary.contains("private-key"), "{summary}"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 9b552d78f..950a43dd2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -194,7 +194,6 @@ const ERROR_SENSITIVE_ASSIGNMENT_KEYS: &[&str] = &[ ]; const ERROR_REDACTED_VALUE: &str = "[redacted-secret]"; -const ERROR_REDACTED_KEY: &str = "[redacted-sensitive-field]"; /// Redact an error for display in Runtime state, diagnostics, and tool /// results. This intentionally does not call `sanitize_prompt_context`: the @@ -525,14 +524,11 @@ fn redact_error_sensitive_assignments(line: &str) -> String { if value_start < cursor { break; } - // Do not retain the sensitive field name itself. A warning may be - // serialized to the Agent/UI, and names such as `api_key` or - // `Authorization` are sensitive context even after their values have - // been replaced. Preserve surrounding quotes, separators, and - // whitespace so JSON-ish diagnostics remain readable. + // 保留敏感字段名,只替换值:Authorization/api_key/token 本身是排障分类,值才是秘密。 + // 同时保留引号、分隔符和空白,使 JSON-ish 错误仍然可读。 let key_end = key_start + key_len; output.push_str(&line[cursor..key_start]); - output.push_str(ERROR_REDACTED_KEY); + output.push_str(&line[key_start..key_end]); output.push_str(&line[key_end..value_start]); let (value_end, replacement) = error_assignment_value_replacement(line, value_start); if value_end <= value_start { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index 7e7a2e385..ca15b25c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -405,9 +405,10 @@ mod tests { let identity = crate::sanitize_diagnostic_message(&marked[0], None); assert!(identity.contains("eventId=error-3-1"), "{identity}"); assert!(identity.contains("code=tool-error"), "{identity}"); - assert_eq!( - crate::sanitize_diagnostic_message(&marked[1], None), - "" + let marked_detail = crate::sanitize_diagnostic_message(&marked[1], None); + assert!( + marked_detail.contains("credential rotation failed"), + "{marked_detail}" ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index 96e41cd18..aec8e986f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -33,6 +33,8 @@ pub(crate) struct TurnReservation { thread_id: String, token: String, user_item_id: Option, + /// panic hook 在析构之前记录可用负载;随占用保存,future 取消时不依赖 task-local 仍可访问。 + panic_detail: std::sync::Arc>>, /// Rust 侧会话保活:只在一条 Direct 回合存活期间运行,随这一轮的占用同生共死。 /// /// 渲染层不再按固定间隔触发续期(见 `useDirectProjectChatController` 的说明):会话保活的 @@ -47,6 +49,7 @@ impl TurnReservation { thread_id: thread_id.to_string(), token: dispatched.token.clone(), user_item_id: dispatched.pending.user_item_id(), + panic_detail: std::sync::Arc::new(std::sync::Mutex::new(None)), _session_keepalive: crate::auth_session::spawn_client_session_keepalive(), } } @@ -86,12 +89,27 @@ impl TurnReservation { impl Drop for TurnReservation { fn drop(&mut self) { // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 + // 这里至少把 Drop 当下能观测到的收场事实带进失败载荷,不能只给一个空分类。 + let panic_detail = match self.panic_detail.lock() { + Ok(slot) => slot.clone(), + Err(_) => Some("读取 DirectProject 宿主 panic 详情失败:panic 上下文锁已中毒".into()), + }; + let host_drop_detail = panic_detail.as_deref().unwrap_or(if std::thread::panicking() { + "DirectProject 宿主任务 panic,未能写下终态;具体 panic 负载请查看应用日志" + } else { + "DirectProject 宿主任务退出时未写下终态,Drop 未观察到 panic;可能为 future 被取消、丢弃或提前返回" + }); // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 #[cfg(not(test))] - let finalized_by_guard = self.finish_if_unfinished(TurnCompletion::host_dropped()); + let finalized_by_guard = self.finish_if_unfinished( + TurnCompletion::host_dropped_with_detail(Path::new(&self.thread_id), host_drop_detail), + ); // 测试态不写日志,因此不读兜底结果;但同一位置的兜底收口仍必须发生。 #[cfg(test)] - let _ = self.finish_if_unfinished(TurnCompletion::host_dropped()); + let _ = self.finish_if_unfinished(TurnCompletion::host_dropped_with_detail( + Path::new(&self.thread_id), + host_drop_detail, + )); // 兜底一旦真的收口,就说明这一轮**从未写下终态**:深层既没成功也没失败地退出了。 // 这条必须留应用日志,否则离线只剩一个 `phase=working` 的账本,无从判断是哪一层 // 提前退出(真实案例:2026-10-02 连续两轮只留下 working 账本,errors/ 与 @@ -100,9 +118,8 @@ impl Drop for TurnReservation { if finalized_by_guard { // 项目侧也留一份脱敏诊断:用户在项目目录里就能看到这一轮的收场, // 不必只依赖 AppData 的应用日志。 - // 字段名用 `tt`(不是 `turnToken`):`turnToken=` 会命中应用日志的凭据标记, - // 整行被替换成 ``,离线就只剩一个 - // 说不出原因的 HostDropped。 + // 保留回合定位字段;错误日志只替换敏感值,不再因字段名命中凭据标记而吞掉整行, + // 这样离线仍能看出 HostDropped 的发生位置。 let failure = TurnError::turn_failed( FailureStage::CodeGeneration, format!( @@ -129,20 +146,27 @@ impl Drop for TurnReservation { // 运行段经 task-local 携带回合身份,panic hook 据此把 panic 位置与负载写进应用日志。 // task-local 而非 thread-local:多线程 runtime 下 future 会跨 worker 迁移。 tokio::task_local! { - static DIRECT_TURN_PANIC_CONTEXT: Option<(String, String)>; + static DIRECT_TURN_PANIC_CONTEXT: Option; +} + +#[derive(Clone)] +struct DirectTurnPanicContext { + thread_id: String, + token: String, + detail: std::sync::Arc>>, } /// 兜底诊断:Direct 回合任务 panic 时,把位置与负载写进应用日志。 /// -/// 没有它时,panic 只会让 `TurnReservation::drop` 把回合收成 `HostDropped`——那是"说不出原因" -/// 的分类,离线只留一个 `phase=working` 的账本,`.agent/runtime/errors/` 与 `application.log` -/// 全空,无法判断是哪一层退出的(真实案例:2026-10-02 连续两轮如此)。 +/// 没有它时,panic 只会让 `TurnReservation::drop` 把回合收成没有 panic detail 的 `HostDropped`, +/// 离线只留一个 `phase=working` 的账本,`.agent/runtime/errors/` 与 `application.log` 全空, +/// 无法判断是哪一层退出的(真实案例:2026-10-02 连续两轮如此)。 fn ensure_direct_turn_panic_hook() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { let previous = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - if let Ok(Some((thread_id, token))) = DIRECT_TURN_PANIC_CONTEXT.try_with(Clone::clone) { + if let Ok(Some(context)) = DIRECT_TURN_PANIC_CONTEXT.try_with(Clone::clone) { let location = info .location() .map(|location| { @@ -154,16 +178,27 @@ fn ensure_direct_turn_panic_hook() { ) }) .unwrap_or_else(|| "未知位置".to_string()); + let detail = format!( + "DirectProject 宿主任务 panic:{};位置={location}", + direct_turn_panic_detail(info.payload()) + ); + if let Ok(mut slot) = context.detail.lock() { + *slot = Some(crate::agent::redact_agent_runtime_error( + Path::new(&context.thread_id), + &detail, + 480, + )); + } #[cfg(not(test))] app_log!( "agent.direct_turn.panic threadId={} tt={} location={} payload={}", - thread_id, - token, + context.thread_id, + context.token, location, info.payload_as_str().unwrap_or("未知 panic 负载") ); #[cfg(test)] - let _ = (thread_id, token, location); + let _ = (context.thread_id, context.token, location); } previous(info); })); @@ -189,7 +224,11 @@ pub(crate) fn kick_queue_dispatch(root: &Path) { let reservation = TurnReservation::resume(&thread_id, &dispatched); let root = root.to_path_buf(); ensure_direct_turn_panic_hook(); - let panic_context = Some((thread_id.clone(), dispatched.token.clone())); + let panic_context = Some(DirectTurnPanicContext { + thread_id: thread_id.clone(), + token: dispatched.token.clone(), + detail: std::sync::Arc::clone(&reservation.panic_detail), + }); tauri::async_runtime::spawn(DIRECT_TURN_PANIC_CONTEXT.scope(panic_context, async move { run_dispatched_direct_turn(root, dispatched, reservation, release_identity_generation) .await; @@ -244,7 +283,7 @@ async fn run_dispatched_direct_turn( crate::agent::codex_app_server::emit_direct_thread_user_item(&root, &canonical_user_item); let capture = crate::analytics::gui::capture_writer_context(); let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id.clone()); - // 回合体 panic 不能落到 `TurnReservation` 的 Drop 兜底:那会把这一轮收成"说不出原因"的 + // 回合体 panic 不能落到 `TurnReservation` 的 Drop 兜底:那会把这一轮收成只有 Drop 观测事实的 // `HostDropped`,症状正是用户看到的「本轮执行已中断,请重试」,而项目侧诊断与应用日志 // **一行都不会有**(真实案例:2026-10-02 连续两轮)。这里把 panic 转成分类失败, // 走与正常失败同一条收口:先写脱敏诊断,再写终态。 @@ -316,6 +355,47 @@ mod tests { }; use uuid::Uuid; + #[tokio::test] + async fn panic_before_explicit_terminal_keeps_payload_in_drop_failure() { + let thread = unique_thread("panic-detail"); + let subscription = watch(&thread); + let reservation = TurnReservation::accept_for_test(&thread, "turn-panic-detail"); + ensure_direct_turn_panic_hook(); + let context = DirectTurnPanicContext { + thread_id: thread.clone(), + token: reservation.token.clone(), + detail: std::sync::Arc::clone(&reservation.panic_detail), + }; + let outcome = DIRECT_TURN_PANIC_CONTEXT + .scope( + Some(context), + std::panic::AssertUnwindSafe(async move { + let _reservation = reservation; + panic!( + "IPC EPIPE; Authorization: Bearer fixture-secret; out of memory (ENOMEM)" + ); + }) + .catch_unwind(), + ) + .await; + assert!(outcome.is_err()); + let events = pending(&subscription); + let payload = events + .iter() + .find_map(|event| match event { + ThreadEvent::TurnCompleted { + failure: Some(TurnFailure::HostDropped(payload)), + .. + } => Some(payload), + _ => None, + }) + .expect("drop failure must include panic detail"); + let detail = payload.detail.as_deref().expect("panic detail"); + assert!(detail.contains("IPC EPIPE"), "{detail}"); + assert!(detail.contains("out of memory (ENOMEM)"), "{detail}"); + assert!(!detail.contains("fixture-secret"), "{detail}"); + } + /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 fn watch(thread_id: &str) -> String { let bootstrap = subscribe_thread(thread_id); @@ -447,7 +527,8 @@ mod tests { matches!( events.get(terminal), Some(ThreadEvent::TurnCompleted { status, failure: Some(failure), .. }) - if *status == TurnCompletedStatus::Failed && matches!(failure, TurnFailure::HostDropped) + if *status == TurnCompletedStatus::Failed + && matches!(failure, TurnFailure::HostDropped(_)) ), "{events:?}" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs index 969eaa9ec..541aab00d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs @@ -1010,11 +1010,16 @@ fn notify_subscribers(thread_id: &str) { }; if let Some(app) = THREAD_MANAGER_APP_HANDLE.get() { for subscription_id in subscriber_ids { - let _ = tauri::Emitter::emit( + if let Err(error) = tauri::Emitter::emit( app, THREAD_NOTIFY_EVENT, serde_json::json!({ "subscriptionId": subscription_id }), - ); + ) { + app_log!( + "agent.thread_manager.notify.emit_failed subscriptionId={} detail={error}", + subscription_id + ); + } } } } @@ -1264,7 +1269,12 @@ mod tests { manager.append("thread-1", ThreadEvent::turn_started(1_000)); manager.append( "thread-1", - ThreadEvent::turn_completed_failed(crate::agent::TurnFailure::HostDropped, FIXED_AT_MS), + ThreadEvent::turn_completed_failed( + crate::agent::TurnFailure::HostDropped(crate::agent::HostDropped { + detail: Some("宿主任务提前结束".into()), + }), + FIXED_AT_MS, + ), ); let bootstrap = manager.subscribe("thread-1"); @@ -1274,7 +1284,7 @@ mod tests { if *status == TurnCompletedStatus::Failed && failure.as_ref().is_some_and(|failure| matches!( failure, - crate::agent::TurnFailure::HostDropped + crate::agent::TurnFailure::HostDropped(_) )) && *at == Some(FIXED_AT_MS) )); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs index e97280e1b..864d28acd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs @@ -18,7 +18,7 @@ use std::path::Path; use crate::agent::{ThreadEvent, TurnError, TurnErrorClassified, TurnOutcome, Unclassified}; -use super::wire::TurnFailure; +use super::wire::{HostDropped, TurnFailure}; /// 一轮的收场:正常收场只有前三档,失败必须带载荷。 /// @@ -56,9 +56,17 @@ impl TurnCompletion { /// 宿主任务提前结束(panic / future 被丢弃 / 取消)的兜底终态。 /// - /// 这类收场说不出原因;能说清原因的一律走 [`Self::failed`]。 + /// 旧调用方仍可构造没有正文的兼容载荷;生产 Drop 路径使用 + /// [`Self::host_dropped_with_detail`],把能观测到的收场事实带给前端。 pub(crate) fn host_dropped() -> Self { - Self::Failed(TurnFailure::HostDropped) + Self::Failed(TurnFailure::HostDropped(HostDropped { detail: None })) + } + + pub(crate) fn host_dropped_with_detail(history_root: &Path, detail: &str) -> Self { + let detail = crate::agent::redact_agent_runtime_error(history_root, detail, 480); + Self::Failed(TurnFailure::HostDropped(HostDropped { + detail: (!detail.trim().is_empty()).then_some(detail), + })) } } @@ -107,7 +115,7 @@ pub(crate) fn turn_terminal( } } session_completion(session_status).unwrap_or_else(|| { - // 收尾阶段的 `status` 认不出来(当前不可能发生):宁可报一条说不出原因的失败,也不冒充 + // 收尾阶段的 `status` 认不出来(当前不可能发生):宁可报一条原因缺失的失败,也不冒充 // 正常收场;载荷照样从 typed 错误投影,保持"只在一处拼载荷"。 let error = TurnError::Unclassified(Unclassified { detail: format!("收尾阶段给出的回合终态无法识别:{session_status}"), @@ -292,12 +300,12 @@ stderrClass=nonempty;stderrBytes=1000"; )); } - /// 兜底终态:说不出原因的那一种只给分类,不冒充真实原因。 + /// 兼容终态:没有额外 detail 的旧构造仍能收口;生产 Drop 路径会带观测到的原因。 #[test] - fn host_dropped_terminal_only_carries_the_classification() { + fn host_dropped_terminal_keeps_optional_detail_shape() { assert_eq!( TurnCompletion::host_dropped(), - TurnCompletion::Failed(TurnFailure::HostDropped) + TurnCompletion::Failed(TurnFailure::HostDropped(HostDropped { detail: None })) ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs index deb44460a..508932fb2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs @@ -20,8 +20,8 @@ use crate::agent::{ /// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 /// /// 载荷直接携带**typed 变体**:没有 `kind` 粗分类、也没有预拼的 `message`。前端按变体选语气、 -/// 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,只用于分流与诊断、 -/// 不直接上屏。 +/// 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,由前端精确脱敏后 +/// 展示,或用于诊断。 /// /// 唯一投影点是 [`crate::agent::TurnError::classify`]:控制流(返修要求)落进 /// [`crate::agent::TurnErrorClassified::ShouldContinue`],所以控制流既不会出现在这里,前端也 @@ -45,6 +45,19 @@ pub(crate) enum TurnFailure { TurnInterrupted(TurnInterrupted), SuperErrorFromStringPlusStage(SuperErrorFromStringPlusStage), Unclassified(Unclassified), - /// 宿主任务提前结束(panic / 被取消):说不出原因的那一种兜底。 - HostDropped, + /// 宿主任务提前结束(panic / 被取消):带上宿主能观测到的具体收场原因。 + /// + /// `detail` 设为可选是为了兼容旧版本已经落盘的 `{ "type": "hostDropped" }` 事件; + /// 新事件由占用对象 Drop 路径补上 `panic` 或 `future 被取消/丢弃`。 + HostDropped(HostDropped), +} + +/// 宿主任务提前结束时可观测到的收场事实。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct HostDropped { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub(crate) detail: Option, } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs index 97f70b437..fc8267ff1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs @@ -72,6 +72,7 @@ pub(crate) enum EditorExecuteError { /// 执行任务异常退出,回执不可用:结果待核对,不要自动重放。 ExecutionUnconfirmed { editor: EditorKind, + cause: String, }, } @@ -108,8 +109,8 @@ impl ToolFailure for EditorExecuteError { Self::ExecutionNotCompleted { editor, report } => { format!("{} 执行未完成:{report}", editor.label()) } - Self::ExecutionUnconfirmed { editor } => format!( - "{} 执行任务异常,回执不可用;执行已发出,结果待核对,不要自动重放:\ + Self::ExecutionUnconfirmed { editor, cause } => format!( + "{} 执行任务异常,回执不可用:{cause};执行已发出,结果待核对,不要自动重放:\ {{\"status\":\"needs-reconciliation\",\"dispatched\":true,\"retryAllowed\":false}}", editor.label() ), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs index fe86f162c..89311142f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs @@ -185,8 +185,8 @@ impl ToolFailure for ResourceCompletionRejection { /// 通道,平台原文必须作为数据原样带出(不是在产生点拼进前缀)。这里只负责记录平台给的事实, /// 前缀由使用它的工具在自己的变体里加,两个工具共用这一份载体。 /// -/// `to_user_msg` 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。`phase_detail` -/// 是结构化字段,只进诊断 sidecar 给开发者/LLM 看,不参与用户文案。 +/// `to_user_msg` 优先给平台 `error` 原文,缺失时展示同一终态的 `phase_detail`;两者都没有 +/// 才说明响应没有提供失败正文。结构化字段也会留在诊断 sidecar。 #[derive(serde::Serialize, Debug)] pub(crate) struct RemoteResourceEditFailure { /// 平台 `error` 字段原文;平台没给时为空。 @@ -197,10 +197,10 @@ pub(crate) struct RemoteResourceEditFailure { impl ToolFailure for RemoteResourceEditFailure { fn to_user_msg(&self) -> String { - match &self.server_message { - Some(server_message) => server_message.clone(), - None => "服务器未返回错误信息".to_string(), - } + self.server_message + .clone() + .or_else(|| self.phase_detail.clone()) + .unwrap_or_else(|| "平台已返回失败终态,但没有提供 error 或 phaseDetail".to_string()) } } @@ -253,14 +253,14 @@ impl ToolFailure for UnknownClientToolRejection { pub(crate) enum DirectExecutionGateRejection { /// 操作许可(付费/写入/执行)取不到。 PermitUnavailable { cause: String }, - /// 取执行许可的阻塞任务没有返回。 - PermitTaskLost, + /// 取执行许可的阻塞任务没有返回;保留 join/panic 事实,避免退成无因固定句。 + PermitTaskLost { cause: String }, /// 回合执行会话 / 计划会话取不到。 SessionUnavailable { cause: String }, - /// 取执行会话的阻塞任务没有返回。 - SessionTaskLost, - /// 工具已返回,但执行回执没有可靠落盘。 - ReceiptNotPersisted, + /// 取执行会话的阻塞任务没有返回;保留 join/panic 事实,避免退成无因固定句。 + SessionTaskLost { cause: String }, + /// 工具已返回,但执行回执没有可靠落盘;保留落盘或回执任务的具体原因。 + ReceiptNotPersisted { cause: String }, } impl ToolFailure for DirectExecutionGateRejection { @@ -269,13 +269,16 @@ impl ToolFailure for DirectExecutionGateRejection { Self::PermitUnavailable { cause } => { format!("本轮执行许可不可用,未派发工具调用:{cause}") } - Self::PermitTaskLost => "宿主执行许可任务未返回,未派发工具调用。".to_string(), - Self::SessionUnavailable { cause } => format!("本轮执行会话不可用:{cause}"), - Self::SessionTaskLost => "宿主执行会话任务未返回。".to_string(), - Self::ReceiptNotPersisted => { - "工具已返回,但宿主执行回执未可靠落盘;请查看交付状态并核对原操作,不自动重放" - .to_string() + Self::PermitTaskLost { cause } => { + format!("宿主执行许可任务未返回,未派发工具调用:{cause}") } + Self::SessionUnavailable { cause } => format!("本轮执行会话不可用:{cause}"), + Self::SessionTaskLost { cause } => { + format!("宿主执行会话任务未返回:{cause}") + } + Self::ReceiptNotPersisted { cause } => format!( + "工具已返回,但宿主执行回执未可靠落盘:{cause};请查看交付状态并核对原操作,不自动重放" + ), } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs index 8abf08c0e..522ae19e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs @@ -18,7 +18,7 @@ pub(crate) enum WebSearchError { MaxResultsOutOfRange { got: u64, max: u64 }, ClientUnavailable { cause: String }, RequestFailed { cause: String }, - HttpStatusNotSuccess { status: u16 }, + HttpStatusNotSuccess { status: u16, detail: Option }, ResponseTooLarge { got_bytes: usize, max_bytes: usize }, ResponseReadFailed { cause: String }, NoPublicResults, @@ -63,9 +63,10 @@ impl ToolFailure for WebSearchError { Self::RequestFailed { cause } => { format!("联网搜索失败:AGC 受控搜索请求未完成:{cause}") } - Self::HttpStatusNotSuccess { status } => { - format!("联网搜索失败:AGC 受控搜索返回 HTTP {status}。") - } + Self::HttpStatusNotSuccess { status, detail } => detail.as_deref().map_or_else( + || format!("联网搜索失败:AGC 受控搜索返回 HTTP {status}。"), + |detail| format!("联网搜索失败:AGC 受控搜索返回 HTTP {status}:{detail}"), + ), Self::ResponseTooLarge { got_bytes, max_bytes, @@ -83,3 +84,19 @@ impl ToolFailure for WebSearchError { } } } + +#[cfg(test)] +mod tests { + use super::{ToolFailure, WebSearchError}; + + #[test] + fn http_failure_keeps_status_and_upstream_detail() { + let message = WebSearchError::HttpStatusNotSuccess { + status: 429, + detail: Some("rate limit exceeded".to_string()), + } + .to_user_msg(); + assert!(message.contains("HTTP 429")); + assert!(message.contains("rate limit exceeded")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index 3a1ae2797..ba6bd729e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -75,14 +75,19 @@ fn emit_asset_generation_task_changed(root: &Path, task_id: &str) { let Some(app) = ASSET_GENERATION_TASK_APP_HANDLE.get() else { return; }; - let _ = tauri::Emitter::emit( + if let Err(error) = tauri::Emitter::emit( app, ASSET_GENERATION_TASK_CHANGED_EVENT, serde_json::json!({ "projectPath": root.to_string_lossy(), "taskId": task_id, }), - ); + ) { + app_log!( + "asset_generation_task.emit_failed taskId={} detail={error}", + task_id + ); + } } const ASSET_GENERATION_TASK_PHASE_QUEUED: &str = "排队中。"; @@ -247,15 +252,21 @@ fn trim_ledger(tasks: &mut Vec) { /// /// 只用来做**精确落点**的交叉核对,所以按同一个口径归一化两侧的路径串(去空白、反斜杠折成 /// 正斜杠);不做模糊匹配、不按素材名猜,避免把另一次生成的产物算到这条任务上。 -fn registered_asset_ids_by_local_path(root: &Path) -> Option> { - let manifest = read_existing_manifest_for_project(root).ok()?; - Some( +fn registered_asset_ids_by_local_path( + root: &Path, +) -> Result>, String> { + let manifest = match read_existing_manifest_for_project(root) { + Ok(manifest) => manifest, + Err(error) if error == "本地项目尚未初始化" => return Ok(None), + Err(error) => return Err(format!("读取生成任务核对用项目清单失败:{error}")), + }; + Ok(Some( manifest .assets .iter() .map(|asset| (normalize_local_path(&asset.local_path), asset.id.clone())) .collect(), - ) + )) } fn normalize_local_path(path: &str) -> String { @@ -340,7 +351,7 @@ pub(crate) fn list_local_project_asset_generation_tasks( ) -> Result, String> { let _guard = lock_ledger()?; let mut tasks = read_ledger(root)?; - let registered = registered_asset_ids_by_local_path(root); + let registered = registered_asset_ids_by_local_path(root)?; if repair_interrupted_tasks(&mut tasks, ®istered) { write_ledger(root, &tasks)?; drop(_guard); diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index a0bb9af10..f9249572c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -11,6 +11,60 @@ const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE: &str = const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE: &str = "private-external-editor-credential-persistence-failed"; +/// Extract a bounded, redacted upstream error without treating a status code as the whole reason. +fn external_asset_http_detail(body: &str) -> Option { + let parsed = serde_json::from_str::(body).ok(); + let error = parsed + .as_ref() + .and_then(|value| value.get("error")) + .or_else(|| parsed.as_ref()); + let detail = ["message", "detail", "code", "additionalDetails"] + .into_iter() + .filter_map(|field| { + error + .and_then(|value| value.get(field)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + .collect::>() + .join(";"); + let detail = (!detail.is_empty()).then_some(detail).or_else(|| { + let safe = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!safe.trim().is_empty() && !matches!(safe.trim(), "{}" | "null" | "\"\"")).then_some(safe) + })?; + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 600, + ); + (!detail.trim().is_empty()).then_some(detail) +} + +pub(crate) fn external_asset_http_failure( + action: &str, + status: reqwest::StatusCode, + body: &str, +) -> String { + let detail = external_asset_http_detail(body) + .map(|detail| format!(":{detail}")) + .unwrap_or_default(); + match status { + reqwest::StatusCode::UNAUTHORIZED => { + format!("authentication-required: {action}失败(HTTP 401),请重新登录后重试{detail}") + } + reqwest::StatusCode::FORBIDDEN => { + format!("permission-denied: {action}被服务器拒绝(HTTP 403){detail}") + } + _ => format!("{action}失败:HTTP {}{detail}", status.as_u16()), + } +} + /// A task-scoped External Editor credential used only by the direct Codex /// runtime. It never changes the GUI account session and deliberately keeps /// the key private: callers may use it to make authenticated requests but may @@ -163,8 +217,10 @@ fn private_external_editor_api_credentials_from_file_at( "本机陶泥儿开发者 Key 配置", PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES, )?; - let parsed = serde_json::from_str::(&content) - .map_err(|_| "本机陶泥儿开发者 Key 配置格式无效,请重新登录客户端后重试".to_string())?; + let parsed = + serde_json::from_str::(&content).map_err(|error| { + format!("本机陶泥儿开发者 Key 配置格式无效:{error};请重新登录客户端后重试") + })?; let api_key = normalize_external_editor_api_key(&parsed.api_key)?; let api_base_url = normalize_external_editor_api_base_url( parsed @@ -406,22 +462,21 @@ async fn create_private_external_editor_api_credentials_from_platform_session( .map_err(|error| format!("创建本机陶泥儿开发者 Key 未取得确定响应;不会自动重试:{error}"))?; let status = response.status(); if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); - return Err(match status { - reqwest::StatusCode::UNAUTHORIZED => { - "authentication-required: 陶泥儿登录已失效,无法创建本机开发者 Key".to_string() - } - reqwest::StatusCode::FORBIDDEN => { - "permission-denied: 当前陶泥儿账号无权创建本机开发者 Key".to_string() - } - _ => format!("创建本机陶泥儿开发者 Key 失败:HTTP {}", status.as_u16()), - }); + return Err(external_asset_http_failure( + "创建本机陶泥儿开发者 Key", + status, + &body, + )); } let payload = response .json::() .await - .map_err(|_| "创建本机陶泥儿开发者 Key 响应格式无效".to_string())?; + .map_err(|error| format!("创建本机陶泥儿开发者 Key 响应格式无效:{error}"))?; let api_key = payload .get("apiKey") .or_else(|| payload.pointer("/data/apiKey")) @@ -455,9 +510,9 @@ pub(crate) async fn ensure_private_external_editor_api_credentials( if let Some(credentials) = private_external_editor_api_credentials_from_file_at(&path)? { return Ok(credentials); } - prepare_private_external_editor_api_credentials_parent_dir_at(&path).map_err(|_| { + prepare_private_external_editor_api_credentials_parent_dir_at(&path).map_err(|error| { format!( - "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE}: 本机开发者凭据存储目录未安全初始化;未创建远端凭据" + "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE}: 本机开发者凭据存储目录未安全初始化:{error};未创建远端凭据" ) })?; // Another local client may have completed the atomic write while this call @@ -467,9 +522,9 @@ pub(crate) async fn ensure_private_external_editor_api_credentials( } let credentials = create_private_external_editor_api_credentials_from_platform_session().await?; - if write_private_external_editor_api_credentials_at(&path, &credentials).is_err() { + if let Err(error) = write_private_external_editor_api_credentials_at(&path, &credentials) { return Err(format!( - "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE}: 本机开发者凭据已创建但未能安全保存;请在账户开发者凭据页面撤销后重试" + "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE}: 本机开发者凭据已创建但未能安全保存:{error};请在账户开发者凭据页面撤销后重试" )); } Ok(credentials) @@ -908,11 +963,15 @@ pub(crate) async fn sync_canvas_project_assets_at( .map_err(|error| format!("读取画板项目失败:{error}"))?; let project_status = project_response.status(); if !project_status.is_success() { - let body = project_response.text().await.unwrap_or_default(); + let body = project_response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(project_status.as_u16(), &body); - return Err(format!( - "读取画板项目失败:HTTP {}", - project_status.as_u16() + return Err(external_asset_http_failure( + "读取画板项目", + project_status, + &body, )); } let project_payload = project_response @@ -1152,6 +1211,9 @@ fn external_asset_host_is_private(host: &str) -> bool { || address.is_unique_local() || address.is_unicast_link_local() || address.is_multicast() + // 与 IPv4 的 198.18/15 对齐:RFC 5180 的 2001:2::/48 同样只有“经已鉴权 + // 稳定引用换签”这一条窄例外能放行,用户或上游直接给的地址仍然失败关闭。 + || external_asset_ip_is_proxy_benchmark(std::net::IpAddr::V6(address)) || address .to_ipv4_mapped() .is_some_and(|mapped| external_asset_host_is_private(&mapped.to_string())) @@ -1165,7 +1227,14 @@ fn external_asset_ip_is_proxy_benchmark(address: std::net::IpAddr) -> bool { let [first, second, ..] = address.octets(); first == 198 && (18..=19).contains(&second) } - std::net::IpAddr::V6(_) => false, + // Clash / Clash.Meta 的 IPv6 fake-IP 默认段是 RFC 5180 的 2001:2::/48,与 IPv4 的 + // RFC 2544 段是同一类“透明代理占位地址”。双栈解析会同时返回两个 fake-IP(现场: + // OSS 域名解析出 198.18.0.162 + 2001:2::91),只承认 IPv4 会让“全部地址都是 + // fake-IP”永远不成立,把正常的平台资产下载误判成私网请求并拒绝。 + std::net::IpAddr::V6(address) => { + let segments = address.segments(); + segments[0] == 0x2001 && segments[1] == 0x0002 && segments[2] == 0x0000 + } } } @@ -1179,9 +1248,9 @@ fn external_asset_resolved_addresses_are_safe( { return true; } - // Clash 等透明代理会把公网域名映射到 RFC 2544 的 198.18.0.0/15 fake-IP。 - // 只有经已鉴权 objectKey/legacy path 换签得到的 URL 可以使用这项窄例外; - // 用户或上游直接提供的 URL、其它私网地址及公私混合解析仍然失败关闭。 + // Clash 等透明代理会把公网域名映射到 RFC 2544 的 198.18.0.0/15(IPv4)和 RFC 5180 的 + // 2001:2::/48(IPv6)fake-IP。只有经已鉴权 objectKey/legacy path 换签得到的 URL 可以使 + // 用这项窄例外;用户或上游直接提供的 URL、其它私网地址及公私混合解析仍然失败关闭。 came_from_stable_reference && !addresses.is_empty() && addresses @@ -1241,7 +1310,7 @@ pub(crate) async fn build_external_asset_download_client( .map(|addresses| addresses.collect::>()) }) .await - .map_err(|_| "解析画板资产下载域名的任务异常".to_string())? + .map_err(|error| format!("解析画板资产下载域名的任务异常:{error}"))? .map_err(|error| format!("解析画板资产下载域名失败:{error}"))?; if addresses.is_empty() { return Err("画板资产下载域名没有可用地址".to_string()); @@ -1367,7 +1436,11 @@ where return Err("画板资产下载地址发生重定向,已拒绝继续请求".to_string()); } if !status.is_success() { - return Err(format!("下载画板资产失败:HTTP {}", status.as_u16())); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(external_asset_http_failure("下载画板资产", status, &body)); } if response .content_length() @@ -1437,9 +1510,12 @@ pub(crate) async fn resolve_external_asset_signed_url( .map_err(|error| format!("换签画板资产失败:{error}"))?; let status = response.status(); if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); - return Err(format!("换签画板资产失败:HTTP {}", status.as_u16())); + return Err(external_asset_http_failure("换签画板资产", status, &body)); } let payload = response .json::() @@ -2260,6 +2336,19 @@ mod tests { use shared_contracts::game_creation_app::GameCreationAppAssetCategory; use std::io::{Read, Write}; + #[test] + fn external_asset_http_failure_keeps_status_and_redacted_upstream_detail() { + let detail = external_asset_http_failure( + "读取画板项目", + reqwest::StatusCode::UNAUTHORIZED, + r#"{"error":{"code":"TOKEN_EXPIRED","message":"access token expired;authorization=Bearer secret"}}"#, + ); + assert!(detail.contains("HTTP 401"), "{detail}"); + assert!(detail.contains("TOKEN_EXPIRED"), "{detail}"); + assert!(detail.contains("access token expired"), "{detail}"); + assert!(!detail.contains("Bearer secret"), "{detail}"); + } + #[test] fn design_artifact_registration_reports_only_real_manifest_changes() { let temporary = tempfile::tempdir().expect("tempdir"); @@ -2985,11 +3074,28 @@ mod tests { assert!(external_asset_resolved_addresses_are_safe(&fake_ip, true)); assert!(!external_asset_resolved_addresses_are_safe(&fake_ip, false)); + // Clash 双栈 fake-IP:IPv6 侧是 RFC 5180 的 2001:2::/48,必须与 IPv4 一起放行, + // 否则双栈解析下“全部地址都是 fake-IP”永不成立,正常资产下载会被误拒。 + let fake_ipv6 = ["[2001:2::91]:443".parse().expect("parse proxy fake IPv6")]; + assert!(external_asset_resolved_addresses_are_safe(&fake_ipv6, true)); + assert!(!external_asset_resolved_addresses_are_safe( + &fake_ipv6, false + )); + let dual_stack_fake_ips = [ + "198.18.0.162:443".parse().expect("parse proxy fake IPv4"), + "[2001:2::91]:443".parse().expect("parse proxy fake IPv6"), + ]; + assert!(external_asset_resolved_addresses_are_safe( + &dual_stack_fake_ips, + true + )); + for address in [ "127.0.0.1:443", "10.0.0.1:443", "169.254.169.254:80", "[::1]:443", + "[fd00::1]:443", ] { let addresses = [address.parse().expect("parse private address")]; assert!(!external_asset_resolved_addresses_are_safe( diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs index 8c62e8deb..6a34d07c8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -161,12 +161,14 @@ pub(crate) struct AuthNetworkFailure { pub(crate) reason: AuthNetworkReason, } -/// 登录服务 5xx 的状态码。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +/// 登录服务 5xx 的状态码与服务端原文。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct AuthServiceUnavailable { pub(crate) status: u16, + /// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 + pub(crate) server_message: Option, } /// 其它未识别拒绝的状态码与服务端原文。 @@ -268,7 +270,10 @@ mod tests { "authNetworkFailure", ), ( - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }), + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { + status: 503, + server_message: None, + }), "authServiceUnavailable", ), ( @@ -342,7 +347,10 @@ mod tests { #[test] fn payload_variants_keep_machine_facts_and_server_text() { let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable( - AuthServiceUnavailable { status: 503 }, + AuthServiceUnavailable { + status: 503, + server_message: None, + }, )) .expect("serialize auth error"); assert_eq!(unavailable["type"], "authServiceUnavailable"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 25ea5b770..680d0ef6a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -565,6 +565,7 @@ fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientA if status.is_server_error() { return ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: status_code, + server_message, }); } ClientAuthError::UnexpectedRejection(UnexpectedRejection { @@ -794,7 +795,12 @@ fn auth_state_view( } fn emit_auth_state(app: &tauri::AppHandle, view: &ClientAuthStateView) { - let _ = app.emit(CLIENT_AUTH_STATE_CHANGED_EVENT, view.clone()); + if let Err(error) = app.emit(CLIENT_AUTH_STATE_CHANGED_EVENT, view.clone()) { + app_log!( + "auth_session.state.emit_failed status={} detail={error}", + view.status + ); + } } fn phone_is_valid(phone: &str) -> bool { @@ -1317,7 +1323,10 @@ mod tests { assert!(!transient.is_authority_failure()); assert_eq!( transient, - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 500 }) + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { + status: 500, + server_message: None, + }) ); } @@ -1424,7 +1433,10 @@ mod tests { fn unreadable_body_keeps_the_known_status() { assert_eq!( classify_unreadable_body(StatusCode::SERVICE_UNAVAILABLE, AuthRoute::PhoneLogin), - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }) + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { + status: 503, + server_message: None, + }) ); assert_eq!( classify_unreadable_body(StatusCode::UNAUTHORIZED, AuthRoute::Session), diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs index 1df7ccf9a..8d18975df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs @@ -643,10 +643,10 @@ pub(super) async fn read_playable_web_game_state( let evaluated = tokio::time::timeout(remaining, page.evaluate(READ_PLAYABLE_GAME_STATE_SCRIPT)) .await .map_err(|_| "读取固定试玩状态超时".to_string())? - .map_err(|_| "读取固定试玩状态失败".to_string())?; + .map_err(|error| format!("读取固定试玩状态失败:{error}"))?; let surface = evaluated .into_value::() - .map_err(|_| "解析固定试玩状态面读取结果失败".to_string())?; + .map_err(|error| format!("解析固定试玩状态面读取结果失败:{error}"))?; if surface.content_length > MAX_PLAYABLE_GAME_STATE_JSON_CHARS { return Err("固定试玩状态 JSON 超过大小上限".to_string()); } @@ -675,7 +675,7 @@ pub(super) async fn click_playtest_control( let mut elements = tokio::time::timeout(remaining, page.find_elements(selector)) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 不存在或查询失败"))?; + .map_err(|error| format!("固定试玩控件 {action} 不存在或查询失败:{error}"))?; if elements.len() != 1 { return Err(format!( "固定试玩控件 {action} 必须唯一,实际数量为 {}", @@ -690,7 +690,7 @@ pub(super) async fn click_playtest_control( tokio::time::timeout(remaining, element.scroll_into_view()) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 无法滚动到可见区域"))?; + .map_err(|error| format!("固定试玩控件 {action} 无法滚动到可见区域:{error}"))?; let remaining = playtest_remaining(deadline)?; let evaluated = tokio::time::timeout( @@ -699,7 +699,7 @@ pub(super) async fn click_playtest_control( ) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 可见性/禁用态读取失败"))?; + .map_err(|error| format!("固定试玩控件 {action} 可见性/禁用态读取失败:{error}"))?; let probe = evaluated .result .value @@ -708,7 +708,7 @@ pub(super) async fn click_playtest_control( .as_str() .ok_or_else(|| format!("固定试玩控件 {action} 可见性/禁用态结果无效"))?; let probe = serde_json::from_str::(probe) - .map_err(|_| format!("固定试玩控件 {action} 可见性/禁用态结果无效"))?; + .map_err(|error| format!("固定试玩控件 {action} 可见性/禁用态结果无效:{error}"))?; if !probe.is_html_element { return Err(format!("固定试玩控件 {action} 必须是 HTMLElement")); } @@ -723,7 +723,7 @@ pub(super) async fn click_playtest_control( tokio::time::timeout(remaining, element.click()) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 不可点击"))?; + .map_err(|error| format!("固定试玩控件 {action} 不可点击:{error}"))?; Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs index 2119e7de5..b3eb67080 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs @@ -135,15 +135,16 @@ async fn snapshot(page: &Page) -> Result<(PlayableWebGameState, RunnerState), St let raw: Value = page .evaluate(READ_PLAYABLE_GAME_STATE_SCRIPT) .await - .map_err(|_| "runner-v1 读取状态失败")? + .map_err(|error| format!("runner-v1 读取状态失败:{error}"))? .into_value() - .map_err(|_| "runner-v1 状态读取结果无效")?; + .map_err(|error| format!("runner-v1 状态读取结果无效:{error}"))?; let text = raw .get("content") .and_then(Value::as_str) .ok_or("runner-v1 状态接口缺失或超限")?; let base = parse_playable_web_game_state(text, BrowserPlaytestScenario::RunnerV1)?; - let value: Value = serde_json::from_str(text).map_err(|_| "runner-v1 JSON 无效")?; + let value: Value = + serde_json::from_str(text).map_err(|error| format!("runner-v1 JSON 无效:{error}"))?; Ok((base, parse_runner_state(&value)?)) } @@ -151,7 +152,7 @@ async fn control_center(page: &Page, selector: &'static str) -> Result<(f64, f64 let mut elements = page .find_elements(selector) .await - .map_err(|_| "runner-v1 查询控件失败")?; + .map_err(|error| format!("runner-v1 查询控件失败:{error}"))?; if elements.len() != 1 { return Err("runner-v1 真实控件必须唯一".into()); } @@ -159,11 +160,11 @@ async fn control_center(page: &Page, selector: &'static str) -> Result<(f64, f64 element .scroll_into_view() .await - .map_err(|_| "runner-v1 控件无法进入视口")?; + .map_err(|error| format!("runner-v1 控件无法进入视口:{error}"))?; let probe = element .call_js_fn(PROBE_PLAYTEST_CONTROL_SCRIPT, false) .await - .map_err(|_| "runner-v1 控件检查失败")?; + .map_err(|error| format!("runner-v1 控件检查失败:{error}"))?; let probe: PlaytestControlProbe = serde_json::from_str( probe .result @@ -172,14 +173,18 @@ async fn control_center(page: &Page, selector: &'static str) -> Result<(f64, f64 .and_then(Value::as_str) .ok_or("runner-v1 控件信息缺失")?, ) - .map_err(|_| "runner-v1 控件信息无效")?; + .map_err(|error| format!("runner-v1 控件信息无效:{error}"))?; if !probe.is_html_element || !probe.visible || probe.disabled { return Err("runner-v1 控件必须可见且可用".into()); } let point = element.call_js_fn("function(){const r=this.getBoundingClientRect();return JSON.stringify({x:r.left+r.width/2,y:r.top+r.height/2});}", false) - .await.map_err(|_| "runner-v1 控件坐标读取失败")?.result.value.ok_or("runner-v1 控件坐标缺失")?; + .await + .map_err(|error| format!("runner-v1 控件坐标读取失败:{error}"))? + .result + .value + .ok_or("runner-v1 控件坐标缺失")?; let point: Value = serde_json::from_str(point.as_str().ok_or("runner-v1 控件坐标结果无效")?) - .map_err(|_| "runner-v1 控件坐标 JSON 无效")?; + .map_err(|error| format!("runner-v1 控件坐标 JSON 无效:{error}"))?; Ok(( point["x"].as_f64().ok_or("runner-v1 x 坐标无效")?, point["y"].as_f64().ok_or("runner-v1 y 坐标无效")?, @@ -196,7 +201,7 @@ async fn touch(page: &Page, point: Option<(f64, f64)>) -> Result<(), String> { }; page.execute(RunnerTouchInput { kind, points }) .await - .map_err(|_| "runner-v1 真实触摸输入失败")?; + .map_err(|error| format!("runner-v1 真实触摸输入失败:{error}"))?; Ok(()) } @@ -242,7 +247,7 @@ async fn held_input( input.windows_virtual_key_code = Some(if jump { 32 } else { 40 }); page.execute(input) .await - .map_err(|_| "runner-v1 键盘输入失败")?; + .map_err(|error| format!("runner-v1 键盘输入失败:{error}"))?; Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 712c41fbd..81682e790 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -111,38 +111,48 @@ fn parse_devtools_ws_url(line: &str) -> Option { Some(ws.to_string()) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum OwnedBrowserLaunchError { - SpawnFailed, - WsTimeout, - WsFailed, + SpawnFailed(String), + WsTimeout(String), + WsFailed(String), ConnectTimeout, - ConnectFailed, + ConnectFailed(String), } impl OwnedBrowserLaunchError { - fn stage(self) -> BrowserLaunchStage { + fn stage(&self) -> BrowserLaunchStage { match self { - Self::SpawnFailed => BrowserLaunchStage::Spawn, - Self::WsTimeout | Self::WsFailed => BrowserLaunchStage::WsHandshake, - Self::ConnectTimeout | Self::ConnectFailed => BrowserLaunchStage::CdpConnect, + Self::SpawnFailed(_) => BrowserLaunchStage::Spawn, + Self::WsTimeout(_) | Self::WsFailed(_) => BrowserLaunchStage::WsHandshake, + Self::ConnectTimeout | Self::ConnectFailed(_) => BrowserLaunchStage::CdpConnect, } } - fn is_recoverable(self) -> bool { + fn is_recoverable(&self) -> bool { matches!( self, - Self::WsTimeout | Self::WsFailed | Self::ConnectTimeout | Self::ConnectFailed + Self::WsTimeout(_) | Self::WsFailed(_) | Self::ConnectTimeout | Self::ConnectFailed(_) ) } - fn code(self) -> &'static str { + fn code(&self) -> &'static str { match self { - Self::SpawnFailed => "browser-spawn-failed", - Self::WsTimeout => "browser-ws-timeout", - Self::WsFailed => "browser-ws-failed", + Self::SpawnFailed(_) => "browser-spawn-failed", + Self::WsTimeout(_) => "browser-ws-timeout", + Self::WsFailed(_) => "browser-ws-failed", Self::ConnectTimeout => "browser-cdp-connect-timeout", - Self::ConnectFailed => "browser-cdp-connect-failed", + Self::ConnectFailed(_) => "browser-cdp-connect-failed", + } + } + + fn detail(&self) -> Option<&str> { + match self { + Self::SpawnFailed(detail) | Self::WsFailed(detail) | Self::ConnectFailed(detail) => { + Some(detail) + } + Self::WsTimeout(detail) => Some(detail), + Self::ConnectTimeout => None, } } } @@ -166,13 +176,14 @@ impl BrowserLaunchStage { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] struct BrowserLaunchFailure { code: &'static str, stage: BrowserLaunchStage, recoverable: bool, subprocess_exited: bool, cleanup_confirmed: bool, + detail: Option, } impl BrowserLaunchFailure { @@ -183,6 +194,24 @@ impl BrowserLaunchFailure { recoverable: false, subprocess_exited: true, cleanup_confirmed: true, + detail: None, + } + } + + fn setup_with_detail(code: &'static str, detail: impl Into) -> Self { + let detail = detail.into(); + Self { + code, + stage: BrowserLaunchStage::Setup, + recoverable: false, + subprocess_exited: true, + cleanup_confirmed: true, + detail: Some( + crate::sanitize_diagnostic_message(&detail, None) + .chars() + .take(1200) + .collect(), + ), } } @@ -197,11 +226,17 @@ impl BrowserLaunchFailure { recoverable: error.is_recoverable(), subprocess_exited, cleanup_confirmed, + detail: error.detail().map(|detail| { + crate::sanitize_diagnostic_message(detail, None) + .chars() + .take(1200) + .collect() + }), } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] struct BrowserRecoveryFailure { first: BrowserLaunchFailure, final_attempt: Option, @@ -211,16 +246,26 @@ impl BrowserRecoveryFailure { fn diagnostic(self) -> String { match self.final_attempt { Some(final_attempt) => format!( - "browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}", + "browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}{}{}", self.first.stage.as_str(), final_attempt.stage.as_str(), final_attempt.subprocess_exited, final_attempt.cleanup_confirmed, self.first.code, final_attempt.code, + self.first + .detail + .as_deref() + .map(|detail| format!(" initial-detail={detail}")) + .unwrap_or_default(), + final_attempt + .detail + .as_deref() + .map(|detail| format!(" final-detail={detail}")) + .unwrap_or_default(), ), None => format!( - "{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}", + "{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}{}", if self.first.recoverable { "browser-recovery-blocked" } else { @@ -230,6 +275,11 @@ impl BrowserRecoveryFailure { self.first.subprocess_exited, self.first.cleanup_confirmed, self.first.code, + self.first + .detail + .as_deref() + .map(|detail| format!(" detail={detail}")) + .unwrap_or_default(), ), } } @@ -271,7 +321,7 @@ async fn devtools_ws_url_from_stderr( let stderr = child .stderr .take() - .ok_or(OwnedBrowserLaunchError::SpawnFailed)?; + .ok_or_else(|| OwnedBrowserLaunchError::SpawnFailed("browser stderr 未建立".into()))?; let mut reader = futures::io::BufReader::new(stderr); let mut captured: Vec = Vec::new(); let mut exited = Box::pin(child.wait()).fuse(); @@ -279,18 +329,18 @@ async fn devtools_ws_url_from_stderr( loop { let mut line = Vec::new(); futures::select! { - _status = exited => return Err(OwnedBrowserLaunchError::WsFailed), + _status = exited => return Err(OwnedBrowserLaunchError::WsFailed(safe_browser_detail(&captured))), result = reader.read_until(b'\n', &mut line).fuse() => { - let count = result.map_err(|_| OwnedBrowserLaunchError::WsFailed)?; + let count = result.map_err(|error| OwnedBrowserLaunchError::WsFailed(format!("读取 browser stderr 失败:{error};{}", safe_browser_detail(&captured))))?; if count == 0 { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(safe_browser_detail(&captured))); } captured.extend_from_slice(&line); if captured.len() > MAX_LAUNCH_STDERR_BYTES { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(format!("browser stderr 超过大小上限;{}", safe_browser_detail(&captured)))); } let Ok(text) = std::str::from_utf8(&line) else { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(format!("browser stderr 不是 UTF-8;{}", safe_browser_detail(&captured)))); }; if let Some(url) = parse_devtools_ws_url(text) { return Ok(url); @@ -302,10 +352,23 @@ async fn devtools_ws_url_from_stderr( match tokio::time::timeout(timeout, read).await { Ok(Ok(url)) => Ok((url, reader)), Ok(Err(error)) => Err(error), - Err(_) => Err(OwnedBrowserLaunchError::WsTimeout), + Err(_) => Err(OwnedBrowserLaunchError::WsTimeout(safe_browser_detail( + &captured, + ))), } } +fn safe_browser_detail(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes).trim().to_string(); + if text.is_empty() { + return "未收到 browser stderr 具体正文".to_string(); + } + crate::sanitize_diagnostic_message(&text, None) + .chars() + .take(1200) + .collect() +} + fn spawn_stderr_drain(mut reader: BrowserStderrReader) -> tokio::task::JoinHandle<()> { // 持续排空 stderr:浏览器日志写满管道会整体 stall。内容有界、不留存。 tokio::spawn(async move { @@ -329,20 +392,46 @@ struct BrowserProcessGuard { job: Option, } +struct BrowserReapOutcome { + exited: bool, + detail: Option, +} + impl BrowserProcessGuard { /// 整树收割:Windows 由 Job 终止全树,再 kill+wait root 兜底; /// Unix 只终止 root,子进程依赖 root 死亡后的级联退出。 /// 返回是否已确认 root 进程退出。 - async fn reap(&mut self) -> bool { + async fn reap(&mut self) -> BrowserReapOutcome { + let mut details = Vec::new(); #[cfg(windows)] if let Some(job) = &self.job { - let _ = job.terminate(); + if let Err(error) = job.terminate() { + details.push(format!("browser Job 终止失败:{error}")); + } + } + match tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await { + Ok(Ok(())) => {} + Ok(Err(error)) if error.kind() != std::io::ErrorKind::NotFound => { + details.push(format!("browser root 终止失败:{error}")); + } + Ok(Err(_)) => {} + Err(_) => details.push("browser root 终止超时".to_string()), + } + let exited = match tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await { + Ok(Ok(_)) => true, + Ok(Err(error)) => { + details.push(format!("等待 browser root 退出失败:{error}")); + false + } + Err(_) => { + details.push("等待 browser root 退出超时".to_string()); + false + } + }; + BrowserReapOutcome { + exited, + detail: (!details.is_empty()).then(|| details.join(";")), } - let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await; - matches!( - tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await, - Ok(Ok(_)) - ) } async fn confirm_reaped(&mut self) -> Result<(), String> { @@ -355,7 +444,7 @@ impl BrowserProcessGuard { loop { if job .is_empty() - .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? + .map_err(|error| format!("browser-tree-reap-unconfirmed:{error}"))? { return Ok(()); } @@ -371,7 +460,8 @@ impl BrowserProcessGuard { // 配置目录,且 browser-cleanup-unconfirmed 永远不会暴露。 match self.child.inner.try_wait() { Ok(Some(_)) => Ok(()), - _ => Err("browser-tree-reap-unconfirmed".into()), + Ok(None) => Err("browser-tree-reap-unconfirmed:root 进程仍在运行".into()), + Err(error) => Err(format!("browser-tree-reap-unconfirmed:{error}")), } } } @@ -411,26 +501,28 @@ impl OwnedBrowser { { return Ok(()); } - let subprocess_exited = self.process.reap().await; - if !subprocess_exited { + let reap = self.process.reap().await; + if !reap.exited { // 进程退出未确认:保留目录与 owner.json,留待下次启动或 // 预检时由跨会话清扫收割,而不是删掉证据让清扫失明。 if let Some(temp) = self.temp.take() { std::mem::forget(temp); } - return Err( - "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=false cleanup-confirmed=false recovery-retried=false" - .into(), - ); + return Err(format!( + "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=false cleanup-confirmed=false recovery-retried=false{}", + reap.detail + .as_deref() + .map(|detail| format!(" detail={detail}")) + .unwrap_or_default() + )); } - if self.process.confirm_reaped().await.is_err() { + if let Err(error) = self.process.confirm_reaped().await { if let Some(temp) = self.temp.take() { std::mem::forget(temp); } - return Err( - "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false" - .into(), - ); + return Err(format!( + "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false detail={error}" + )); } Ok(()) } @@ -450,8 +542,8 @@ async fn cleanup_failed_launch( error: OwnedBrowserLaunchError, tree_control_confirmed: bool, ) -> BrowserLaunchFailure { - let subprocess_exited = process.reap().await; - let tree_reaped = if tree_control_confirmed && subprocess_exited { + let reap = process.reap().await; + let tree_reaped = if tree_control_confirmed && reap.exited { #[cfg(windows)] { process.job.is_none() || process.confirm_reaped().await.is_ok() @@ -471,7 +563,19 @@ async fn cleanup_failed_launch( let _ = temp.keep(); false }; - BrowserLaunchFailure::from_launch_error(error, subprocess_exited, cleanup_confirmed) + let mut failure = + BrowserLaunchFailure::from_launch_error(error, reap.exited, cleanup_confirmed); + if let Some(detail) = reap.detail { + let detail = crate::sanitize_diagnostic_message(&detail, None) + .chars() + .take(1200) + .collect::(); + failure.detail = Some(match failure.detail.take() { + Some(existing) => format!("{existing};清理:{detail}"), + None => format!("清理:{detail}"), + }); + } + failure } /// 自拉浏览器并完成 CDP 连接。Windows 先把 root 放入 KILL_ON_JOB_CLOSE @@ -484,9 +588,9 @@ async fn launch_owned_browser( ) -> Result { let child = match config.launch() { Ok(child) => child, - Err(_) => { + Err(error) => { return Err(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed(error.to_string()), true, true, )); @@ -511,7 +615,9 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed( + "browser root identity unavailable".into(), + ), false, ) .await); @@ -528,13 +634,15 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed( + "browser process tree not covered by Job".into(), + ), tree_reaped, ) .await); } } - Err(_) => { + Err(error) => { let tree_reaped = match (child.inner.id(), root_identity.as_deref()) { (Some(root_pid), Some(root_identity)) => { super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok() @@ -545,7 +653,7 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed(format!("创建 browser Job 失败:{error}")), tree_reaped, ) .await); @@ -575,12 +683,12 @@ async fn launch_owned_browser( .await; let (browser, mut handler) = match connected { Ok(Ok(pair)) => pair, - Ok(Err(_)) => { + Ok(Err(error)) => { drain_task.abort(); return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::ConnectFailed, + OwnedBrowserLaunchError::ConnectFailed(error.to_string()), true, ) .await); @@ -616,10 +724,12 @@ async fn launch_browser_attempt( executable: &std::path::Path, proxy_bypass_list: &str, ) -> Result { - let temporary = create_browser_process_temp_dir() - .map_err(|_| BrowserLaunchFailure::setup("browser-temp-unavailable"))?; - let config = browser_config(executable, &temporary, proxy_bypass_list) - .map_err(|_| BrowserLaunchFailure::setup("browser-config-invalid"))?; + let temporary = create_browser_process_temp_dir().map_err(|error| { + BrowserLaunchFailure::setup_with_detail("browser-temp-unavailable", error) + })?; + let config = browser_config(executable, &temporary, proxy_bypass_list).map_err(|error| { + BrowserLaunchFailure::setup_with_detail("browser-config-invalid", error) + })?; launch_owned_browser(config, executable, temporary).await } @@ -635,15 +745,16 @@ async fn launch_browser_with_recovery( /// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 /// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 pub(crate) async fn check_browser_health() -> Result { - let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; + let discovered = + discover_chrome_or_edge().map_err(|error| format!("browser-not-found:{error}"))?; let owned = launch_browser_with_recovery(&discovered.executable_path, "<-loopback").await?; let version = tokio::time::timeout(Duration::from_secs(5), owned.browser().version()).await; - if owned.shutdown().await.is_err() { - return Err("browser-cleanup-failed".into()); + if let Err(error) = owned.shutdown().await { + return Err(format!("browser-cleanup-failed:{error}")); } let version = version - .map_err(|_| "browser-cdp-timeout")? - .map_err(|_| "browser-cdp-failed")?; + .map_err(|_| "browser-cdp-timeout:等待 DevTools 版本响应超过 5000 ms".to_string())? + .map_err(|error| format!("browser-cdp-failed:{error}"))?; if !safe_version_label(&version.product) || !safe_version_label(&version.protocol_version) { return Err("browser-version-invalid".into()); } @@ -828,7 +939,7 @@ mod health_tests { if attempt == 0 { drop(temporary); Err(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::WsFailed, + OwnedBrowserLaunchError::WsFailed("fixture ws failure".into()), true, true, )) @@ -876,7 +987,7 @@ mod health_tests { async fn consecutive_browser_failures_keep_both_recovery_stages_and_safe_diagnostics() { let failure = launch_with_one_recovery(|| async { Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::WsFailed, + OwnedBrowserLaunchError::WsFailed("fixture ws failure".into()), true, true, )) @@ -889,6 +1000,7 @@ mod health_tests { assert!(diagnostic.contains("subprocess-exited=true")); assert!(diagnostic.contains("cleanup-confirmed=true")); assert!(diagnostic.contains("recovery-retried=true")); + assert!(diagnostic.contains("fixture ws failure")); assert!(!diagnostic.contains("/tmp")); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index 085d4fb91..1b76bfafc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -108,6 +108,16 @@ pub(crate) struct ProjectCommandSpec { pub(crate) cwd: PathBuf, pub(crate) timeout_seconds: u64, pub(crate) verification_eligible: bool, + /// Linux 上 npm 的真实启动锚点:`(node, npm-cli.js)`。保留 `executable` 为 npm shim 以 + /// 维持既有 program / verification 口径,实际进程改由 node 直接运行 npm-cli.js,避免 + /// `#!/usr/bin/env node` 在沙箱里落到系统 node。 + pub(crate) node_launcher: Option<(PathBuf, PathBuf)>, +} + +struct ResolvedProjectCommandExecutable { + executable: PathBuf, + safe_path: OsString, + node_launcher: Option<(PathBuf, PathBuf)>, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -160,9 +170,11 @@ impl ProjectCommandTree { let pid = child.id().ok_or("受控命令缺少进程身份")?; let start_identity = crate::process_identity::external_agent_runner_process_start_identity(pid) - .ok() - .flatten() + .map_err(|error| format!("受控命令 leader 启动身份读取失败:{error}"))? .filter(|identity| !identity.is_empty()); + if start_identity.is_none() { + return Err("受控命令 leader 启动身份为空,拒绝接管进程组".into()); + } Ok(Self::Group { pid, start_identity, @@ -191,7 +203,7 @@ impl ProjectCommandTree { } let observed = crate::process_identity::external_agent_runner_process_start_identity(*pid) - .map_err(|_| "受控进程组 leader 身份未确认,拒绝发送终止信号")?; + .map_err(|error| format!("受控进程组 leader 身份未确认:{error}"))?; if !owned_project_command_group_identity_matches( start_identity.as_deref(), observed.as_deref(), @@ -212,15 +224,23 @@ impl ProjectCommandTree { { let Self::Job(job) = self; let requested = job.terminate(); - if requested.is_err() { - let _ = child.start_kill(); - } + let fallback_error = requested + .as_ref() + .err() + .and_then(|_| child.start_kill().err()); let deadline = tokio::time::Instant::now() + Duration::from_secs(5); let waited = tokio::time::timeout_at(deadline, child.wait()).await; - requested?; + if let Err(error) = requested { + return Err(match fallback_error { + Some(fallback_error) => { + format!("{error};主进程兜底终止失败:{fallback_error}") + } + None => error, + }); + } waited - .map_err(|_| "等待受控命令主进程退出超时")? - .map_err(|_| "受控命令主进程退出未确认")?; + .map_err(|_| "等待受控命令主进程退出超时:超过 5 秒".to_string())? + .map_err(|error| format!("受控命令主进程退出未确认:{error}"))?; while !job.is_empty()? { if tokio::time::Instant::now() >= deadline { return Err("受控命令 Windows Job 子树退出未确认".into()); @@ -233,11 +253,14 @@ impl ProjectCommandTree { { let deadline = tokio::time::Instant::now() + Duration::from_secs(5); let requested = self.request_owned_group_termination(); - let _ = child.start_kill(); + let child_kill_error = child.start_kill().err(); let waited = tokio::time::timeout_at(deadline, child.wait()).await; + let child_kill_detail = child_kill_error + .map(|error| format!(";主进程兜底终止失败:{error}")) + .unwrap_or_default(); waited - .map_err(|_| "等待受控命令主进程退出超时")? - .map_err(|_| "受控命令主进程退出未确认")?; + .map_err(|_| "等待受控命令主进程退出超时:超过 5 秒".to_string())? + .map_err(|error| format!("受控命令主进程退出未确认:{error}{child_kill_detail}"))?; #[cfg(target_os = "linux")] { let Self::Group { pid, .. } = self; @@ -531,6 +554,7 @@ where cwd: root.to_path_buf(), timeout_seconds: 15, verification_eligible: false, + node_launcher: None, }; let launch = prepare_project_command_launch_spec(root, &spec)?; let mut staged = stage_project_command_launch_spec(&spec, launch)?; @@ -638,17 +662,18 @@ pub(crate) fn resolve_project_bootstrap_spec_at( )); } let program = "npm".to_string(); - let (executable, safe_path) = resolve_project_command_executable(root, &program) + let resolved = resolve_project_command_executable(root, &program) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; Ok(ProjectCommandSpec { program, - executable, - safe_path, + executable: resolved.executable, + safe_path: resolved.safe_path, arguments: vec!["install".to_string()], cwd_relative, cwd, timeout_seconds, verification_eligible: false, + node_launcher: resolved.node_launcher, }) } @@ -682,17 +707,18 @@ fn resolve_project_command_spec_inner( if program == "node" { validate_project_command_node_test_files(&cwd, arguments)?; } - let (executable, safe_path) = resolve_project_command_executable(root, &program)?; + let resolved = resolve_project_command_executable(root, &program)?; let verification_eligible = project_command_verification_eligible(&program, arguments); Ok(ProjectCommandSpec { program, - executable, - safe_path, + executable: resolved.executable, + safe_path: resolved.safe_path, arguments: arguments.to_vec(), cwd_relative, cwd, timeout_seconds, verification_eligible, + node_launcher: resolved.node_launcher, }) } @@ -880,36 +906,81 @@ fn project_command_npm_verification_script_suffix_allowed(suffix: &str) -> bool fn resolve_project_command_executable( root: &Path, program: &str, -) -> Result<(PathBuf, OsString), String> { +) -> Result { if matches!(program, "node" | "npm") { let runtime = crate::environment_check::resolve_node_runtime(root)?; - let executable = if program == "node" { - runtime.node.clone() - } else { - runtime - .node - .parent() - .ok_or("node-runtime-invalid")? - .join(if cfg!(windows) { "npm.cmd" } else { "npm" }) - }; + if program == "node" { + return Ok(ResolvedProjectCommandExecutable { + executable: runtime.node, + safe_path: runtime.safe_path, + node_launcher: None, + }); + } + let executable = runtime + .node + .parent() + .ok_or("node-runtime-invalid")? + .join(if cfg!(windows) { "npm.cmd" } else { "npm" }); if !executable.is_file() { return Err("npm-runtime-missing-launcher".into()); } - return Ok((executable, runtime.safe_path)); + #[cfg(target_os = "linux")] + let node_launcher = Some((runtime.node.clone(), runtime.npm_cli.clone())); + #[cfg(not(target_os = "linux"))] + let node_launcher = None; + return Ok(ResolvedProjectCommandExecutable { + executable, + safe_path: runtime.safe_path, + node_launcher, + }); } #[cfg(target_os = "linux")] - let path = std::env::join_paths([ - PathBuf::from("/usr/local/sbin"), - PathBuf::from("/usr/local/bin"), - PathBuf::from("/usr/sbin"), - PathBuf::from("/usr/bin"), - PathBuf::from("/sbin"), - PathBuf::from("/bin"), - ]) - .map_err(|error| format!("构造 command.exec 受信任系统 PATH 失败:{error}"))?; + let path = { + // 先信任客户端解析出的 Node 工具链 bin(fnm / nvm / 系统),再落到系统目录; + // npx / corepack / pnpm / yarn 等随之在沙箱里与 node 同版本。 + let mut directories = Vec::new(); + if let Ok(runtime) = crate::environment_check::resolve_node_runtime(root) { + if let Some(bin) = runtime.node.parent() { + directories.push(bin.to_path_buf()); + } + } + directories.extend([ + PathBuf::from("/usr/local/sbin"), + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/sbin"), + PathBuf::from("/usr/bin"), + PathBuf::from("/sbin"), + PathBuf::from("/bin"), + ]); + std::env::join_paths(directories) + .map_err(|error| format!("构造 command.exec 受信任系统 PATH 失败:{error}"))? + }; #[cfg(not(target_os = "linux"))] let path = std::env::var_os("PATH").ok_or_else(|| "command.exec 缺少 PATH".to_string())?; - resolve_project_command_executable_from_path(root, program, &path) + let (executable, safe_path) = + resolve_project_command_executable_from_path(root, program, &path)?; + Ok(ResolvedProjectCommandExecutable { + executable, + safe_path, + node_launcher: None, + }) +} + +/// Linux 上 npm 以 `node ` 启动;其余情况保持 `executable + args`。 +pub(crate) fn project_command_actual_target(spec: &ProjectCommandSpec) -> (PathBuf, Vec) { + #[cfg(target_os = "linux")] + { + if let Some((node, npm_cli)) = spec.node_launcher.as_ref() { + let mut arguments = Vec::with_capacity(spec.arguments.len() + 1); + arguments.push(npm_cli.to_string_lossy().into_owned()); + arguments.extend(spec.arguments.iter().cloned()); + return (node.clone(), arguments); + } + } + ( + spec.executable.clone(), + project_command_actual_arguments(spec), + ) } fn resolve_project_command_executable_from_path( @@ -1597,13 +1668,16 @@ pub(crate) fn prepare_project_command_launch_spec( } } + #[cfg(target_os = "linux")] + let (target_executable, target_arguments) = project_command_actual_target(spec); + #[cfg(not(target_os = "linux"))] let arguments = project_command_actual_arguments(spec); #[cfg(target_os = "linux")] { let sandbox = prepare_command_sandbox_launch( root, - &spec.executable, - &arguments, + &target_executable, + &target_arguments, &spec.cwd, &environment, ) @@ -1706,7 +1780,8 @@ pub(crate) fn stage_project_command_launch_spec( ) -> Result { #[cfg(target_os = "linux")] { - let target_arguments = project_command_actual_arguments(spec) + let (target_executable, target_arguments) = project_command_actual_target(spec); + let target_arguments = target_arguments .into_iter() .map(OsString::from) .collect::>(); @@ -1718,7 +1793,7 @@ pub(crate) fn stage_project_command_launch_spec( environment: launch.environment, metadata: command_sandbox_platform_metadata(), }, - &spec.executable, + &target_executable, &target_arguments, ) .map_err(|error| { @@ -2586,6 +2661,34 @@ where mod tests { use super::*; + #[cfg(target_os = "linux")] + #[test] + fn npm_command_targets_node_plus_npm_cli_on_linux() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("game")).unwrap(); + let spec = resolve_project_command_spec_at( + root.path(), + "npm", + &["run".to_string(), "build".to_string()], + ".", + 30, + ) + .unwrap(); + let (executable, arguments) = project_command_actual_target(&spec); + assert_eq!( + executable.file_name().and_then(|name| name.to_str()), + Some("node"), + "{executable:?}" + ); + assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}"); + assert_eq!(&arguments[1..], ["run".to_string(), "build".to_string()]); + + let bootstrap = resolve_project_bootstrap_spec_at(root.path(), 30).unwrap(); + let (_, arguments) = project_command_actual_target(&bootstrap); + assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}"); + assert_eq!(arguments[1], "install"); + } + #[cfg(target_os = "linux")] #[tokio::test] async fn exited_group_accepts_orphan_zombies_but_rejects_live_members() { @@ -2785,6 +2888,7 @@ mod tests { cwd: root.to_path_buf(), timeout_seconds: 20, verification_eligible: false, + node_launcher: None, }; let staged = StagedProjectCommandLaunchSpec { launch: ProjectCommandLaunchSpec { diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs index 31e801e9f..1e6655bdc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs @@ -280,16 +280,7 @@ mod linux { environment: &[(OsString, OsString)], ) -> Result { let mut metadata = CommandSandboxMetadata::enforced_linux(); - let network_enabled = executable - .file_name() - .and_then(OsStr::to_str) - .is_some_and(|name| { - name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd") - }) - && arguments - .first() - .is_some_and(|argument| argument == "install"); - if network_enabled { + if command_sandbox_requests_npm_install(executable, arguments) { metadata.network = "enabled"; } let bwrap = find_trusted_bwrap().map_err(|error| { @@ -350,6 +341,14 @@ mod linux { ) }, )?; + let node_read_only = collect_node_toolchain_mounts(&root, &executable, &target_environment) + .map_err(|error| { + CommandSandboxError::new( + format!("command sandbox node toolchain mount 无效:{error}"), + metadata.clone(), + ) + })?; + let external_read_only = merge_read_only_mounts(external_read_only, node_read_only); let fixed_system_read_only = collect_fixed_system_mounts(); let mut launch = build_linux_bwrap_launch(LinuxSandboxPlan { @@ -450,6 +449,11 @@ mod linux { let mut values = BTreeMap::::new(); for (name, value) in environment { validate_environment_name(name)?; + if name == OsStr::new("FNM_MULTISHELL_PATH") { + // fnm 的 multishell 路径位于 /run 下,而 sandbox 用 tmpfs 覆盖 /run; + // 保留会让子进程按一个不存在的目录查找 node。 + continue; + } let replacement = match name.to_str().unwrap_or_default() { "HOME" | "USERPROFILE" => Some(COMMAND_SANDBOX_PRIVATE_HOME), "TMPDIR" | "TEMP" | "TMP" => Some("/tmp"), @@ -527,6 +531,108 @@ mod linux { .collect()) } + /// `npm install` 是唯一允许联网的受控入口。Linux 以 `node install` 启动时 + /// executable 是 node,网络判定不能只看 executable 文件名。 + fn command_sandbox_requests_npm_install(executable: &Path, arguments: &[String]) -> bool { + let npm_name = executable + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| { + name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd") + }); + if npm_name { + return arguments + .first() + .is_some_and(|argument| argument == "install"); + } + arguments + .first() + .map(Path::new) + .and_then(Path::file_name) + .and_then(OsStr::to_str) + .is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js")) + && arguments + .get(1) + .is_some_and(|argument| argument == "install") + } + + /// fnm / nvm / 系统 / 随包 Node 的安装前缀必须整棵只读挂进沙箱:只挂单个 `node` 或 `npm` + /// shim 会让 `#!/usr/bin/env node` 落到系统 node,并让 npm 的 `../lib/node_modules/npm` + /// 相对 require 失效。前缀本身仍走与主机发现共用的窄叶校验。 + fn collect_node_toolchain_mounts( + root: &Path, + executable: &Path, + environment: &[(OsString, OsString)], + ) -> Result, String> { + let mut mounts = BTreeMap::::new(); + for candidate in node_installation_candidates(executable, environment) { + if candidate.starts_with(root) { + continue; + } + let Ok(prefix) = + crate::environment_check::validate_node_installation_prefix(&candidate) + else { + continue; + }; + add_external_mount(root, &prefix, &prefix, &mut mounts)?; + } + Ok(mounts + .into_iter() + .map(|(destination, source)| ReadOnlyMount { + source, + destination, + }) + .collect()) + } + + fn node_installation_candidates( + executable: &Path, + environment: &[(OsString, OsString)], + ) -> Vec { + let mut candidates = Vec::new(); + if let Ok(canonical) = fs::canonicalize(executable) { + push_node_prefix_candidates(&canonical, &mut candidates); + } + if let Some(path) = environment + .iter() + .find(|(name, _)| name == OsStr::new("PATH")) + .map(|(_, value)| value) + { + for directory in std::env::split_paths(path) { + if !directory.is_absolute() { + continue; + } + let Ok(directory) = fs::canonicalize(&directory) else { + continue; + }; + if directory.is_dir() { + push_node_prefix_candidates(&directory.join("node"), &mut candidates); + } + } + } + candidates + } + + fn push_node_prefix_candidates(path: &Path, candidates: &mut Vec) { + let Some(parent) = path.parent() else { + return; + }; + for ancestor in parent.ancestors().take(5) { + candidates.push(ancestor.to_path_buf()); + } + } + + fn merge_read_only_mounts( + first: Vec, + second: Vec, + ) -> Vec { + let mut mounts = first; + mounts.extend(second); + mounts.sort_by(|left, right| left.destination.cmp(&right.destination)); + mounts.dedup_by(|left, right| left.destination == right.destination); + mounts + } + fn validate_external_toolchain_root(name: &str, root: &Path) -> Result<(), String> { if root.parent() == Some(Path::new("/home")) { return Err(format!( @@ -594,16 +700,7 @@ mod linux { let mut args = Vec::::new(); push_namespace_arguments( &mut args, - plan.executable - .file_name() - .and_then(OsStr::to_str) - .is_some_and(|name| { - name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd") - }) - && plan - .arguments - .first() - .is_some_and(|argument| argument == "install"), + command_sandbox_requests_npm_install(&plan.executable, &plan.arguments), ); push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); for (target, destination) in &plan.merged_usr_links { @@ -1135,6 +1232,113 @@ mod linux { assert_eq!(mounts[0].source, rustup_home); } + fn install_node_prefix_fixture(prefix: &Path) { + use std::os::unix::fs::PermissionsExt; + + let bin = prefix.join("bin"); + let npm = prefix.join("lib/node_modules/npm/bin"); + std::fs::create_dir_all(&bin).expect("create node bin"); + std::fs::create_dir_all(&npm).expect("create npm bin"); + let node = bin.join("node"); + std::fs::write(&node, b"node").expect("write node"); + std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o755)) + .expect("chmod node"); + std::fs::write(npm.join("npm-cli.js"), b"npm").expect("write npm-cli"); + } + + #[test] + fn node_installation_prefix_is_mounted_read_only_from_executable_and_path() { + let tree = unique_temp_tree(); + let root = tree.0.join("workspace-node-prefix"); + let installation = tree + .0 + .join("fnm") + .join("node-versions") + .join("v22.23.3") + .join("installation"); + std::fs::create_dir_all(&root).expect("create workspace"); + install_node_prefix_fixture(&installation); + let node = installation.join("bin/node"); + + let mounts = collect_node_toolchain_mounts( + &root, + &node, + &[( + OsString::from("PATH"), + installation.join("bin").into_os_string(), + )], + ) + .expect("node installation prefix should mount"); + assert_eq!(mounts.len(), 1); + assert_eq!(mounts[0].source, installation.canonicalize().unwrap()); + assert_eq!(mounts[0].destination, installation.canonicalize().unwrap()); + } + + #[test] + fn node_toolchain_mount_skips_wide_and_incomplete_candidates() { + let tree = unique_temp_tree(); + let root = tree.0.join("workspace-node-skip"); + std::fs::create_dir_all(&root).expect("create workspace"); + let incomplete = tree.0.join("incomplete-node"); + std::fs::create_dir_all(incomplete.join("bin")).expect("create bin"); + std::fs::write(incomplete.join("bin/node"), b"node").expect("write node"); + + let mounts = collect_node_toolchain_mounts( + &root, + &incomplete.join("bin/node"), + &[( + OsString::from("PATH"), + OsString::from("/usr/bin:/root:/tmp:/"), + )], + ) + .expect("wide or incomplete candidates must be skipped, not fatal"); + assert!(mounts.is_empty(), "unexpected mounts: {mounts:?}"); + } + + #[test] + fn normalize_target_environment_drops_fnm_multishell_path() { + let values = normalize_target_environment(&[ + ( + OsString::from("FNM_MULTISHELL_PATH"), + OsString::from("/run/user/0/fnm_multishells/1_2"), + ), + (OsString::from("PATH"), OsString::from("/usr/bin")), + ]) + .expect("normalize environment"); + assert!(!values + .iter() + .any(|(name, _)| name == OsStr::new("FNM_MULTISHELL_PATH"))); + assert!( + values + .iter() + .any(|(name, value)| name == OsStr::new("PATH") + && value == OsStr::new("/usr/bin")) + ); + } + + #[test] + fn npm_install_network_detection_supports_node_launcher() { + assert!(command_sandbox_requests_npm_install( + Path::new("/opt/node/bin/node"), + &[ + "/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(), + "install".to_string(), + ], + )); + assert!(!command_sandbox_requests_npm_install( + Path::new("/opt/node/bin/node"), + &[ + "/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(), + "run".to_string(), + "build".to_string(), + ], + )); + assert!(command_sandbox_requests_npm_install( + Path::new("/usr/bin/npm"), + &["install".to_string()], + )); + } + struct TempTree(PathBuf); impl Drop for TempTree { @@ -1268,6 +1472,130 @@ print("SANDBOX_OK") } } + #[test] + fn command_sandbox_real_linux_opt_in_runs_host_node_and_npm_cli() { + if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() { + return; + } + let tree = unique_temp_tree(); + let root = tree.0.join("workspace-node-runtime"); + std::fs::create_dir_all(root.join(".agent")).expect("create runtime control"); + let Ok(runtime) = crate::environment_check::resolve_node_runtime(&root) else { + return; + }; + let environment = vec![ + (OsString::from("PATH"), runtime.safe_path.clone()), + (OsString::from("HOME"), OsString::from("/host/home")), + ]; + let run = |executable: &Path, arguments: &[String]| { + let launch = prepare_command_sandbox_launch( + &root, + executable, + arguments, + &root, + &environment, + ) + .expect("prepare node sandbox"); + let output = Command::new(&launch.executable) + .args(&launch.arguments) + .current_dir(&launch.cwd) + .env_clear() + .envs(launch.environment.iter().cloned()) + .output() + .expect("run node sandbox"); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() + }; + + let version = run( + &runtime.node, + &[ + "-e".to_string(), + "process.stdout.write(process.version)".to_string(), + ], + ); + assert!( + version.starts_with('v'), + "unexpected node version: {version}" + ); + + let npm_cli = runtime.npm_cli.to_string_lossy().into_owned(); + let npm_version = run(&runtime.node, &[npm_cli, "--version".to_string()]); + assert!(!npm_version.is_empty(), "npm --version returned nothing"); + } + + #[test] + fn command_sandbox_real_linux_opt_in_runs_nvm_installation_prefix() { + if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() { + return; + } + let Some(nvm_dir) = std::env::var_os("NVM_DIR").map(PathBuf::from) else { + return; + }; + let versions_dir = nvm_dir.join("versions").join("node"); + let Ok(entries) = std::fs::read_dir(&versions_dir) else { + return; + }; + let mut versions = entries + .flatten() + .filter_map(|entry| entry.file_name().to_str().map(str::to_string)) + .collect::>(); + versions.sort(); + let Some(version) = versions.pop() else { + return; + }; + let prefix = crate::environment_check::validate_node_installation_prefix( + &versions_dir.join(&version), + ) + .expect("nvm 安装前缀应通过窄叶校验"); + let node = prefix.join("bin").join("node"); + let npm_cli = prefix.join("lib/node_modules/npm/bin/npm-cli.js"); + + let tree = unique_temp_tree(); + let root = tree.0.join("workspace-nvm-runtime"); + std::fs::create_dir_all(root.join(".agent")).expect("create runtime control"); + let environment = vec![ + (OsString::from("PATH"), prefix.join("bin").into_os_string()), + (OsString::from("HOME"), OsString::from("/host/home")), + ]; + let run = |arguments: &[String]| { + let launch = + prepare_command_sandbox_launch(&root, &node, arguments, &root, &environment) + .expect("prepare nvm sandbox"); + let output = Command::new(&launch.executable) + .args(&launch.arguments) + .current_dir(&launch.cwd) + .env_clear() + .envs(launch.environment.iter().cloned()) + .output() + .expect("run nvm sandbox"); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() + }; + + let version = run(&[ + "-e".to_string(), + "process.stdout.write(process.version)".to_string(), + ]); + assert!( + version.starts_with('v'), + "unexpected node version: {version}" + ); + let npm_version = run(&[ + npm_cli.to_string_lossy().into_owned(), + "--version".to_string(), + ]); + assert!(!npm_version.is_empty(), "npm --version returned nothing"); + } + #[test] fn command_sandbox_staged_gate_real_linux_opt_in_blocks_until_commit() { if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 78e40770a..cfcd3406d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -295,7 +295,8 @@ fn validate_local_asset_import_requirements( // leave the early validation path unable to reach the one-shot UAC // repair entry. crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地导入文件")?; - let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地文件失败".to_string())?; + let metadata = + fs::symlink_metadata(path).map_err(|error| format!("读取本地文件失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通文件".to_string()); } @@ -1322,14 +1323,15 @@ fn read_registered_ui_editor_font( } let target = resolve_local_project_path(root, &asset.local_path)?; prepare_game_creator_private_path_for_read(&target, false, "项目字体")?; - let metadata = fs::symlink_metadata(&target).map_err(|_| "读取项目字体失败".to_string())?; + let metadata = + fs::symlink_metadata(&target).map_err(|error| format!("读取项目字体失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("项目字体必须是普通文件".to_string()); } if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE { return Err("项目字体超过 8 MiB 限制".to_string()); } - let bytes = fs::read(&target).map_err(|_| "读取项目字体失败".to_string())?; + let bytes = fs::read(&target).map_err(|error| format!("读取项目字体失败:{error}"))?; let source_file_name = target .file_name() .and_then(|value| value.to_str()) @@ -1380,14 +1382,15 @@ pub(crate) fn import_ui_editor_local_fonts( for source in source_paths { let path = Path::new(source.trim()); crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地字体")?; - let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地字体失败".to_string())?; + let metadata = + fs::symlink_metadata(path).map_err(|error| format!("读取本地字体失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通字体文件".to_string()); } if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE { return Err("字体超过 8 MiB 限制".to_string()); } - let bytes = fs::read(path).map_err(|_| "读取本地字体失败".to_string())?; + let bytes = fs::read(path).map_err(|error| format!("读取本地字体失败:{error}"))?; let source_file_name = path .file_name() .and_then(|value| value.to_str()) @@ -3432,12 +3435,18 @@ pub(crate) fn import_local_project_assets_for_agent_with_write_permit( reject_agent_local_resource_source_path(&normalized)?; let source = resolve_local_project_path(root, &normalized)?; prepare_game_creator_private_path_for_read(&source, false, "本地资源")?; - let metadata = - fs::symlink_metadata(&source).map_err(|_| format!("本地资源不存在:{normalized}"))?; + let metadata = fs::symlink_metadata(&source).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + format!("本地资源不存在:{normalized}") + } else { + format!("读取本地资源元数据失败:{normalized}:{error}") + } + })?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err(format!("本地素材只能是普通文件:{normalized}")); } - let bytes = fs::read(&source).map_err(|_| format!("读取本地资源失败:{normalized}"))?; + let bytes = fs::read(&source) + .map_err(|error| format!("读取本地资源失败:{normalized}:{error}"))?; let file_type = agent_local_project_file_type(&normalized, &bytes)?; if metadata.len() > file_type.max_file_size { return Err(format!( @@ -3491,7 +3500,7 @@ pub(crate) fn import_local_project_assets_for_agent_with_write_permit( if target.exists() { prepare_game_creator_private_path_for_read(&target, false, "目标资源")?; let existing_bytes = - fs::read(&target).map_err(|_| "读取目标资源失败".to_string())?; + fs::read(&target).map_err(|error| format!("读取目标资源失败:{error}"))?; if existing_bytes != bytes { return Err(format!("本地资源目标已存在且内容不同:{local_path}")); } @@ -3968,7 +3977,7 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result max_bytes { return Err("平台素材超过当前图片下载限制".to_string()); } @@ -3986,9 +3995,15 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result validate_requested_game_project_creation_root(requested), None => automatic_local_game_projects_root(app), diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index d90f1bb93..0e47ad40b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -4469,30 +4469,35 @@ pub(crate) async fn fetch_custom_llm_models( .redirect(reqwest::redirect::Policy::none()) .timeout(std::time::Duration::from_secs(15)) .build() - .map_err(|_| "初始化模型列表请求失败".to_string())?; + .map_err(|error| format!("初始化模型列表请求失败:{error}"))?; let mut response = client .get(url) .bearer_auth(llm.api_key.trim()) .send() .await - .map_err(|error| { - if error.is_timeout() { - "模型列表请求超时,请重试".to_string() - } else { - "无法连接模型端点,请检查 API 地址和网络".to_string() - } - })?; + .map_err(|error| format!("无法连接模型端点:{error};请检查 API 地址和网络"))?; if !response.status().is_success() { - return Err(format!( - "模型列表读取失败(HTTP {})", - response.status().as_u16() - )); + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + return Err(if detail.is_empty() { + format!("模型列表读取失败(HTTP {})", status.as_u16()) + } else { + format!("模型列表读取失败(HTTP {}):{detail}", status.as_u16()) + }); } let mut body = Vec::new(); while let Some(chunk) = response .chunk() .await - .map_err(|_| "读取模型列表响应失败或超时".to_string())? + .map_err(|error| format!("读取模型列表响应失败或超时:{error}"))? { if body.len() + chunk.len() > 1024 * 1024 { return Err("模型列表响应超过 1 MiB 上限".to_string()); @@ -4508,7 +4513,7 @@ pub(crate) async fn fetch_custom_llm_models( data: Vec, } let models: Models = serde_json::from_slice(&body) - .map_err(|_| "模型端点需返回 OpenAI 兼容的 data[].id 列表".to_string())?; + .map_err(|error| format!("模型端点需返回 OpenAI 兼容的 data[].id 列表:{error}"))?; let ids = models .data .into_iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs index f9b87d922..46a151a0e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs @@ -210,15 +210,15 @@ fn update_godot_authorized_project_at( if bytes.len() > 64 * 1024 { return Err("Godot 待清理项目归属超过限制".into()); } - let mut temporary = - tempfile::NamedTempFile::new_in(config).map_err(|_| "无法创建 Godot 项目归属记录")?; + let mut temporary = tempfile::NamedTempFile::new_in(config) + .map_err(|error| format!("无法创建 Godot 项目归属记录:{error}"))?; temporary .write_all(&bytes) .and_then(|_| temporary.as_file().sync_all()) - .map_err(|_| "无法持久保存 Godot 项目归属")?; + .map_err(|error| format!("无法持久保存 Godot 项目归属:{error}"))?; temporary .persist(config.join(GODOT_PROJECTS_FILE)) - .map_err(|_| "无法提交 Godot 项目归属记录")?; + .map_err(|error| format!("无法提交 Godot 项目归属记录:{}", error.error))?; Ok(()) } @@ -254,7 +254,7 @@ pub(crate) fn godot_project_cleanup_required(project: &Path) -> Result Ok(entries.next().is_some()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(_) => Err("无法确认 Godot 桥缓存是否已清理".into()), + Err(error) => Err(format!("无法确认 Godot 桥缓存是否已清理:{error}")), } } @@ -266,7 +266,7 @@ fn remove_fence(path: &Path) -> Result<(), String> { match std::fs::remove_file(path) { Ok(()) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(_) => Err("无法清理编辑器执行确认记录,继续阻断执行".into()), + Err(error) => Err(format!("无法清理编辑器执行确认记录:{error},继续阻断执行")), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs index 5025dc08c..ec6176520 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs @@ -22,6 +22,77 @@ const SCHEMA: &str = "agc-node-runtime.v1"; const PROBE_TIMEOUT: Duration = Duration::from_secs(10); const MAX_PROBE_BYTES: u64 = 4096; +/// 进程输出持续排空但只保留有界尾部;失败收口前精确脱敏,不能把 stderr 整份丢弃。 +#[derive(Default)] +struct CapturedProcessOutput { + bytes: Vec, + total: usize, +} + +async fn capture_process_output( + mut reader: impl tokio::io::AsyncRead + Unpin, + output: &mut CapturedProcessOutput, + limit: usize, +) -> Result<(), std::io::Error> { + let mut buffer = vec![0u8; 4096]; + loop { + let count = reader.read(&mut buffer).await?; + if count == 0 { + return Ok(()); + } + output.total = output.total.saturating_add(count); + output.bytes.extend_from_slice(&buffer[..count]); + let excess = output.bytes.len().saturating_sub(limit); + if excess > 0 { + output.bytes.drain(..excess); + } + } +} + +fn environment_failure(code: &str, detail: impl std::fmt::Display) -> String { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail.to_string(), + 1800, + ); + format!("{code}: {detail}") +} + +fn process_failure_output( + code: &str, + detail: impl std::fmt::Display, + stdout: &CapturedProcessOutput, + stderr: &CapturedProcessOutput, +) -> String { + // 优先保留 stderr(npm/Node 的具体故障通常在这里),stdout 留作构建报错的补充。 + environment_failure( + code, + format!( + "{detail};stdoutBytes={};stderrBytes={};stderr={};stdout={}", + stdout.total, + stderr.total, + String::from_utf8_lossy(&stderr.bytes), + String::from_utf8_lossy(&stdout.bytes), + ), + ) +} + +fn blocked_environment_report(error: &str) -> Value { + let candidate = error.split([':', ';']).next().unwrap_or_default(); + let code = if candidate.contains('-') + && candidate + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + candidate + } else { + "environment-check-unclassified" + }; + let diagnostic = + crate::agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), error, 1800); + json!({"status":"blocked", "code":code, "diagnostic":diagnostic}) +} + #[derive(Clone, Debug)] pub(crate) struct NodeRuntime { pub node: PathBuf, @@ -282,11 +353,488 @@ fn validate_bundle(directory: &Path) -> Result<(), String> { Ok(()) } +fn host_home_directory() -> Option { + #[cfg(windows)] + { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + } + #[cfg(not(windows))] + { + std::env::var_os("HOME").map(PathBuf::from) + } +} + +/// 受控 Node 安装前缀:`/bin/node` + `/lib/node_modules/npm`(fnm / nvm / 系统 +/// 发行版)或随包 `/node` + `/node_modules/npm`。宽泛宿主目录、用户 HOME 及其 +/// 祖先、逃逸符号链接都在这里失败关闭;命令沙箱复用同一校验,避免主机发现与 bwrap mount 两套 +/// 信任规则漂移。 +pub(crate) fn validate_node_installation_prefix(prefix: &Path) -> Result { + let canonical = fs::canonicalize(prefix) + .map_err(|error| format!("无法 canonicalize node 安装前缀:{error}"))?; + if !canonical.is_absolute() || canonical.parent().is_none() { + return Err("node 安装前缀必须是非根目录的绝对路径".to_string()); + } + const DENIED_ROOTS: [&str; 11] = [ + "/home", "/root", "/tmp", "/var", "/etc", "/proc", "/dev", "/run", "/sys", "/boot", "/srv", + ]; + if DENIED_ROOTS + .iter() + .any(|denied| canonical == Path::new(denied)) + { + return Err(format!( + "拒绝把宽泛宿主目录作为 node 安装前缀:{}", + canonical.display() + )); + } + if let Some(home) = host_home_directory().and_then(|home| fs::canonicalize(home).ok()) { + if canonical == home || home.starts_with(&canonical) { + return Err("拒绝把用户主目录或其祖先作为 node 安装前缀".to_string()); + } + } + let bin_node = canonical.join("bin").join(executable_name()); + let bundle_node = canonical.join(executable_name()); + let bin_npm_cli = canonical.join("lib/node_modules/npm/bin/npm-cli.js"); + let bundle_npm_cli = canonical.join("node_modules/npm/bin/npm-cli.js"); + let node = if bin_node.is_file() { + Some(bin_node) + } else if bundle_node.is_file() { + Some(bundle_node) + } else { + None + }; + let npm_cli = if bin_npm_cli.is_file() { + Some(bin_npm_cli) + } else if bundle_npm_cli.is_file() { + Some(bundle_npm_cli) + } else { + None + }; + let (node, npm_cli) = match (node, npm_cli) { + (Some(node), Some(npm_cli)) => (node, npm_cli), + _ => { + return Err(format!( + "{} 不是完整的 node 安装前缀(缺少 node 或 npm-cli.js)", + canonical.display() + )) + } + }; + for path in [&node, &npm_cli] { + let resolved = fs::canonicalize(path) + .map_err(|error| format!("node 安装前缀内路径不可解析:{error}"))?; + if !resolved.starts_with(&canonical) { + return Err("node 安装前缀存在逃逸符号链接".to_string()); + } + } + Ok(canonical) +} + +fn parse_numeric_version(value: &str) -> Option<(u64, u64, u64)> { + let value = value.trim().trim_start_matches('v'); + let mut parts = value.split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts + .next() + .map_or(Some(0), |part| part.parse::().ok())?; + let patch = parts + .next() + .map_or(Some(0), |part| part.parse::().ok())?; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct InstalledNodeVersion { + manager: &'static str, + version: (u64, u64, u64), + prefix: PathBuf, +} + +fn environment_managed_roots() -> (Vec, Vec) { + let mut fnm_roots = Vec::new(); + let mut nvm_roots = Vec::new(); + if let Some(dir) = std::env::var_os("FNM_DIR") { + fnm_roots.push(PathBuf::from(dir)); + } + if let Some(dir) = std::env::var_os("NVM_DIR") { + nvm_roots.push(PathBuf::from(dir)); + } + if let Some(home) = host_home_directory() { + fnm_roots.push(home.join(".local").join("share").join("fnm")); + nvm_roots.push(home.join(".nvm")); + } + dedup_paths(&mut fnm_roots); + dedup_paths(&mut nvm_roots); + (fnm_roots, nvm_roots) +} + +fn dedup_paths(paths: &mut Vec) { + let mut seen = BTreeSet::new(); + paths.retain(|path| seen.insert(path.clone())); +} + +fn installed_node_versions( + fnm_roots: &[PathBuf], + nvm_roots: &[PathBuf], +) -> Vec { + let mut installed = Vec::new(); + for root in fnm_roots { + installed.extend(collect_installed_node_versions( + &root.join("node-versions"), + "fnm", + true, + )); + } + for root in nvm_roots { + installed.extend(collect_installed_node_versions( + &root.join("versions").join("node"), + "nvm", + false, + )); + } + installed +} + +fn collect_installed_node_versions( + versions_dir: &Path, + manager: &'static str, + nested_installation: bool, +) -> Vec { + let Ok(entries) = fs::read_dir(versions_dir) else { + return Vec::new(); + }; + let mut installed = Vec::new(); + for entry in entries.flatten() { + let Some(version) = parse_numeric_version(&entry.file_name().to_string_lossy()) else { + continue; + }; + let candidate = if nested_installation { + entry.path().join("installation") + } else { + entry.path() + }; + let Ok(prefix) = validate_node_installation_prefix(&candidate) else { + continue; + }; + installed.push(InstalledNodeVersion { + manager, + version, + prefix, + }); + } + installed +} + +fn runtime_from_installed( + installed: &InstalledNodeVersion, + root: &Path, + path: &OsStr, +) -> Result { + let prefix = &installed.prefix; + let bin_node = prefix.join("bin").join(executable_name()); + let node = if bin_node.is_file() { + bin_node + } else { + prefix.join(executable_name()) + }; + let bin_npm_cli = prefix.join("lib/node_modules/npm/bin/npm-cli.js"); + let npm_cli = if bin_npm_cli.is_file() { + bin_npm_cli + } else { + prefix.join("node_modules/npm/bin/npm-cli.js") + }; + runtime_from_paths(root, node, npm_cli, installed.manager, path) +} + +/// 把 `.nvmrc` / `.node-version` / `engines.node` 里的单个比较项解释成对某个已安装版本的判定。 +/// 返回 `None` 表示当前比较项超出受支持子集,调用方必须整体回落到宿主 PATH,不能猜。故意不支持 +/// `||`、连字符区间、prerelease 与部分 `>` / `<=` 语义,避免用错误匹配替换用户选中的版本。 +fn comparator_matches_version(version: (u64, u64, u64), comparator: &str) -> Option { + let (operator, rest) = if let Some(rest) = comparator.strip_prefix(">=") { + (">=", rest) + } else if let Some(rest) = comparator.strip_prefix("<=") { + ("<=", rest) + } else if let Some(rest) = comparator.strip_prefix('>') { + (">", rest) + } else if let Some(rest) = comparator.strip_prefix('<') { + ("<", rest) + } else if let Some(rest) = comparator.strip_prefix('=') { + ("=", rest) + } else if let Some(rest) = comparator.strip_prefix('^') { + ("^", rest) + } else if let Some(rest) = comparator.strip_prefix('~') { + ("~", rest) + } else { + ("=", comparator) + }; + let rest = rest.trim(); + if rest.is_empty() { + return None; + } + if rest.eq_ignore_ascii_case("x") || rest == "*" || rest.eq_ignore_ascii_case("latest") { + return Some(true); + } + let mut parsed = Vec::new(); + for component in rest.split('.') { + if component.eq_ignore_ascii_case("x") || component == "*" { + break; + } + parsed.push(component.parse::().ok()?); + } + if parsed.is_empty() || parsed.len() > 3 { + return None; + } + let parts = parsed.len(); + let major = parsed[0]; + let minor = *parsed.get(1).unwrap_or(&0); + let patch = *parsed.get(2).unwrap_or(&0); + Some(match operator { + "=" => match parts { + 1 => version.0 == major, + 2 => version.0 == major && version.1 == minor, + _ => version == (major, minor, patch), + }, + ">=" => version >= (major, minor, patch), + ">" if parts < 3 => return None, + ">" => version > (major, minor, patch), + "<" => version < (major, minor, patch), + "<=" if parts < 3 => return None, + "<=" => version <= (major, minor, patch), + "^" => { + if major > 0 { + version >= (major, minor, patch) && version.0 == major + } else if parts >= 2 && minor > 0 { + version >= (0, minor, patch) && version.0 == 0 && version.1 == minor + } else if parts >= 3 { + version >= (0, 0, patch) && version.0 == 0 && version.1 == 0 && version.2 == patch + } else { + version.0 == 0 + } + } + "~" => { + if parts >= 2 { + version >= (major, minor, patch) && version.0 == major && version.1 == minor + } else { + version >= (major, 0, 0) && version.0 == major + } + } + _ => return None, + }) +} + +fn version_matches_pin(version: (u64, u64, u64), pin: &str) -> Option { + let pin = pin.trim(); + if pin.is_empty() { + return Some(true); + } + // `.nvmrc` 两种写法都常见:`v22.23.3` 与 `22.23.3`。 + let pin = pin.strip_prefix('v').unwrap_or(pin); + let lower = pin.to_ascii_lowercase(); + if matches!(lower.as_str(), "*" | "x" | "node" | "latest" | "lts/*") + || lower.starts_with("lts/") + { + return Some(true); + } + if lower.starts_with("iojs") || lower.contains("||") { + return None; + } + let mut any = false; + for comparator in pin.split_whitespace() { + any = true; + if !comparator_matches_version(version, comparator)? { + return Some(false); + } + } + if any { + Some(true) + } else { + None + } +} + +enum PinSelection<'a> { + Matched(&'a InstalledNodeVersion), + NoMatch, + Unsupported, +} + +fn select_pinned_installation<'a>( + installed: &'a [InstalledNodeVersion], + pin: &str, +) -> PinSelection<'a> { + let mut best: Option<&InstalledNodeVersion> = None; + let mut understood = false; + for candidate in installed { + match version_matches_pin(candidate.version, pin) { + Some(true) => { + understood = true; + if best.is_none_or(|current| candidate.version > current.version) { + best = Some(candidate); + } + } + Some(false) => understood = true, + None => return PinSelection::Unsupported, + } + } + match best { + Some(best) => PinSelection::Matched(best), + None if understood => PinSelection::NoMatch, + None => PinSelection::Unsupported, + } +} + +fn read_project_version_file_pin(root: &Path) -> Option { + for name in [".nvmrc", ".node-version"] { + let path = root.join(name); + let Ok(metadata) = fs::metadata(&path) else { + continue; + }; + if !metadata.is_file() || metadata.len() > 4096 { + continue; + } + let Ok(content) = fs::read_to_string(&path) else { + continue; + }; + if let Some(pin) = content + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with('#')) + { + return Some(pin.to_string()); + } + } + None +} + +fn read_project_engines_range(root: &Path) -> Option { + let path = root.join("package.json"); + let metadata = fs::metadata(&path).ok()?; + if !metadata.is_file() || metadata.len() > 4 * 1024 * 1024 { + return None; + } + let bytes = fs::read(&path).ok()?; + let value: Value = serde_json::from_slice(&bytes).ok()?; + let range = value.get("engines")?.get("node")?.as_str()?.trim(); + if range.is_empty() { + None + } else { + Some(range.to_string()) + } +} + +fn active_managed_prefix() -> Option { + if let Some(path) = std::env::var_os("FNM_MULTISHELL_PATH") { + if let Ok(prefix) = validate_node_installation_prefix(Path::new(&path)) { + return Some(prefix); + } + } + if let Some(bin) = std::env::var_os("NVM_BIN") { + if let Some(parent) = Path::new(&bin).parent() { + if let Ok(prefix) = validate_node_installation_prefix(parent) { + return Some(prefix); + } + } + } + None +} + +fn default_managed_installation<'a>( + installed: &'a [InstalledNodeVersion], + fnm_roots: &[PathBuf], + nvm_roots: &[PathBuf], +) -> Option<&'a InstalledNodeVersion> { + for root in fnm_roots { + let alias = root.join("aliases").join("default"); + if let Ok(prefix) = validate_node_installation_prefix(&alias) { + if let Some(node) = installed.iter().find(|node| node.prefix == prefix) { + return Some(node); + } + } + } + for root in nvm_roots { + let Ok(content) = fs::read_to_string(root.join("alias").join("default")) else { + continue; + }; + // nvm 的 default 别名常写成 `22`、`v22.23.3` 或 `lts/*`,不是完整三元组;按 pin 的 + // 同一子集在已安装版本里选最高匹配,避免 `22` 被当成 (22,0,0) 而永远匹配不到。 + if let PinSelection::Matched(node) = select_pinned_installation(installed, content.trim()) { + return Some(node); + } + } + None +} + +fn resolve_pinned_managed_runtime( + root: &Path, + path: &OsStr, + fnm_roots: &[PathBuf], + nvm_roots: &[PathBuf], +) -> Result, String> { + let installed = installed_node_versions(fnm_roots, nvm_roots); + if installed.is_empty() { + return Ok(None); + } + let Some(pin) = read_project_version_file_pin(root) else { + return Ok(None); + }; + match select_pinned_installation(&installed, &pin) { + PinSelection::Matched(node) => Ok(Some(runtime_from_installed(node, root, path)?)), + PinSelection::NoMatch => Err("node-version-pinned-not-installed".into()), + PinSelection::Unsupported => Ok(None), + } +} + +fn resolve_managed_fallback_runtime( + root: &Path, + path: &OsStr, + fnm_roots: &[PathBuf], + nvm_roots: &[PathBuf], +) -> Result, String> { + let installed = installed_node_versions(fnm_roots, nvm_roots); + if installed.is_empty() { + return Ok(None); + } + // engines.node 只作为“在已安装版本里优先选谁”的偏好,不阻塞;真正的版本文件 pin 才失败关闭。 + if let Some(range) = read_project_engines_range(root) { + if let PinSelection::Matched(node) = select_pinned_installation(&installed, &range) { + return Ok(Some(runtime_from_installed(node, root, path)?)); + } + } + if let Some(prefix) = active_managed_prefix() { + if let Some(node) = installed.iter().find(|node| node.prefix == prefix) { + return Ok(Some(runtime_from_installed(node, root, path)?)); + } + } + if let Some(node) = default_managed_installation(&installed, fnm_roots, nvm_roots) { + return Ok(Some(runtime_from_installed(node, root, path)?)); + } + let node = installed + .iter() + .max_by_key(|node| node.version) + .expect("non-empty installed versions"); + Ok(Some(runtime_from_installed(node, root, path)?)) +} + fn resolve_at( root: &Path, bundle: Option<&Path>, development: bool, path: &OsStr, +) -> Result { + let (fnm_roots, nvm_roots) = environment_managed_roots(); + resolve_at_with_managed_roots(root, bundle, development, path, &fnm_roots, &nvm_roots) +} + +fn resolve_at_with_managed_roots( + root: &Path, + bundle: Option<&Path>, + development: bool, + path: &OsStr, + fnm_roots: &[PathBuf], + nvm_roots: &[PathBuf], ) -> Result { let root = root .canonicalize() @@ -304,16 +852,31 @@ fn resolve_at( if !development { return Err("node-runtime-bundle-missing".into()); } - let directories = safe_directories(&root, path); + if let Some(runtime) = resolve_pinned_managed_runtime(&root, path, fnm_roots, nvm_roots)? { + return Ok(runtime); + } + if let Some(runtime) = resolve_host_path_runtime(&root, path) { + return Ok(runtime); + } + if let Some(runtime) = resolve_managed_fallback_runtime(&root, path, fnm_roots, nvm_roots)? { + return Ok(runtime); + } + Err("node-npm-runtime-missing".into()) +} + +fn resolve_host_path_runtime(root: &Path, path: &OsStr) -> Option { + let directories = safe_directories(root, path); for directory in &directories { let candidate = directory.join(executable_name()); let Ok(node) = candidate.canonicalize() else { continue; }; - if !node.is_file() || node.starts_with(&root) { + if !node.is_file() || node.starts_with(root) { continue; } - let parent = node.parent().ok_or("node-runtime-invalid")?; + let Some(parent) = node.parent() else { + continue; + }; let mut npm_candidates = vec![ parent.join("node_modules/npm/bin/npm-cli.js"), parent.join("../lib/node_modules/npm/bin/npm-cli.js"), @@ -330,10 +893,12 @@ fn resolve_at( .into_iter() .find(|candidate| candidate.is_file()) { - return runtime_from_paths(&root, node, npm_cli, "development", path); + if let Ok(runtime) = runtime_from_paths(root, node, npm_cli, "development", path) { + return Some(runtime); + } } } - Err("node-npm-runtime-missing".into()) + None } pub(crate) fn resolve_node_runtime(root: &Path) -> Result { @@ -357,11 +922,14 @@ fn valid_version(value: &str) -> bool { async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result { // npm 初始化也会查询用户目录;显式隔离,避免依赖 Windows 后备查询。 - let home = tempfile::tempdir().map_err(|_| "runtime-probe-home-unavailable")?; + let home = tempfile::tempdir() + .map_err(|error| environment_failure("runtime-probe-home-unavailable", error))?; let config = home.path().join("user.npmrc"); let global_config = home.path().join("global.npmrc"); - fs::write(&config, b"").map_err(|_| "runtime-probe-home-unavailable")?; - fs::write(&global_config, b"").map_err(|_| "runtime-probe-home-unavailable")?; + fs::write(&config, b"") + .map_err(|error| environment_failure("runtime-probe-home-unavailable", error))?; + fs::write(&global_config, b"") + .map_err(|error| environment_failure("runtime-probe-home-unavailable", error))?; let mut command = tokio::process::Command::new(&runtime.node); command .env_clear() @@ -377,7 +945,7 @@ async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result Result MAX_PROBE_BYTES { - return Err("runtime-version-output-too-large"); - } - let status = child - .wait() - .await - .map_err(|_| "runtime-version-wait-failed")?; + let (out, err, status) = tokio::join!( + capture_process_output(stdout, &mut output, MAX_PROBE_BYTES as usize), + capture_process_output(stderr, &mut error_output, MAX_PROBE_BYTES as usize), + child.wait(), + ); + out.map_err(|error| environment_failure("runtime-version-output-failed", error))?; + err.map_err(|error| environment_failure("runtime-version-output-failed", error))?; + let status = + status.map_err(|error| environment_failure("runtime-version-wait-failed", error))?; if !status.success() { - return Err("runtime-version-failed"); + return Err(process_failure_output( + "runtime-version-failed", + format!("exitStatus={status}"), + &output, + &error_output, + )); + } + if output.total as u64 > MAX_PROBE_BYTES { + return Err(process_failure_output( + "runtime-version-output-too-large", + format!("版本输出超过 {MAX_PROBE_BYTES} 字节上限"), + &output, + &error_output, + )); } Ok(()) }) @@ -421,15 +1002,23 @@ async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result Value { let result = tokio::task::spawn_blocking(move || resolve_node_runtime(&root)).await; let runtime = match result { Ok(Ok(runtime)) => runtime, - Ok(Err(code)) => { - return json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()}) + Ok(Err(error)) => { + let mut blocked = blocked_environment_report(&error); + blocked["elapsedMs"] = json!(started.elapsed().as_millis()); + return blocked; } - Err(_) => { - return json!({"status":"blocked","code":"runtime-check-failed","elapsedMs":started.elapsed().as_millis()}) + Err(error) => { + return blocked_environment_report(&environment_failure( + "runtime-check-failed", + error, + )); } }; let (node, npm) = tokio::join!( @@ -458,8 +1052,10 @@ pub(crate) async fn check_environment(root: &Path) -> Value { (Ok(node), Ok(npm)) => { json!({"status":"ready","source":runtime.source,"nodeVersion":node,"npmVersion":npm,"elapsedMs":started.elapsed().as_millis()}) } - (Err(code), _) | (_, Err(code)) => { - json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()}) + (Err(error), _) | (_, Err(error)) => { + let mut blocked = blocked_environment_report(&error); + blocked["elapsedMs"] = json!(started.elapsed().as_millis()); + blocked } } }; @@ -469,8 +1065,10 @@ pub(crate) async fn check_environment(root: &Path) -> Value { Ok(browser) => { json!({"status":"ready","kind":browser.kind,"product":browser.product,"protocolVersion":browser.protocol_version,"elapsedMs":started.elapsed().as_millis()}) } - Err(code) => { - json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()}) + Err(error) => { + let mut blocked = blocked_environment_report(&error); + blocked["elapsedMs"] = json!(started.elapsed().as_millis()); + blocked } } }; @@ -617,12 +1215,34 @@ mod tests { fs::write(root.path().join(executable_name()), b"fake").unwrap(); let path = std::env::join_paths([Path::new("."), root.path()]).unwrap(); assert_eq!( - resolve_at(root.path(), None, true, &path).unwrap_err(), + resolve_at_with_managed_roots(root.path(), None, true, &path, &[], &[]).unwrap_err(), "node-npm-runtime-missing" ); assert!(!valid_version("v24.0.0\nSECRET")); } + #[test] + fn runtime_probe_failure_keeps_bounded_redacted_process_detail() { + let stdout = CapturedProcessOutput { + bytes: b"npm version probe".to_vec(), + total: 18, + }; + let stderr = CapturedProcessOutput { + bytes: b"EACCES: permission denied; Authorization: Bearer private-token; C:\\Users\\private\\npmrc".to_vec(), + total: 91, + }; + let detail = process_failure_output( + "runtime-version-failed", + "exitStatus=code:1", + &stdout, + &stderr, + ); + assert!(detail.contains("runtime-version-failed"), "{detail}"); + assert!(detail.contains("EACCES"), "{detail}"); + assert!(!detail.contains("private-token"), "{detail}"); + assert!(!detail.contains("C:\\Users\\private"), "{detail}"); + } + #[test] fn diagnostic_path_removes_runtime_and_project_entries_but_keeps_other_tools() { let project = tempfile::tempdir().unwrap(); @@ -682,4 +1302,227 @@ mod tests { "1.0.0" ); } + + fn install_node_fixture(prefix: &Path) { + let bin = prefix.join("bin"); + let npm = prefix.join("lib/node_modules/npm/bin"); + fs::create_dir_all(&bin).unwrap(); + fs::create_dir_all(&npm).unwrap(); + fs::write(bin.join(executable_name()), b"node").unwrap(); + fs::write(npm.join("npm-cli.js"), b"npm").unwrap(); + } + + fn install_fnm_version(root: &Path, version: &str) -> PathBuf { + let installation = root + .join("node-versions") + .join(version) + .join("installation"); + install_node_fixture(&installation); + installation + } + + fn install_nvm_version(root: &Path, version: &str) -> PathBuf { + let prefix = root.join("versions").join("node").join(version); + install_node_fixture(&prefix); + prefix + } + + #[test] + fn node_installation_prefix_rejects_home_incomplete_and_symlink_escape() { + let fnm_root = tempfile::tempdir().unwrap(); + let installation = install_fnm_version(fnm_root.path(), "v22.23.3"); + assert_eq!( + validate_node_installation_prefix(&installation).unwrap(), + installation.canonicalize().unwrap() + ); + if let Some(home) = host_home_directory() { + assert!(validate_node_installation_prefix(&home).is_err()); + } + let incomplete = tempfile::tempdir().unwrap(); + fs::create_dir_all(incomplete.path().join("bin")).unwrap(); + fs::write( + incomplete.path().join("bin").join(executable_name()), + b"node", + ) + .unwrap(); + assert!(validate_node_installation_prefix(incomplete.path()).is_err()); + + let link_root = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + + let valid_link = link_root.path().join("installation-link"); + symlink(&installation, &valid_link).unwrap(); + // 指向真实安装的 symlink 解析后仍是完整前缀。 + assert_eq!( + validate_node_installation_prefix(&valid_link).unwrap(), + installation.canonicalize().unwrap() + ); + let version_link = link_root.path().join("version-link"); + symlink( + fnm_root.path().join("node-versions").join("v22.23.3"), + &version_link, + ) + .unwrap(); + // 解析后不是安装前缀(缺 bin/node + lib/node_modules/npm)必须失败关闭。 + assert!(validate_node_installation_prefix(&version_link).is_err()); + } + let _ = link_root; + } + + #[test] + fn collects_fnm_and_nvm_installations_and_falls_back_to_highest() { + let fnm_root = tempfile::tempdir().unwrap(); + let nvm_root = tempfile::tempdir().unwrap(); + install_fnm_version(fnm_root.path(), "v22.23.3"); + install_nvm_version(nvm_root.path(), "v20.11.0"); + let installed = installed_node_versions( + &[fnm_root.path().to_path_buf()], + &[nvm_root.path().to_path_buf()], + ); + assert_eq!(installed.len(), 2); + assert_eq!(installed[0].manager, "fnm"); + assert_eq!(installed[1].manager, "nvm"); + + let project = tempfile::tempdir().unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[nvm_root.path().to_path_buf()], + ) + .unwrap(); + assert_eq!(runtime.source, "fnm"); + assert!(runtime.node.to_string_lossy().contains("v22.23.3")); + } + + #[test] + fn nvm_major_only_default_alias_selects_the_installed_patch() { + let nvm_root = tempfile::tempdir().unwrap(); + install_nvm_version(nvm_root.path(), "v20.11.0"); + let expected = install_nvm_version(nvm_root.path(), "v22.23.3"); + let alias_dir = nvm_root.path().join("alias"); + fs::create_dir_all(&alias_dir).unwrap(); + // `nvm alias default 22` 写的是主版本号,不是完整三元组。 + fs::write(alias_dir.join("default"), "22\n").unwrap(); + let installed = installed_node_versions(&[], &[nvm_root.path().to_path_buf()]); + assert_eq!(installed.len(), 2); + let default = + default_managed_installation(&installed, &[], &[nvm_root.path().to_path_buf()]) + .expect("major-only default alias should resolve"); + assert_eq!(default.prefix, expected.canonicalize().unwrap()); + assert_eq!(default.version, (22, 23, 3)); + } + + #[test] + fn version_file_pin_is_authoritative_and_blocks_when_not_installed() { + let fnm_root = tempfile::tempdir().unwrap(); + install_fnm_version(fnm_root.path(), "v20.11.0"); + install_fnm_version(fnm_root.path(), "v22.23.3"); + let project = tempfile::tempdir().unwrap(); + fs::write(project.path().join(".nvmrc"), "20\n").unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[], + ) + .unwrap(); + assert_eq!(runtime.source, "fnm"); + assert!(runtime.node.to_string_lossy().contains("v20.11.0")); + + fs::write(project.path().join(".nvmrc"), "v18.0.0\n").unwrap(); + assert_eq!( + resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[], + ) + .unwrap_err(), + "node-version-pinned-not-installed" + ); + } + + #[test] + fn engines_range_is_a_preference_and_never_blocks() { + let fnm_root = tempfile::tempdir().unwrap(); + install_fnm_version(fnm_root.path(), "v20.11.0"); + install_fnm_version(fnm_root.path(), "v22.23.3"); + let project = tempfile::tempdir().unwrap(); + fs::write( + project.path().join("package.json"), + r#"{"engines":{"node":"^20.0.0"}}"#, + ) + .unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[], + ) + .unwrap(); + assert!(runtime.node.to_string_lossy().contains("v20.11.0")); + + fs::write( + project.path().join("package.json"), + r#"{"engines":{"node":"^18.0.0"}}"#, + ) + .unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[], + ) + .unwrap(); + assert!(runtime.node.to_string_lossy().contains("v22.23.3")); + } + + #[test] + fn unsupported_version_pin_falls_back_instead_of_blocking() { + let fnm_root = tempfile::tempdir().unwrap(); + install_fnm_version(fnm_root.path(), "v22.23.3"); + let project = tempfile::tempdir().unwrap(); + for pin in ["iojs\n", ">20\n", "<=20\n"] { + fs::write(project.path().join(".node-version"), pin).unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[], + ) + .unwrap(); + assert_eq!(runtime.source, "fnm"); + } + } + + #[test] + fn version_pin_comparators_follow_the_documented_subset() { + assert_eq!(version_matches_pin((20, 11, 0), "20"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "20.11"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "20.11.0"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "22"), Some(false)); + assert_eq!(version_matches_pin((20, 11, 0), "^20.0.0"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "~20.11.0"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), ">=20 <23"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "lts/*"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "lts/iron"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), ">=22 || 20"), None); + assert_eq!(version_matches_pin((20, 11, 0), ">20"), None); + assert_eq!(version_matches_pin((20, 11, 0), "<=20"), None); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs index 018e9ff92..5c3d1ee48 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs @@ -179,24 +179,6 @@ pub(crate) fn record_new_web_scaffold_at(root: &Path, project_id: &str) -> Resul write_receipt(root, &receipt) } -async fn drain_output( - mut reader: R, -) -> Result<(), std::io::Error> { - // 保留输出大小界限;正文不进入预检回执,也不因管道满而阻塞子进程。 - let mut total = 0usize; - let mut buffer = [0u8; 4096]; - loop { - let count = reader.read(&mut buffer).await?; - if count == 0 { - return Ok(()); - } - total = total.saturating_add(count); - if total > MAX_COMMAND_OUTPUT { - total = MAX_COMMAND_OUTPUT; - } - } -} - async fn host_npm( runtime: &NodeRuntime, cwd: &Path, @@ -205,11 +187,14 @@ async fn host_npm( online: bool, owner: Option<(&Path, &PreparationOwner)>, ) -> Result<(), String> { - let home = tempfile::tempdir().map_err(|_| "web-build-home-unavailable")?; + let home = tempfile::tempdir() + .map_err(|error| environment_failure("web-build-home-unavailable", error))?; let config = home.path().join("user.npmrc"); let global_config = home.path().join("global.npmrc"); - fs::write(&config, b"").map_err(|_| "web-build-home-unavailable")?; - fs::write(&global_config, b"").map_err(|_| "web-build-home-unavailable")?; + fs::write(&config, b"") + .map_err(|error| environment_failure("web-build-home-unavailable", error))?; + fs::write(&global_config, b"") + .map_err(|error| environment_failure("web-build-home-unavailable", error))?; let mut execution = NpmExecution::new(); if let Some((root, owner)) = owner { record_execution(root, owner, Some(execution.clone()))?; @@ -251,9 +236,11 @@ async fn host_npm( use std::os::unix::process::CommandExt; command.as_std_mut().process_group(0); } - let mut child = command.spawn().map_err(|_| "web-build-start-failed")?; - let tree = - OwnedNpmTree::assign(&child, &execution).map_err(|_| "web-build-process-guard-failed")?; + let mut child = command + .spawn() + .map_err(|error| environment_failure("web-build-start-failed", error))?; + let tree = OwnedNpmTree::assign(&child, &execution) + .map_err(|error| environment_failure("web-build-process-guard-failed", error))?; execution.process = Some(ProcessIdentity::capture( child.id().ok_or("web-build-process-identity-missing")?, )?); @@ -265,17 +252,28 @@ async fn host_npm( stdin .write_all(b"GO\n") .await - .map_err(|_| "web-build-gate-failed")?; + .map_err(|error| environment_failure("web-build-gate-failed", error))?; drop(stdin); let stdout = child.stdout.take().ok_or("web-build-output-missing")?; let stderr = child.stderr.take().ok_or("web-build-output-missing")?; + let mut output = CapturedProcessOutput::default(); + let mut error_output = CapturedProcessOutput::default(); let result = tokio::time::timeout(timeout, async { - let (out, err, status) = - tokio::join!(drain_output(stdout), drain_output(stderr), child.wait()); - out.map_err(|_| "web-build-output-failed")?; - err.map_err(|_| "web-build-output-failed")?; - if !status.map_err(|_| "web-build-wait-failed")?.success() { - return Err("web-build-command-failed"); + let (out, err, status) = tokio::join!( + capture_process_output(stdout, &mut output, MAX_COMMAND_OUTPUT), + capture_process_output(stderr, &mut error_output, MAX_COMMAND_OUTPUT), + child.wait() + ); + out.map_err(|error| environment_failure("web-build-output-failed", error))?; + err.map_err(|error| environment_failure("web-build-output-failed", error))?; + let status = status.map_err(|error| environment_failure("web-build-wait-failed", error))?; + if !status.success() { + return Err(process_failure_output( + "web-build-command-failed", + format!("exitStatus={status}"), + &output, + &error_output, + )); } Ok(()) }) @@ -285,10 +283,19 @@ async fn host_npm( let _ = child.kill().await; let _ = child.wait().await; } - tree.terminate() - .map_err(|_| "web-build-subtree-not-reaped")?; + tree.terminate().map_err(|error| { + environment_failure( + "web-build-subtree-not-reaped", + format!("{error};原执行错误={result:?}"), + ) + })?; let deadline = Instant::now() + Duration::from_secs(5); - while !tree.empty().map_err(|_| "web-build-subtree-not-reaped")? { + while !tree.empty().map_err(|error| { + environment_failure( + "web-build-subtree-not-reaped", + format!("{error};原执行错误={result:?}"), + ) + })? { if Instant::now() >= deadline { return Err("web-build-subtree-not-reaped".into()); } @@ -297,9 +304,14 @@ async fn host_npm( if let Some((root, owner)) = owner { record_execution(root, owner, None)?; } - result - .map_err(|_| "web-build-timeout")? - .map_err(str::to_string) + result.map_err(|_| { + process_failure_output( + "web-build-timeout", + format!("npm 执行超过 {} ms", timeout.as_millis()), + &output, + &error_output, + ) + })? } /// 浏览器验证失败的宿主错误码。 @@ -349,11 +361,10 @@ fn browser_validation_failure_code(error: &str) -> &'static str { } } -fn browser_validation_diagnostic(error: &str) -> Option<&str> { - (error.starts_with("browser-recovery-") - || error.starts_with("browser-launch-failed:") - || error.starts_with("browser-cleanup-unconfirmed:")) - .then_some(error) +fn browser_validation_diagnostic(error: &str) -> Option { + let diagnostic = + crate::agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), error, 1800); + (!diagnostic.is_empty()).then_some(diagnostic) } pub(crate) async fn host_web_creation_preflight() -> Value { @@ -415,19 +426,8 @@ pub(crate) async fn host_web_creation_preflight() -> Value { } .await; let mut result = run.unwrap_or_else(|error| { - let (code, diagnostic) = error - .split_once(";diagnostic=") - .map_or((error.as_str(), None), |(code, diagnostic)| { - (code, Some(diagnostic)) - }); - let mut blocked = json!({ - "schemaVersion": "agc-web-creation-preflight.v1", - "status": "blocked", - "code": code, - }); - if let Some(diagnostic) = diagnostic { - blocked["diagnostic"] = json!(diagnostic); - } + let mut blocked = blocked_environment_report(&error); + blocked["schemaVersion"] = json!("agc-web-creation-preflight.v1"); blocked }); result["elapsedMs"] = json!(started.elapsed().as_millis()); @@ -623,12 +623,12 @@ mod tests { "web-preflight-browser-recovery-failed" ); assert_eq!( - browser_validation_diagnostic(recovery_diagnostic), + browser_validation_diagnostic(recovery_diagnostic).as_deref(), Some(recovery_diagnostic) ); assert_eq!( - browser_validation_diagnostic("启动浏览器失败:原始错误"), - None + browser_validation_diagnostic("启动浏览器失败:原始错误").as_deref(), + Some("启动浏览器失败:原始错误") ); assert_eq!( browser_validation_failure_code("宿主已停止本轮浏览器验证"), @@ -640,7 +640,7 @@ mod tests { "web-preflight-browser-cleanup-failed" ); assert_eq!( - browser_validation_diagnostic(cleanup_diagnostic), + browser_validation_diagnostic(cleanup_diagnostic).as_deref(), Some(cleanup_diagnostic) ); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs index b6f8bb86d..58f7a1f8b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs @@ -36,12 +36,17 @@ pub(crate) fn schedule_notification() { let (events, current_generation) = snapshot_with_generation(); LAST_EMITTED_GENERATION.store(current_generation, Ordering::Release); if !events.is_empty() { - let _ = handle.emit( + if let Err(error) = handle.emit( "error-report-updated", serde_json::json!({ "generation": current_generation, }), - ); + ) { + let _ = crate::append_application_log_line(&format!( + "RUST error_report::notifications: error_report.notification.emit_failed generation={} detail={error}", + current_generation + )); + } } if let Some(active) = TIMER_ACTIVE.get() { *active diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs index 8ec0d10f8..7739aa7f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs @@ -101,8 +101,15 @@ pub fn report_diagnostic_error( let action = action.map(|value| sanitize(value, MAX_ACTION_CHARS)); let page = page.map(|value| sanitize(value, MAX_PAGE_CHARS)); let sanitized_message = sanitize(message, MAX_MESSAGE_CHARS); - let message = - stable_codex_app_server_error_message(&sanitized_message).unwrap_or(sanitized_message); + // 分类字符串只用于稳定去重,不能覆盖用户在错误报告里看到的正文。 + // 之前这里直接把展示值替换成 `codex-app-server-error:`,导致 401/429、 + // IPC、stderr 等事实被压成一个编号;保留精确脱敏后的正文,另取稳定键参与指纹。 + let stable_message = stable_codex_app_server_error_message(&sanitized_message); + let fingerprint_message = stable_message + .as_deref() + .map(ToOwned::to_owned) + .unwrap_or_else(|| sanitized_message.clone()); + let message = sanitized_message; let stack = stack.map(|value| sanitize(value, MAX_STACK_CHARS)); if message.trim().is_empty() { return None; @@ -111,7 +118,7 @@ pub fn report_diagnostic_error( &source, action.as_deref().unwrap_or_default(), page.as_deref().unwrap_or_default(), - &message, + &fingerprint_message, raw_call_site.as_deref().unwrap_or_default(), ]); let timestamp = now(); @@ -122,6 +129,12 @@ pub fn report_diagnostic_error( if let Some(existing) = state.events.get_mut(&fingerprint) { existing.count = existing.count.saturating_add(1); existing.last_occurred_at = timestamp; + // 如果先到的是前端的短分类、后到的是宿主的详细正文,用后者补全列表。 + if stable_message.as_deref() == Some(existing.message.as_str()) + && message != existing.message + { + existing.message = message.clone(); + } (false, existing.clone()) } else { state.sequence = state.sequence.saturating_add(1); @@ -245,6 +258,33 @@ mod tests { .expect("frontend event"); assert_eq!(runtime.event_id, frontend.event_id); assert_eq!(snapshot()[0].count, 2); - assert_eq!(snapshot()[0].message, "codex-app-server-error:other"); + assert_eq!( + snapshot()[0].message, + "runtime 调用 LLM 失败:kind=codex-app-server-other fingerprint= chars=42" + ); + } + + #[test] + fn detailed_codex_app_server_message_upgrades_an_earlier_short_classification() { + reset_for_tests(); + let short = report_diagnostic_error( + "agent-runtime", + "codex-app-server-error:other", + None, + Some("agent-runtime"), + Some("project-supervisor"), + ) + .expect("short event"); + let detailed = report_agent_runtime_error( + "project-supervisor", + "runtime 调用 LLM 失败:kind=codex-app-server-other exitStatus=1 stderr=IPC EPIPE", + ) + .expect("detailed event"); + assert_eq!(short.event_id, detailed.event_id); + assert_eq!(snapshot()[0].count, 2); + assert_eq!( + snapshot()[0].message, + "runtime 调用 LLM 失败:kind=codex-app-server-other exitStatus=1 stderr=IPC EPIPE" + ); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs index 54698314f..ecb0adc23 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs @@ -12,7 +12,7 @@ use reqwest::StatusCode; use serde_json::Value; use sha2::{Digest, Sha256}; use shared_contracts::error_reports::{CreateErrorReportBatchRequest, ErrorReportLogInput, Event}; -use std::time::Duration; +use std::{path::Path, time::Duration}; use url::Url; const HTTP_TIMEOUT: Duration = Duration::from_secs(60); @@ -69,30 +69,71 @@ fn stable_submission_id(events: &[Event]) -> String { } fn response_error(status: StatusCode, body: &str) -> String { - if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); - } - if status == StatusCode::FORBIDDEN { - return "permission-denied: 当前账号没有提交错误报告的权限".to_string(); - } let detail = serde_json::from_str::(body) .ok() .and_then(|value| { - value - .get("error") - .and_then(|error| error.get("message")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) + let error = value.get("error").unwrap_or(&value); + Some( + ["message", "detail", "code", "additionalDetails"] + .into_iter() + .filter_map(|field| { + error + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + .collect::>() + .join(";"), + ) }) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); - format!("错误报告提交失败:{detail}") + .filter(|value| !value.is_empty()) + .or_else(|| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\"")) + .then_some(detail) + }) + .map(|detail| { + crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 600, + ) + }) + .filter(|value| !value.trim().is_empty()); + if status == StatusCode::UNAUTHORIZED { + return match detail { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; + } + if status == StatusCode::FORBIDDEN { + return format!( + "permission-denied: 当前账号没有提交错误报告的权限{}", + detail + .map(|detail| format!(":{detail}")) + .unwrap_or_default() + ); + } + format!( + "错误报告提交失败:HTTP {}{}", + status.as_u16(), + detail + .map(|detail| format!(":{detail}")) + .unwrap_or_default() + ) } fn response_data(body: &str) -> Result { let value: Value = serde_json::from_str(body) - .map_err(|_| "错误报告提交失败:响应不是合法 JSON".to_string())?; + .map_err(|error| format!("错误报告提交失败:响应不是合法 JSON:{error}"))?; Ok(value.get("data").cloned().unwrap_or(value)) } @@ -134,7 +175,7 @@ pub async fn submit_error_report( .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建错误报告客户端失败".to_string())?; + .map_err(|error| format!("创建错误报告客户端失败:{error}"))?; validate_session(&snapshot)?; let response = client .post(endpoint(&snapshot)?) @@ -144,12 +185,12 @@ pub async fn submit_error_report( .json(&payload) .send() .await - .map_err(|_| "错误报告提交失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("错误报告提交失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "错误报告提交失败:读取响应失败".to_string())?; + .map_err(|error| format!("错误报告提交失败:读取响应失败:{error}"))?; validate_session(&snapshot)?; if !status.is_success() { return Err(response_error(status, &body)); @@ -219,4 +260,16 @@ mod tests { assert_eq!(value["batchId"], "batch-1"); assert!(!value.to_string().contains("token")); } + + #[test] + fn response_error_keeps_status_and_redacted_server_detail() { + let detail = response_error( + StatusCode::TOO_MANY_REQUESTS, + r#"{"error":{"code":"RATE_LIMITED","message":"too many requests;token=secret"}}"#, + ); + assert!(detail.contains("HTTP 429"), "{detail}"); + assert!(detail.contains("RATE_LIMITED"), "{detail}"); + assert!(detail.contains("too many requests"), "{detail}"); + assert!(!detail.contains("token=secret"), "{detail}"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index e2484fb12..b2dab4c5d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -346,6 +346,15 @@ fn parse_error_payload(body: &str) -> (Option, Option) { .get("message") .and_then(Value::as_str) .map(str::to_string); + let message = message.or_else(|| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\"")) + .then_some(detail) + }); (code, message) } @@ -355,7 +364,7 @@ fn response_data(body: &str) -> Result { if value.get("ok").and_then(Value::as_bool) == Some(false) { let (code, message) = parse_error_payload(body); return Err(server_error_detail(code, message, || { - "服务器未返回错误信息".to_string() + "服务端响应 ok=false,但未提供 error/code/message 详情".to_string() })); } Ok(value.get("data").cloned().unwrap_or(value)) @@ -365,7 +374,12 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); let (code, message) = parse_error_payload(body); if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); + return match message.or(code) { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; } if status == StatusCode::FORBIDDEN { return format!( @@ -381,6 +395,10 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { .as_deref() .is_some_and(|value| value.trim_start().starts_with("VERSION_NUMBER_CONFLICT"))) { + let has_meaningful_message = message.as_deref().is_some_and(|value| { + let trimmed = value.trim(); + !trimmed.is_empty() && !trimmed.starts_with('{') + }); let detail = message .as_deref() .map(|value| { @@ -390,9 +408,16 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { .trim_start_matches([':', ':']) .trim() }) - .filter(|value| !value.is_empty()) + .filter(|value| has_meaningful_message && !value.is_empty()) .unwrap_or("服务端无法为这次发布分配版本号"); - return format!("版本号冲突:{detail}"); + let code_suffix = if !has_meaningful_message { + code.as_deref() + .map(|value| format!("(错误码:{value})")) + .unwrap_or_default() + } else { + String::new() + }; + return format!("版本号冲突:{detail}{code_suffix}"); } let detail = server_error_detail(code, message, || format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") @@ -428,11 +453,10 @@ async fn request_json( request = request.header(CONTENT_TYPE, "application/json").json(&body); } let response = request.send().await.map_err(|error| { - if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - } + format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_distribution_request_failure(&error) + ) })?; let status = response.status(); let text = response @@ -453,6 +477,34 @@ fn build_client() -> Result { .map_err(|error| format!("创建陶泥儿网络客户端失败:{error}")) } +fn describe_distribution_request_failure(error: &reqwest::Error) -> String { + let kind = if error.is_timeout() { + "请求超时" + } else if error.is_connect() { + "连接失败" + } else if error.is_body() { + "响应正文读取失败" + } else if error.is_request() { + "请求发送失败" + } else { + "网络请求失败" + }; + let mut causes = Vec::new(); + let mut source = std::error::Error::source(error); + while let Some(current) = source { + let text = current.to_string(); + if !text.is_empty() && !causes.contains(&text) { + causes.push(text); + } + source = current.source(); + } + if causes.is_empty() { + kind.to_string() + } else { + format!("{kind}:{}", causes.join(" → ")) + } +} + fn normalized_origin(value: &str) -> String { value.trim().trim_end_matches('/').to_string() } @@ -506,11 +558,10 @@ async fn request_json_for_read( ) .timeout(HTTP_TIMEOUT); let response = request.send().await.map_err(|error| { - GameDistributionReadError::Unavailable(if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - }) + GameDistributionReadError::Unavailable(format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_distribution_request_failure(&error) + )) })?; let status = response.status(); let text = response.text().await.map_err(|error| { @@ -627,7 +678,13 @@ async fn resolve_preview_url( if object_key.is_empty() { return None; } - let read_url = asset_read_url(&snapshot.api_base_url, object_key).ok()?; + let read_url = match asset_read_url(&snapshot.api_base_url, object_key) { + Ok(url) => url, + Err(error) => { + app_log!("读取发行素材预览地址构造失败:{error}"); + return None; + } + }; match crate::assets::resolve_external_asset_signed_url( client, snapshot.access_token.as_str(), @@ -1084,14 +1141,22 @@ async fn resolve_cover_queue( .error .clone() .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "服务器未返回错误信息".to_string())); + .unwrap_or_else(|| { + format!( + "封面生成任务返回 failed,但没有提供 error 详情;operationId={operation_id}" + ) + })); } return Ok(initial); } let started_at = tokio::time::Instant::now(); for _ in 0..COVER_QUEUE_MAX_POLLS { if started_at.elapsed() > COVER_QUEUE_TIMEOUT { - return Err("生成游戏封面超时,请稍后重试".to_string()); + return Err(format!( + "生成游戏封面超时(超过 {} 秒);operationId={operation_id},当前状态={}", + COVER_QUEUE_TIMEOUT.as_secs(), + queue.status.as_deref().unwrap_or("unknown") + )); } tokio::time::sleep(COVER_QUEUE_POLL_INTERVAL).await; let status = request_json( @@ -1117,7 +1182,11 @@ async fn resolve_cover_queue( return Err(status .error .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "服务器未返回错误信息".to_string())); + .unwrap_or_else(|| { + format!( + "封面生成任务返回 failed,但没有提供 error 详情;operationId={operation_id}" + ) + })); } if status.status == "completed" { let result = status.result.unwrap_or(Value::Null); @@ -1134,7 +1203,10 @@ async fn resolve_cover_queue( }); } } - Err("生成游戏封面超时,请稍后重试".to_string()) + Err(format!( + "生成游戏封面超时(轮询 {} 次仍未完成);operationId={operation_id}", + COVER_QUEUE_MAX_POLLS + )) } #[tauri::command] @@ -1290,7 +1362,7 @@ fn emit_publish_progress( received_bytes: u64, total_bytes: u64, ) { - let _ = app.emit( + if let Err(error) = app.emit( PACKAGE_UPLOAD_PROGRESS_EVENT, crate::game_package_upload::progress_event_payload( version_id, @@ -1300,7 +1372,14 @@ fn emit_publish_progress( received_bytes, total_bytes, ), - ); + ) { + app_log!( + "game_distribution.publish_progress.emit_failed publishId={} versionId={} phase={} detail={error}", + publish_id, + version_id, + phase + ); + } } #[tauri::command] @@ -1653,6 +1732,14 @@ mod tests { map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"), "authentication-required: 陶泥儿登录态已过期,请重新登录后重试" ); + assert_eq!( + map_http_error( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"access token expired"}}"#, + "读取失败", + ), + "authentication-required: 陶泥儿登录态已过期,请重新登录后重试:access token expired" + ); assert_eq!( map_http_error( StatusCode::BAD_REQUEST, @@ -1675,14 +1762,14 @@ mod tests { ), "版本号冲突:版本号已达上限 18446744073709551615,无法自动递增" ); - // 没有 message 时给出兜底提示,不泄漏原始英文代码。 + // 没有 message 时同时给出可读提示和服务端错误码,不能只剩一个编号。 assert_eq!( map_http_error( StatusCode::CONFLICT, r#"{"error":{"code":"VERSION_NUMBER_CONFLICT"}}"#, "创建游戏发行版本失败" ), - "版本号冲突:服务端无法为这次发布分配版本号" + "版本号冲突:服务端无法为这次发布分配版本号(错误码:VERSION_NUMBER_CONFLICT)" ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs index d90d18c3d..679ed41f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs @@ -129,7 +129,15 @@ fn parse_server_error(status: u16, body: &str) -> (Option, Option Option Vec { - let Ok(metadata) = fs::symlink_metadata(path) else { - return Vec::new(); +/// 读取账本;只有文件确实不存在时才返回空记录,损坏、权限和类型错误必须阻止发布, +/// 否则可能丢失幂等键并重复创建版本。 +fn read_attempts_at(path: &Path) -> Result, String> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("读取发布尝试账本元数据失败:{error}")), }; if metadata.file_type().is_symlink() || !metadata.is_file() { - return Vec::new(); + return Err("发布尝试账本必须是普通文件".to_string()); } - let Ok(content) = fs::read_to_string(path) else { - return Vec::new(); - }; - let Ok(file) = serde_json::from_str::(&content) else { - return Vec::new(); - }; + let content = + fs::read_to_string(path).map_err(|error| format!("读取发布尝试账本失败:{error}"))?; + let file = serde_json::from_str::(&content) + .map_err(|error| format!("解析发布尝试账本失败:{error}"))?; file.attempts .into_iter() - .filter_map(normalize_record) + .enumerate() + .map(|(index, record)| { + normalize_record(record) + .ok_or_else(|| format!("发布尝试账本第 {} 条记录无效", index + 1)) + }) .collect() } @@ -249,7 +254,7 @@ pub(crate) fn resolve_game_publish_root_key( let _guard = GAME_PUBLISH_ATTEMPT_LOCK .lock() .map_err(|_| "发布尝试账本锁不可用".to_string())?; - let mut attempts = read_attempts_at(&path); + let mut attempts = read_attempts_at(&path)?; if let Some(existing) = attempts .iter() .find(|record| record_matches_intent(record, &candidate)) @@ -295,7 +300,7 @@ pub(crate) fn clear_game_publish_attempt( let _guard = GAME_PUBLISH_ATTEMPT_LOCK .lock() .map_err(|_| "发布尝试账本锁不可用".to_string())?; - let mut attempts = read_attempts_at(&path); + let mut attempts = read_attempts_at(&path)?; let before = attempts.len(); attempts.retain(|record| { !(record.account_id == account_id @@ -464,6 +469,23 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn corrupt_attempt_ledger_returns_detail_instead_of_starting_a_new_attempt() { + let dir = fixture_dir("corrupt"); + fs::write( + game_publish_attempt_path(&dir), + br#"{"schemaVersion":"agc-game-publish-attempts.v1","attempts":["bad"]}"#, + ) + .expect("write corrupt ledger"); + let error = resolve_game_publish_root_key( + &dir, + intent("user-1", "https://dev.test", "proj-1", &"a".repeat(64)), + ) + .unwrap_err(); + assert!(error.contains("解析发布尝试账本失败"), "{error}"); + let _ = fs::remove_dir_all(&dir); + } + #[test] fn ledger_only_holds_identifiers_and_summaries() { let dir = fixture_dir("shape"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs index eb2d75db9..7c233a2e9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs @@ -29,14 +29,14 @@ struct ApiEnvelope { fn parse_catalog_payload(bytes: &[u8]) -> Result { let value: Value = serde_json::from_slice(bytes) - .map_err(|_| "模型列表响应不是有效 JSON,请稍后重试".to_string())?; + .map_err(|error| format!("模型列表响应不是有效 JSON:{error}"))?; // The platform API normally returns the versioned response envelope. Keep // accepting the raw payload for older dev proxies and local fixtures, but // never accept an error envelope as a successful catalog. if value.get("ok").is_some() { let envelope: ApiEnvelope = serde_json::from_value(value) - .map_err(|_| "模型列表响应格式无效,请稍后重试".to_string())?; + .map_err(|error| format!("模型列表响应格式无效:{error}"))?; if !envelope.ok { let message = envelope .error @@ -78,7 +78,7 @@ async fn read_bounded_body( .next() .await .transpose() - .map_err(|_| "读取模型列表响应失败或超时,请重试".to_string())? + .map_err(|error| format!("读取模型列表响应失败或超时:{error}"))? { if body.len().saturating_add(chunk.len()) > max_bytes { return Err("模型列表响应超过 1 MiB 上限".to_string()); @@ -107,13 +107,7 @@ async fn fetch_game_creator_llm_models( ) .send() .await - .map_err(|error| { - if error.is_timeout() { - "模型列表请求超时,请重试".to_string() - } else { - "无法连接模型服务,请检查网络后重试".to_string() - } - })?; + .map_err(|error| format!("无法连接模型服务:{error};请检查网络后重试"))?; let status = response.status(); let body = read_bounded_body(response, MODEL_CATALOG_MAX_BYTES).await?; @@ -148,7 +142,7 @@ pub(crate) async fn load_game_creator_llm_models() -> Result"); } } - let lowercase = sanitized.to_ascii_lowercase(); - if [ - "authorization", - "bearer ", - "api_key", - "apikey", - "api key", - "x-api-key", - "token=", - "token:", - "credential", - ] - .iter() - .any(|marker| lowercase.contains(marker)) - { - return "".to_string(); - } + // 错误日志是排障材料:只替换敏感值,不能因同一行出现 authorization/token/credential + // 字段就把 HTTP 状态、错误码和其它原因一起删掉。 + // 这里没有项目根时使用一个不会存在的哨兵路径,避免把日志中的普通句点当成项目路径替换。 + sanitized = + agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &sanitized, 2_048); sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized)); sanitized.chars().take(2_048).collect() } @@ -1450,8 +1438,13 @@ mod diagnostic_log_tests { fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() { assert_eq!( sanitize_diagnostic_message("Authorization: Bearer secret", None), - "" + "Authorization: Bearer [redacted-secret]" ); + let upstream = + sanitize_diagnostic_message("upstream HTTP 401: invalid token; retry-after=30", None); + assert!(upstream.contains("HTTP 401"), "{upstream}"); + assert!(upstream.contains("invalid token"), "{upstream}"); + assert!(upstream.contains("retry-after=30"), "{upstream}"); assert_eq!( sanitize_diagnostic_message(r"failed at C:\private\project\game.json", None), "failed at " diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs index 0fa86e867..1baccdd2c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs @@ -163,27 +163,80 @@ fn response_data(payload: Value) -> Value { fn response_error(status: StatusCode, body: &str, action: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); - if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); - } - if status == StatusCode::FORBIDDEN { - return format!("permission-denied: {action}权限不足"); - } - let detail = serde_json::from_str::(body) - .ok() - .and_then(|value| { - value - .get("error") - .and_then(|error| error.get("message")) + let parsed = serde_json::from_str::(body).ok(); + let error = parsed.as_ref().and_then(|value| value.get("error")); + let detail = ["message", "code", "detail", "additionalDetails"] + .into_iter() + .filter_map(|field| { + let value = error + .and_then(|value| value.get(field)) + .or_else(|| parsed.as_ref().and_then(|value| value.get(field))) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) - .map(ToString::to_string) + .map(ToString::to_string)?; + // 先把常见的 `token=... provider rejected` 形状切开,再交给统一脱敏器; + // 否则它会把整行当成长凭据,连后面的真实原因一起吞掉。 + Some(redact_upload_inline_secret_tail(value)) }) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + .collect::>() + .join(";"); + let detail = (!detail.is_empty()) + .then_some(detail) + .or_else(|| { + let safe = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!safe.is_empty() && !matches!(safe.trim(), "{}" | "null" | "\"\"")).then_some(safe) + }) + .map(|detail| { + crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &detail, + 600, + ) + }); + if status == StatusCode::UNAUTHORIZED { + return match detail { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; + } + if status == StatusCode::FORBIDDEN { + return format!( + "permission-denied: {action}权限不足{}", + detail + .map(|detail| format!(":{detail}")) + .unwrap_or_default() + ); + } + let detail = detail.unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{action}失败:{detail}") } +fn redact_upload_inline_secret_tail(mut value: String) -> String { + for key in ["token=", "api_key=", "api-key=", "authorization="] { + let Some(start) = value.to_ascii_lowercase().find(key) else { + continue; + }; + let value_start = start + key.len(); + let value_end = value[value_start..] + .find(|character: char| { + character.is_whitespace() || matches!(character, ';' | ';' | ',' | ',') + }) + .map(|offset| value_start + offset) + .unwrap_or(value.len()); + if value_end > value_start { + value.replace_range(value_start..value_end, "[redacted-secret]"); + } + } + value +} + fn upload_route() -> &'static str { "/api/assets/direct-upload-tickets" } @@ -217,19 +270,19 @@ async fn request_ticket( })) .send() .await - .map_err(|_| "创建素材上传凭证失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "创建素材上传凭证失败:读取响应失败".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:读取响应失败:{error}"))?; validate_session(snapshot)?; if !status.is_success() { return Err(response_error(status, &body, "创建素材上传凭证")); } let payload = serde_json::from_str::(&body) .map(response_data) - .map_err(|_| "创建素材上传凭证失败:响应格式无效".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:响应格式无效:{error}"))?; let upload = payload .get("upload") .ok_or_else(|| "创建素材上传凭证失败:响应缺少 upload".to_string())?; @@ -285,7 +338,7 @@ async fn request_ticket( } fn validate_upload_host(host: &str) -> Result { - let url = Url::parse(host.trim()).map_err(|_| "素材上传地址无效".to_string())?; + let url = Url::parse(host.trim()).map_err(|error| format!("素材上传地址无效:{error}"))?; let hostname = url.host_str().unwrap_or_default().to_ascii_lowercase(); let is_local = url.scheme() == "http" && matches!(hostname.as_str(), "127.0.0.1" | "localhost"); let is_oss = url.scheme() == "https" && hostname.ends_with(".aliyuncs.com"); @@ -307,7 +360,7 @@ async fn upload_object( .timeout(HTTP_TIMEOUT) .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|_| "创建素材上传客户端失败".to_string())?; + .map_err(|error| format!("创建素材上传客户端失败:{error}"))?; let mut form = Form::new(); for (key, value) in &ticket.form_fields { form = form.text(key.clone(), value.clone()); @@ -315,18 +368,24 @@ async fn upload_object( let part = Part::bytes(input.bytes.clone()) .file_name(input.file_name.clone()) .mime_str(&input.content_type) - .map_err(|_| "素材媒体类型无效".to_string())?; + .map_err(|error| format!("素材媒体类型无效:{error}"))?; let response = client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传素材失败:无法访问素材存储,请检查网络后重试".to_string())?; + .map_err(|error| format!("上传素材失败:无法访问素材存储:{error};请检查网络后重试"))?; validate_session(snapshot)?; - if response.status().as_u16() != ticket.success_action_status { - return Err(format!( - "上传素材到对象存储失败(HTTP {}),请重试", - response.status().as_u16() + let status = response.status(); + if status.as_u16() != ticket.success_action_status { + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "上传素材到对象存储", + status, + &body, )); } Ok(()) @@ -356,19 +415,19 @@ async fn confirm_object( })) .send() .await - .map_err(|_| "确认素材资产失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("确认素材资产失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "确认素材资产失败:读取响应失败".to_string())?; + .map_err(|error| format!("确认素材资产失败:读取响应失败:{error}"))?; validate_session(snapshot)?; if !status.is_success() { return Err(response_error(status, &body, "确认素材资产")); } let payload = serde_json::from_str::(&body) .map(response_data) - .map_err(|_| "确认素材资产失败:响应格式无效".to_string())?; + .map_err(|error| format!("确认素材资产失败:响应格式无效:{error}"))?; let asset = payload .get("assetObject") .ok_or_else(|| "确认素材资产失败:响应缺少 assetObject".to_string())?; @@ -403,7 +462,7 @@ pub(crate) async fn upload_platform_media_asset( .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建素材上传客户端失败".to_string())?; + .map_err(|error| format!("创建素材上传客户端失败:{error}"))?; let ticket = request_ticket(&client, &snapshot, &input).await?; upload_object(&snapshot, &ticket, &input).await?; confirm_object(&client, &snapshot, &ticket, &input).await @@ -450,4 +509,29 @@ mod tests { assert_eq!(payload["assetObjectId"], "asset-1"); assert!(!payload.to_string().contains("token")); } + + #[test] + fn response_error_keeps_server_code_and_redacted_body_when_message_is_missing() { + let detail = response_error( + StatusCode::BAD_REQUEST, + r#"{"error":{"code":"INVALID_ASSET","additionalDetails":"token=secret provider rejected"}}"#, + "确认素材资产", + ); + assert!(detail.contains("INVALID_ASSET"), "{detail}"); + assert!(!detail.contains("token=secret"), "{detail}"); + assert!(detail.contains("provider rejected"), "{detail}"); + } + + #[test] + fn response_error_keeps_auth_status_and_server_message() { + let detail = response_error( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"asset token expired"}}"#, + "上传素材", + ); + assert_eq!( + detail, + "authentication-required: 陶泥儿登录态已过期,请重新登录后重试:asset token expired" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_maintenance.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_maintenance.rs index 8135d41d7..c0ddf7ff1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_maintenance.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_maintenance.rs @@ -39,11 +39,16 @@ pub(crate) fn emit_platform_maintenance_notice(status: u16) { let Some(app) = PLATFORM_MAINTENANCE_APP_HANDLE.get() else { return; }; - let _ = tauri::Emitter::emit( + if let Err(error) = tauri::Emitter::emit( app, PLATFORM_MAINTENANCE_EVENT, serde_json::json!({ "code": "MAINTENANCE", "status": status }), - ); + ) { + app_log!( + "platform_maintenance.emit_failed status={} detail={error}", + status + ); + } } /// 在平台响应的错误分支上分类维护态:命中就广播一次,未命中什么都不做。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 7694d53f3..d527716d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -7,7 +7,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Component, Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; @@ -43,6 +43,7 @@ const RPC_TIMEOUT: Duration = Duration::from_secs(10); const EDITOR_RPC_TIMEOUT: Duration = Duration::from_secs(90); const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; const MAX_RPC_BYTES: usize = 2 * 1024 * 1024; +const MAX_PLUGIN_STDERR_BYTES: usize = 64 * 1024; const KNOWN_PERMISSIONS: &[&str] = &[ "events.subscribe", @@ -160,9 +161,10 @@ struct RunningPlugin { #[cfg(windows)] _job: crate::process_session::WindowsProcessJob, stdin: Arc>, - lines: Option>, + lines: Option>>, pending: PendingRpc, registrations: Arc>, + stderr_summary: Arc>, next_request_id: u64, editor_context: Option, active: Arc, @@ -513,7 +515,8 @@ fn workspace_plugin_sources(root: &Path) -> Result, Str Err(error) => return Err(format!("读取插件工作区失败:{error}")), }; let mut sources = Vec::new(); - for entry in entries.flatten() { + for entry in entries { + let entry = entry.map_err(|error| format!("读取插件工作区目录项失败:{error}"))?; let plugin_root = entry.path(); if !plugin_root.is_dir() || !plugin_root.join(PLUGIN_MANIFEST_FILE_NAME).is_file() { continue; @@ -577,7 +580,7 @@ fn spawn_plugin(manifest: &PluginManifest, root: &Path) -> Result Result stderr, + None => { + let _ = child.kill(); + let _ = child.wait(); + return Err("插件 stderr 不可用".to_string()); + } + }; + let stderr_summary = Arc::new(Mutex::new(String::new())); + let stderr_summary_writer = Arc::clone(&stderr_summary); + thread::spawn(move || { + let mut bytes = Vec::new(); + let result = stderr + .take((MAX_PLUGIN_STDERR_BYTES + 1) as u64) + .read_to_end(&mut bytes); + let summary = match result { + Ok(_) if bytes.len() > MAX_PLUGIN_STDERR_BYTES => { + "插件 stderr 超过大小上限".to_string() + } + Ok(_) => crate::sanitize_diagnostic_message(&String::from_utf8_lossy(&bytes), None) + .chars() + .take(1200) + .collect(), + Err(error) => format!("读取插件 stderr 失败:{error}"), + }; + if let Ok(mut stored) = stderr_summary_writer.lock() { + *stored = summary; + } + }); #[cfg(windows)] let job = match crate::process_session::WindowsProcessJob::assign_std(&child) { Ok(job) => job, @@ -627,9 +659,18 @@ fn spawn_plugin(manifest: &PluginManifest, root: &Path) -> Result { + if sender.send(Ok(line)).is_err() { + break; + } + } + Ok(None) => break, + Err(error) => { + let _ = sender.send(Err(error)); + break; + } } } }); @@ -641,6 +682,7 @@ fn spawn_plugin(manifest: &PluginManifest, root: &Path) -> Result Result, St loop { let buffer = reader .fill_buf() - .map_err(|_| "读取插件输出失败".to_string())?; + .map_err(|error| format!("读取插件输出失败:{error}"))?; if buffer.is_empty() { return if bytes.is_empty() { Ok(None) @@ -670,7 +712,7 @@ fn read_bounded_rpc_line(reader: &mut impl BufRead) -> Result, St if newline.is_some() { return String::from_utf8(bytes) .map(Some) - .map_err(|_| "插件输出不是 UTF-8".to_string()); + .map_err(|error| format!("插件输出不是 UTF-8:{error}")); } } } @@ -967,10 +1009,37 @@ impl PluginHost { .as_ref() .is_some_and(|existing| existing.status == "failed"); let mut running = existing.and_then(|existing| existing.running); - let exited = running - .as_mut() - .and_then(|running| running.child.try_wait().ok().flatten()) - .is_some(); + let process_error = + running + .as_mut() + .and_then(|running| match running.child.try_wait() { + Ok(Some(status)) => Some(if let Some(code) = status.code() { + format!( + "插件进程已退出:exitCode={code}{}", + running + .stderr_summary + .lock() + .ok() + .filter(|summary| !summary.is_empty()) + .map(|summary| format!(";stderr={summary}")) + .unwrap_or_default() + ) + } else { + format!( + "插件进程已被信号终止{}", + running + .stderr_summary + .lock() + .ok() + .filter(|summary| !summary.is_empty()) + .map(|summary| format!(";stderr={summary}")) + .unwrap_or_default() + ) + }), + Ok(None) => None, + Err(error) => Some(format!("读取插件进程状态失败:{error}")), + }); + let exited = process_error.is_some(); if exited { running = None; } @@ -993,11 +1062,7 @@ impl PluginHost { manifest, root: source.root, status, - last_error: if exited { - Some("插件进程已退出".to_string()) - } else { - previous_error - }, + last_error: process_error.or(previous_error), running, }, ); @@ -1177,17 +1242,37 @@ impl PluginHost { .plugins .get_mut(id) .ok_or_else(|| "插件不存在".to_string())?; + let mut stop_error = None; if let Some(mut running) = record.running.take() { - let _ = running.child.kill(); - let _ = running.child.wait(); + if let Err(error) = running.child.kill() { + stop_error = Some(format!("终止插件进程失败:{error}")); + } + match running.child.wait() { + Ok(_) if stop_error.is_none() => {} + Err(error) => { + stop_error = Some(format!("等待插件进程退出失败:{error}")); + } + _ => {} + } } - record.status = if record.manifest.enabled { + record.status = if stop_error.is_some() { + "failed".to_string() + } else if record.manifest.enabled { "stopped".to_string() } else { "disabled".to_string() }; - record.last_error = None; - audit(&root, id, "stop", true, None); + record.last_error = stop_error.clone(); + audit( + &root, + id, + "stop", + stop_error.is_none(), + stop_error.as_deref(), + ); + if let Some(error) = stop_error { + return Err(error); + } self.summary_locked(record) } @@ -1205,7 +1290,9 @@ impl PluginHost { } let mut cleanup = Ok(()); if !enabled { - let _ = self.stop(id); + self.stop(id) + .map(|_| ()) + .map_err(|error| format!("插件停止失败,未切换禁用状态:{error}"))?; if let Some(editor) = crate::editor_adapters::ManagedEditor::for_plugin(id) { let state = self .state @@ -1437,10 +1524,18 @@ impl PluginHost { let manifest = record.manifest.clone(); let root = root.to_path_buf(); thread::spawn(move || { - while let Ok(line) = lines.recv() { + let mut terminal_error = None; + while let Ok(line_result) = lines.recv() { if !active.load(Ordering::SeqCst) { break; } + let line = match line_result { + Ok(line) => line, + Err(error) => { + terminal_error = Some(error); + break; + } + }; let Ok(envelope) = serde_json::from_str::(&line) else { continue; }; @@ -1489,14 +1584,18 @@ impl PluginHost { json!({"jsonrpc":"2.0","id":id,"error":{"code":-32001,"message":error}}) } }; - let _ = write_rpc_shared(&writer, &payload); + if let Err(error) = write_rpc_shared(&writer, &payload) { + terminal_error = Some(format!("插件 RPC 响应写入失败:{error}")); + break; + } } } } if let Ok(mut waiting) = pending.lock() { let remaining = std::mem::take(&mut *waiting); + let terminal_error = terminal_error.unwrap_or_else(|| "插件进程已退出".to_string()); for (_, sender) in remaining { - let _ = sender.send(Err("插件进程已退出".to_string())); + let _ = sender.send(Err(terminal_error.clone())); } } }); diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs index b1856306d..b403d7ca5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs @@ -153,7 +153,12 @@ pub(crate) async fn set_agc_plugin_project_path( } fn emit_plugin_state_changed(app: &tauri::AppHandle, host: &PluginHost) { - if let Ok(extensions) = host.list_extensions() { - let _ = app.emit(AGC_PLUGIN_STATE_CHANGED_EVENT, extensions); + match host.list_extensions() { + Ok(extensions) => { + if let Err(error) = app.emit(AGC_PLUGIN_STATE_CHANGED_EVENT, extensions) { + app_log!("agc.plugin_state.emit_failed detail={error}"); + } + } + Err(error) => app_log!("agc.plugin_state.list_failed detail={error}"), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs index 410d3a6c1..145413fc3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs @@ -55,13 +55,19 @@ impl WindowsProcessJob { }; let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; if process.is_null() { - return Err("无法打开进程核对 Windows Job 归属".into()); + return Err(format!( + "无法打开进程核对 Windows Job 归属:{}", + std::io::Error::last_os_error() + )); } let mut in_job = 0; let okay = unsafe { IsProcessInJob(process, self.0, &mut in_job) }; unsafe { CloseHandle(process) }; if okay == 0 { - return Err("无法核对进程 Windows Job 归属".into()); + return Err(format!( + "无法核对进程 Windows Job 归属:{}", + std::io::Error::last_os_error() + )); } Ok(in_job != 0) } @@ -74,12 +80,18 @@ impl WindowsProcessJob { }; let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) }; if process.is_null() { - return Err("无法打开进程加入 Windows Job".into()); + return Err(format!( + "无法打开进程加入 Windows Job:{}", + std::io::Error::last_os_error() + )); } let okay = unsafe { AssignProcessToJobObject(self.0, process) }; unsafe { CloseHandle(process) }; if okay == 0 { - return Err("无法将进程加入 Windows Job".into()); + return Err(format!( + "无法将进程加入 Windows Job:{}", + std::io::Error::last_os_error() + )); } Ok(()) } @@ -120,7 +132,10 @@ impl WindowsProcessJob { } let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; if snapshot == INVALID_HANDLE_VALUE { - return Err("读取受控命令主线程失败".into()); + return Err(format!( + "读取受控命令主线程失败:{}", + std::io::Error::last_os_error() + )); } let mut entry = THREADENTRY32::default(); entry.dwSize = std::mem::size_of::() as u32; @@ -148,7 +163,10 @@ impl WindowsProcessJob { ) }; if thread.is_null() { - return Err("打开受控命令主线程失败".into()); + return Err(format!( + "打开受控命令主线程失败:{}", + std::io::Error::last_os_error() + )); } let belongs = unsafe { GetProcessIdOfThread(thread) } == pid; let previous = if belongs { @@ -158,7 +176,10 @@ impl WindowsProcessJob { }; unsafe { CloseHandle(thread) }; if previous != 1 { - return Err("受控命令恢复未确认,不能无门执行".into()); + return Err(format!( + "受控命令恢复未确认,不能无门执行:previousResumeCount={previous};{}", + std::io::Error::last_os_error() + )); } Ok(()) } @@ -215,7 +236,10 @@ impl WindowsProcessJob { ) }; if okay == 0 { - return Err("无法确认 Windows Job 子树已退出".into()); + return Err(format!( + "无法确认 Windows Job 子树已退出:{}", + std::io::Error::last_os_error() + )); } Ok(information.ActiveProcesses == 0) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/shutdown.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/shutdown.rs index e73457218..62997e3cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/shutdown.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/shutdown.rs @@ -27,13 +27,26 @@ pub(crate) fn shutdown_all_process_sessions() { } } } + // 收敛后的 `LiveProcessSession` 不再保留 `process_id` 字段,进程身份就是注册表的 key; + // 这里带上它,与 master 版 `recovery.rs` 的 `process_session.shutdown.send_failed` 日志同口径。 let sessions = process_session_registry() .lock() .ok() - .map(|registry| registry.sessions.values().cloned().collect::>()) + .map(|registry| { + registry + .sessions + .iter() + .map(|(process_id, live)| (process_id.clone(), live.clone())) + .collect::>() + }) .unwrap_or_default(); - for live in sessions { - let _ = live.control.send(ProcessControl::Shutdown); + for (process_id, live) in sessions { + if let Err(error) = live.control.send(ProcessControl::Shutdown) { + app_log!( + "process_session.shutdown.send_failed processId={} detail={error}", + process_id + ); + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs index 36ac074df..040ed3d36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs @@ -44,9 +44,11 @@ mod linux { launch: &ProjectCommandLaunchSpec, spec: &ProjectCommandSpec, ) -> Result { + let (target_executable, target_arguments) = + crate::command_exec::project_command_actual_target(spec); if !launch.executable.is_absolute() || !launch.cwd.is_absolute() - || !spec.executable.is_absolute() + || !target_executable.is_absolute() { return Err("process session bridge launch path 必须是绝对路径".to_string()); } @@ -68,8 +70,8 @@ mod linux { ) }) .collect(), - target_executable: spec.executable.as_os_str().as_bytes().to_vec(), - target_arguments: crate::command_exec::project_command_actual_arguments(spec) + target_executable: target_executable.as_os_str().as_bytes().to_vec(), + target_arguments: target_arguments .into_iter() .map(|argument| OsString::from(argument).into_vec()) .collect(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index 4896eaa65..e6e5442de 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -160,17 +160,18 @@ pub(crate) fn resolve_publish_build_cwd(root: &Path) -> Result metadata, - Err(_) => continue, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!("读取 {cwd}/package.json 元数据失败:{error}")); + } }; if metadata.file_type().is_symlink() || !metadata.is_file() { continue; } - let Ok(content) = fs::read_to_string(&package_path) else { - continue; - }; - let Ok(package) = serde_json::from_str::(&content) else { - continue; - }; + let content = fs::read_to_string(&package_path) + .map_err(|error| format!("读取 {cwd}/package.json 失败:{error}"))?; + let package = serde_json::from_str::(&content) + .map_err(|error| format!("解析 {cwd}/package.json 失败:{error}"))?; let declared = package .get("scripts") .and_then(|scripts| scripts.get("build")) @@ -344,16 +345,21 @@ fn declared_dependency_spec(package: &serde_json::Value, name: &str) -> Option"]`,v1 看 `dependencies`。 -fn locked_dependency_version(package_root: &Path, name: &str) -> Option { +fn locked_dependency_version(package_root: &Path, name: &str) -> Result, String> { let lock_path = package_root.join("package-lock.json"); - let content = fs::read_to_string(&lock_path).ok()?; - let lock: serde_json::Value = serde_json::from_str(&content).ok()?; + let content = match fs::read_to_string(&lock_path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("读取 package-lock.json 失败:{error}")), + }; + let lock: serde_json::Value = serde_json::from_str(&content) + .map_err(|error| format!("解析 package-lock.json 失败:{error}"))?; let from_packages = lock .get("packages") .and_then(|packages| packages.get(format!("node_modules/{name}"))) .and_then(|entry| entry.get("version")) .and_then(serde_json::Value::as_str); - from_packages + Ok(from_packages .or_else(|| { lock.get("dependencies") .and_then(|dependencies| dependencies.get(name)) @@ -361,7 +367,7 @@ fn locked_dependency_version(package_root: &Path, name: &str) -> Option .and_then(serde_json::Value::as_str) }) .map(|version| version.trim().to_string()) - .filter(|version| !version.is_empty()) + .filter(|version| !version.is_empty())) } /// 读取某个 npm 工作区的 `package.json`;不存在或不是普通文件时返回 `None`。 @@ -441,11 +447,9 @@ fn ensure_publish_project_stack_at( "发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{build_cwd_relative}/package.json 未声明 phaser 依赖。请先安装 Phaser 4(例如 npm install phaser@4.2.1)后重新发布。" )); }; - let major = declared_version_major(&spec).or_else(|| { - locked_dependency_version(&package_root, "phaser") - .as_deref() - .and_then(declared_version_major) - }); + let locked_version = locked_dependency_version(&package_root, "phaser")?; + let major = declared_version_major(&spec) + .or_else(|| locked_version.as_deref().and_then(declared_version_major)); if major.is_some_and(|major| major != 4) { return Err(format!( "发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{cwd_relative}/package.json 声明的 phaser 版本是 {spec}。请升级到 Phaser 4(例如 npm install phaser@4.2.1)后重新发布。" diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs index 605774f61..5de9bde7b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs @@ -898,7 +898,7 @@ fn validate_external_editor_binding_access_shape( fn normalize_external_editor_binding_service_origin(value: &str) -> Result { let value = value.trim().trim_end_matches('/'); let parsed = reqwest::Url::parse(value) - .map_err(|_| "External Editor binding 服务地址无效".to_string())?; + .map_err(|error| format!("External Editor binding 服务地址无效:{error}"))?; if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() || !parsed.username().is_empty() diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs index 024baef1d..4290d6005 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs @@ -111,7 +111,7 @@ fn new_record(context: &ProjectModelUsageContext, source: &str) -> ModelUsageRec fn read_private_bytes(path: &Path, max_bytes: u64) -> Result>, String> { match fs::symlink_metadata(path) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(_) => return Err("读取项目模型记录元数据失败".to_string()), + Err(error) => return Err(format!("读取项目模型记录元数据失败:{error}")), Ok(_) => {} } let (file, metadata) = open_project_private_regular_file(path, MODEL_USAGE_LABEL)?; @@ -121,7 +121,7 @@ fn read_private_bytes(path: &Path, max_bytes: u64) -> Result>, St let mut content = Vec::new(); file.take(max_bytes + 1) .read_to_end(&mut content) - .map_err(|_| "读取项目模型记录失败".to_string())?; + .map_err(|error| format!("读取项目模型记录失败:{error}"))?; if content.len() as u64 > max_bytes { return Err("项目模型记录超过读取上限".to_string()); } @@ -145,7 +145,7 @@ fn read_usage_records(path: &Path) -> Result<(Vec, u64), Strin return Err("项目模型记录超过记录上限".to_string()); } let record: ModelUsageRecord = serde_json::from_slice(line) - .map_err(|_| "项目模型记录损坏,已保留原文件".to_string())?; + .map_err(|error| format!("项目模型记录损坏,已保留原文件:{error}"))?; validate_record(&record)?; records.push(record); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 4aa2ec76e..7da3b0e59 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -1454,8 +1454,8 @@ fn resolve_resource_edit_source( RESOURCE_EDIT_TEXT_MAX_BYTES, "源文本资源", )?; - let text = - String::from_utf8(bytes).map_err(|_| "文本资源必须使用 UTF-8 编码".to_string())?; + let text = String::from_utf8(bytes) + .map_err(|error| format!("文本资源必须使用 UTF-8 编码:{error}"))?; return Ok(ResourceEditSourceSnapshot { canonical_resource_id, source_path: Some(path), @@ -1507,7 +1507,7 @@ fn resolve_resource_edit_source( let (source_width, source_height) = if input.edit_kind == LocalProjectResourceEditKind::CharacterAnimation { let decoded = image::load_from_memory(&bytes) - .map_err(|_| "生成角色动画前无法解析源图片尺寸".to_string())?; + .map_err(|error| format!("生成角色动画前无法解析源图片尺寸:{error}"))?; (Some(decoded.width()), Some(decoded.height())) } else { (None, None) @@ -1766,39 +1766,38 @@ async fn request_resource_edit_upload_ticket( ) .send() .await - .map_err(|_| "result-unknown: 创建源资源上传凭证未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 创建源资源上传凭证请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authentication_error()); + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } - if response.status() == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authorization_error()); + if status == reqwest::StatusCode::FORBIDDEN { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } - if !response.status().is_success() { - if response.status().is_server_error() { - return Err(format!( + if !status.is_success() { + let base = if status.is_server_error() { + format!( "result-unknown: 创建源资源上传凭证返回 HTTP {}", - response.status().as_u16() - )); - } - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(format!( - "创建源资源上传凭证失败:HTTP {}", - response.status().as_u16() - )); + status.as_u16() + ) + } else if let Some(error) = post_response_session_error.as_ref() { + error.clone() + } else { + format!("创建源资源上传凭证失败:HTTP {}", status.as_u16()) + }; + return Err(append_editor_api_reason(base, response).await); } let payload = response .json::() .await - .map_err(|_| "result-unknown: 解析源资源上传凭证失败".to_string())?; + .map_err(|error| format!("result-unknown: 解析源资源上传凭证失败:{error}"))?; if post_response_session_error.is_none() { post_response_session_error = access.validate_frozen_session().err(); } @@ -1877,11 +1876,11 @@ async fn upload_resource_edit_source( .ok_or_else(|| "源媒体上传缺少文件内容".to_string())?; let upload_url = validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) - .map_err(|_| "源资源上传地址不安全".to_string())?; + .map_err(|error| format!("源资源上传地址无效:{error}"))?; access.validate_frozen_session()?; let client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await - .map_err(|_| "无法创建源资源上传客户端".to_string())?; + .map_err(|error| format!("无法创建源资源上传客户端:{error}"))?; access.validate_frozen_session()?; let mut form = Form::new(); for (key, value) in &ticket.form_fields { @@ -1891,29 +1890,26 @@ async fn upload_resource_edit_source( let part = Part::bytes(bytes.clone()) .file_name(file_name) .mime_str(&source.media_type) - .map_err(|_| "源资源媒体类型不能用于上传".to_string())?; + .map_err(|error| format!("源资源媒体类型不能用于上传:{error}"))?; access.validate_frozen_session()?; let response = client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "result-unknown: 上传源资源未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 上传源资源请求失败:{error}"))?; let post_response_session_error = access.validate_frozen_session().err(); if response.status().as_u16() != ticket.success_action_status { + let status = response.status(); if response.status().is_server_error() { - return Err(format!( - "result-unknown: 上传源资源返回 HTTP {}", - response.status().as_u16() - )); + let base = format!("result-unknown: 上传源资源返回 HTTP {}", status.as_u16()); + return Err(append_editor_api_reason(base, response).await); } if let Some(error) = post_response_session_error { - return Err(error); + return Err(append_editor_api_reason(error, response).await); } - return Err(format!( - "上传源资源失败:HTTP {}", - response.status().as_u16() - )); + let base = format!("上传源资源失败:HTTP {}", status.as_u16()); + return Err(append_editor_api_reason(base, response).await); } Ok(ResourceEditRemoteStage { value: (), @@ -1953,39 +1949,38 @@ async fn confirm_resource_edit_source( ) .send() .await - .map_err(|_| "result-unknown: 确认源资源上传未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 确认源资源上传请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authentication_error()); + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } - if response.status() == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authorization_error()); + if status == reqwest::StatusCode::FORBIDDEN { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } - if !response.status().is_success() { - if response.status().is_server_error() { - return Err(format!( + if !status.is_success() { + let base = if status.is_server_error() { + format!( "result-unknown: 确认源资源上传返回 HTTP {}", - response.status().as_u16() - )); - } - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(format!( - "确认源资源上传失败:HTTP {}", - response.status().as_u16() - )); + status.as_u16() + ) + } else if let Some(error) = post_response_session_error.as_ref() { + error.clone() + } else { + format!("确认源资源上传失败:HTTP {}", status.as_u16()) + }; + return Err(append_editor_api_reason(base, response).await); } let payload = response .json::() .await - .map_err(|_| "result-unknown: 解析源资源确认响应失败".to_string())?; + .map_err(|error| format!("result-unknown: 解析源资源确认响应失败:{error}"))?; if post_response_session_error.is_none() { post_response_session_error = access.validate_frozen_session().err(); } @@ -2029,8 +2024,8 @@ async fn register_resource_edit_source_image( .bytes .as_deref() .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; - let decoded = - image::load_from_memory(bytes).map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; + let decoded = image::load_from_memory(bytes) + .map_err(|error| format!("登记源图片前无法解析图片尺寸:{error}"))?; let endpoint = format!( "/api/external/v1/editor/projects/{}/resources", percent_encode_query_component(remote_project_id) @@ -2065,39 +2060,38 @@ async fn register_resource_edit_source_image( ) .send() .await - .map_err(|_| "result-unknown: 登记源图片项目资源未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 登记源图片项目资源请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authentication_error()); + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } - if response.status() == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authorization_error()); + if status == reqwest::StatusCode::FORBIDDEN { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } - if !response.status().is_success() { - if response.status().is_server_error() { - return Err(format!( + if !status.is_success() { + let base = if status.is_server_error() { + format!( "result-unknown: 登记源图片项目资源返回 HTTP {}", - response.status().as_u16() - )); - } - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(format!( - "登记源图片项目资源失败:HTTP {}", - response.status().as_u16() - )); + status.as_u16() + ) + } else if let Some(error) = post_response_session_error.as_ref() { + error.clone() + } else { + format!("登记源图片项目资源失败:HTTP {}", status.as_u16()) + }; + return Err(append_editor_api_reason(base, response).await); } let payload = response .json::() .await - .map_err(|_| "result-unknown: 解析源图片项目资源响应失败".to_string())?; + .map_err(|error| format!("result-unknown: 解析源图片项目资源响应失败:{error}"))?; if post_response_session_error.is_none() { post_response_session_error = access.validate_frozen_session().err(); } @@ -2313,7 +2307,7 @@ async fn ensure_resource_edit_source_reference( .as_deref() .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; let decoded = image::load_from_memory(bytes) - .map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; + .map_err(|error| format!("登记源图片前无法解析图片尺寸:{error}"))?; let resource_id = if let Some(resource_id) = ledger.source_remote_resource_id.clone() { resource_id } else { @@ -2657,14 +2651,39 @@ async fn editor_api_rejection_reason(response: reqwest::Response) -> Option chunk, + Err(error) => { + return Some(format!("读取上游错误正文失败:{error}")); + } + }; if body.len() + chunk.len() > MAX_EDITOR_ERROR_BODY_BYTES { - return None; + return Some(format!( + "上游错误正文超过 {} 字节上限", + MAX_EDITOR_ERROR_BODY_BYTES + )); } body.extend_from_slice(&chunk); } - let body = String::from_utf8(body).ok()?; - editor_api_rejection_reason_from_body(body.as_str()) + let body = match String::from_utf8(body) { + Ok(body) => body, + Err(error) => return Some(format!("上游错误正文不是 UTF-8:{error}")), + }; + editor_api_rejection_reason_from_body(body.as_str()).or_else(|| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + MAX_EDITOR_ERROR_DETAIL_CHARS, + ); + (!detail.trim().is_empty()).then_some(detail) + }) +} + +async fn append_editor_api_reason(base: String, response: reqwest::Response) -> String { + match editor_api_rejection_reason(response).await { + Some(reason) => format!("{base}:{reason}"), + None => base, + } } /// 追加到用户可见原因里的外部字段上限:正文已经按 64KiB 收口,但单个字段仍可能把 @@ -2755,7 +2774,7 @@ async fn submit_resource_edit_remote( .as_deref() .ok_or_else(|| "资源编辑账本缺少请求正文".to_string())?; let mut body_value = serde_json::from_str::(body) - .map_err(|_| "资源编辑账本请求正文无效".to_string())?; + .map_err(|error| format!("资源编辑账本请求正文无效:{error}"))?; if editor_api_mode() == EditorApiMode::PlatformAccount { if let Some(inputs) = body_value.as_object_mut().and_then(|body| { body.entry("generationInputs") @@ -2788,16 +2807,16 @@ async fn submit_resource_edit_remote( let mut post_response_session_error = access.validate_frozen_session().err(); let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error.as_ref() { - return Err(error.clone()); - } - return Err(editor_api_authentication_error()); + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } if status == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error.as_ref() { - return Err(error.clone()); - } - return Err(editor_api_authorization_error()); + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } if status == reqwest::StatusCode::BAD_REQUEST { ledger.terminal_failure_code = Some("remote-request-bad-request".to_string()); @@ -2823,20 +2842,27 @@ async fn submit_resource_edit_remote( if let Some(error) = post_response_session_error.as_ref() { return Err(error.clone()); } - return Err(format!( - "result-unknown: 资源编辑请求返回不确定响应 HTTP {}", - status.as_u16() - )); + let reason = editor_api_rejection_reason(response).await; + return Err(match reason { + Some(reason) => format!( + "result-unknown: 资源编辑请求返回不确定响应 HTTP {}:{reason}", + status.as_u16() + ), + None => format!( + "result-unknown: 资源编辑请求返回不确定响应 HTTP {}", + status.as_u16() + ), + }); } let payload = match response.json::().await { Ok(payload) => payload, - Err(_) => { + Err(error) => { update_resource_edit_phase( root, ledger, ResourceEditLedgerPhase::ReconciliationRequired, )?; - return Err("result-unknown: 资源编辑响应无法解析".to_string()); + return Err(format!("result-unknown: 资源编辑响应无法解析:{error}")); } }; if post_response_session_error.is_none() { @@ -2910,8 +2936,8 @@ async fn wait_for_resource_edit_remote( ) .send() .await - .map_err(|_| { - ResourceEditError::Other("result-unknown: 查询资源编辑任务失败".to_string()) + .map_err(|error| { + ResourceEditError::Other(format!("result-unknown: 查询资源编辑任务请求失败:{error}")) })?; access .validate_frozen_session() @@ -2927,14 +2953,22 @@ async fn wait_for_resource_edit_remote( continue; } if !response.status().is_success() { - return Err(ResourceEditError::Other(format!( + let base = format!( "result-unknown: 查询资源编辑任务返回 HTTP {}", response.status().as_u16() - ))); + ); + return Err(ResourceEditError::Other( + append_editor_api_reason(base, response).await, + )); } - let payload = response.json::().await.map_err(|_| { - ResourceEditError::Other("result-unknown: 资源编辑任务响应无法解析".to_string()) - })?; + let payload = response + .json::() + .await + .map_err(|error| { + ResourceEditError::Other(format!( + "result-unknown: 资源编辑任务响应无法解析:{error}" + )) + })?; access .validate_frozen_session() .map_err(ResourceEditError::Other)?; @@ -3153,7 +3187,7 @@ fn validate_downloaded_media( return Err("抠图结果必须是带透明通道的 PNG".to_string()); } let decoded = image::load_from_memory_with_format(bytes, image::ImageFormat::Png) - .map_err(|_| "抠图结果不是有效的 PNG".to_string())?; + .map_err(|error| format!("抠图结果不是有效的 PNG:{error}"))?; if !decoded.color().has_alpha() { return Err("抠图结果缺少透明通道".to_string()); } @@ -3435,7 +3469,9 @@ async fn prepare_remote_resource_edit( let client = crate::http_client::agc_main_site_client_builder() .timeout(Duration::from_secs(35 * 60)) .build() - .map_err(|_| ResourceEditError::Other("无法创建资源编辑 HTTP 客户端".to_string()))?; + .map_err(|error| { + ResourceEditError::Other(format!("无法创建资源编辑 HTTP 客户端:{error}")) + })?; let download_source = if ledger.phase == ResourceEditLedgerPhase::RemoteCompleted { if let Some(legacy_public_path) = ledger.remote_legacy_public_path.clone() { serde_json::json!({ "imageSrc": legacy_public_path }) @@ -3597,8 +3633,10 @@ async fn prepare_remote_resource_edit( media_read_limit(&input.edit_kind), ) .await - .map_err(|_| { - ResourceEditError::Other("result-unknown: 远端资源编辑结果下载或换签失败".to_string()) + .map_err(|error| { + ResourceEditError::Other(format!( + "result-unknown: 远端资源编辑结果下载或换签失败:{error}" + )) })? .ok_or_else(|| ResourceEditError::Other("远端资源编辑结果缺少可下载媒体".to_string()))?; access @@ -4729,7 +4767,7 @@ fn write_resource_edit_result_binding( | LocalProjectResourceEditKind::BackgroundRemoval ) { let decoded = image::load_from_memory(&bytes) - .map_err(|_| "派生图片 binding 无法解析尺寸".to_string())?; + .map_err(|error| format!("派生图片 binding 无法解析尺寸:{error}"))?; (Some(decoded.width()), Some(decoded.height())) } else { (None, None) @@ -5862,7 +5900,7 @@ pub(crate) async fn derive_local_project_resource_typed( Err(error) => Err(error), Ok(bytes) => (|| { let content = std::str::from_utf8(&bytes) - .map_err(|_| "派生文本不是 UTF-8".to_string()) + .map_err(|error| format!("派生文本不是 UTF-8:{error}")) .map_err(ResourceEditError::Other)?; let (media_type, extension) = validate_text_derivative( &input.edit_kind, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs index fc66e2b8a..fb90b7ca0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs @@ -21,17 +21,19 @@ pub(crate) enum ResourceEditError { } impl ResourceEditError { - /// 面向用户与模型的文案。 - /// - /// 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。「谁失败了」这类前缀由使用者 - /// 自己加(工具面用各工具自己的前缀,桌面命令面用「资源编辑生成失败」),这一层不替它们定调; - /// `phase_detail` 只进诊断,不进用户文案。 + /// 面向用户与模型的文案:优先给平台 `error` 原文,缺失时展示同一终态里的 + /// `phaseDetail`,两者都缺失才说明响应没有提供失败正文。前缀由使用者自己加。 pub(crate) fn to_user_msg(&self) -> String { match self { - Self::RemoteGenerationFailed { server_message, .. } => match server_message { - Some(server_message) => server_message.clone(), - None => "服务器未返回错误信息".to_string(), - }, + Self::RemoteGenerationFailed { + server_message, + phase_detail, + } => server_message + .clone() + .or_else(|| phase_detail.clone()) + .unwrap_or_else(|| { + "平台已返回失败终态,但没有提供 error 或 phaseDetail".to_string() + }), Self::Other(message) => message.clone(), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs index 97bd7907b..f83d13bf2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs @@ -373,6 +373,20 @@ async fn sync_project_snapshot_async( report.failed_files.len(), pending_files ); + for failure in report + .failed_files + .iter() + .chain(report.pending_files.iter()) + { + app_log!( + "project_snapshot.sync.file_detail projectId={} trigger={} path={} code={} detail={}", + report.project_id, + report.trigger, + failure.relative_path, + failure.code, + failure.detail + ); + } Ok(report) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs index 6ff4c226a..4b9a2901f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs @@ -323,7 +323,13 @@ async fn project_snapshot_response_error( let detail = response .text() .await - .map(|body| body.chars().take(200).collect::()) + .map(|body| { + crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &body, + 200, + ) + }) .unwrap_or_default(); let detail = detail.trim().to_string(); if detail.is_empty() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs index f3a783c11..bbe5e3cc1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs @@ -147,7 +147,7 @@ impl ProjectResourcePreviewReadManager { Ok(result) }) .await - .map_err(|_| "资源预览读取任务异常结束".to_string())?; + .map_err(|error| format!("资源预览读取任务异常结束:{error}"))?; result } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 781a5d4a4..e07f929ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -472,8 +472,8 @@ fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts( method: method.to_string(), params, }; - let payload = - serde_json::to_vec(&request).map_err(|_| "序列化 Agent Runner 请求失败".to_string())?; + let payload = serde_json::to_vec(&request) + .map_err(|error| format!("序列化 Agent Runner 请求失败:{error}"))?; let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, endpoint.port).into(); let mut stream = TcpStream::connect_timeout(&address, connect_timeout) .map_err(|error| format!("连接 Agent Runner 失败:{error}"))?; @@ -489,7 +489,7 @@ fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts( let response_payload = read_external_agent_runner_frame(&mut stream) .map_err(|error| format!("读取 Agent Runner 响应失败:{error}"))?; let response = serde_json::from_slice::(&response_payload) - .map_err(|_| "解析 Agent Runner 响应失败".to_string())?; + .map_err(|error| format!("解析 Agent Runner 响应失败:{error}"))?; if response.protocol_version != protocol_version { return Err("Agent Runner 响应协议版本不兼容".to_string()); } @@ -1708,7 +1708,7 @@ mod diagnostic_log_tests { "agent.runner.failed: request failed https://example.invalid/api?value=1", config_dir, ), - "agent.runner.failed: request failed https://example.invalid/api?" + "agent.runner.failed: request failed " ); assert_eq!( sanitize_agent_runner_output("error password=hunter2", config_dir), diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index 65265250e..4c22c40df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -42,7 +42,7 @@ async fn template_library_access_for_session( .timeout(Duration::from_secs(15)) .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|_| "template-library-unavailable: 无法检查模板库权限".to_string())?; + .map_err(|error| format!("template-library-unavailable: 无法检查模板库权限:{error}"))?; let response = client .get(format!( "{}/api/runtime/frontend-config", @@ -51,12 +51,19 @@ async fn template_library_access_for_session( .bearer_auth(&session.access_token) .send() .await - .map_err(|_| "template-library-unavailable: 检查模板库权限失败,请重试".to_string())?; + .map_err(|error| { + format!("template-library-unavailable: 检查模板库权限失败,请重试:{error}") + })?; validate_platform_session_identity(&session.identity())?; - if !response.status().is_success() { + let status = response.status(); + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); return Err(format!( - "template-library-unavailable: 检查模板库权限返回 HTTP {}", - response.status().as_u16() + "template-library-unavailable: {}", + crate::assets::external_asset_http_failure("检查模板库权限", status, &body,) )); } const MAX_BYTES: usize = 64 * 1024; @@ -69,8 +76,9 @@ async fn template_library_access_for_session( let mut bytes = Vec::new(); let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { - let chunk = - chunk.map_err(|_| "template-library-unavailable: 读取模板库权限失败".to_string())?; + let chunk = chunk.map_err(|error| { + format!("template-library-unavailable: 读取模板库权限失败:{error}") + })?; if bytes.len() + chunk.len() > MAX_BYTES { return Err("template-library-unavailable: 模板库权限响应无效".to_string()); } @@ -78,7 +86,7 @@ async fn template_library_access_for_session( } validate_platform_session_identity(&session.identity())?; let payload: serde_json::Value = serde_json::from_slice(&bytes) - .map_err(|_| "template-library-unavailable: 模板库权限响应无效".to_string())?; + .map_err(|error| format!("template-library-unavailable: 模板库权限响应无效:{error}"))?; Ok(payload .get("agcTemplateLibraryEnabled") .and_then(|value| value.as_bool()) @@ -572,7 +580,16 @@ async fn fetch_limited_bytes( .await .map_err(|error| format!("请求模板库失败:{error}"))?; if !response.status().is_success() { - return Err(format!("模板库返回 HTTP {}", response.status().as_u16())); + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "模板库请求", + status, + &body, + )); } if response .content_length() diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs index 9477e2a37..fff16eb3f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs @@ -118,11 +118,20 @@ async fn raw_extract_inner( format!("图片分离请求失败:{error}") })?; if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); app_log!( "ui_separation.error stage=image_edit reason=http_status status={}", - response.status() + status ); - return Err(format!("图片分离请求失败(HTTP {})", response.status())); + return Err(crate::assets::external_asset_http_failure( + "图片分离请求", + status, + &body, + )); } let payload = response.json::().await.map_err(|error| { app_log!("ui_separation.error stage=image_edit reason=parse_response error={error}"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs b/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs index 34ba1be97..d13c0995f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs @@ -153,20 +153,22 @@ fn file_from_snapshot(snapshot: WorkspacePreferencesSnapshot) -> WorkspacePrefer } } -fn read_preferences_at(path: &Path) -> WorkspacePreferencesSnapshot { - let Ok(metadata) = fs::symlink_metadata(path) else { - return WorkspacePreferencesSnapshot::default(); +fn read_preferences_at(path: &Path) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(WorkspacePreferencesSnapshot::default()) + } + Err(error) => return Err(format!("读取工作区偏好元数据失败:{error}")), }; if metadata.file_type().is_symlink() || !metadata.is_file() { - return WorkspacePreferencesSnapshot::default(); + return Err("工作区偏好文件必须是普通文件".to_string()); } - let Ok(content) = fs::read_to_string(path) else { - return WorkspacePreferencesSnapshot::default(); - }; - let Ok(file) = serde_json::from_str::(&content) else { - return WorkspacePreferencesSnapshot::default(); - }; - snapshot_from_file(file) + let content = + fs::read_to_string(path).map_err(|error| format!("读取工作区偏好失败:{error}"))?; + let file = serde_json::from_str::(&content) + .map_err(|error| format!("解析工作区偏好失败:{error}"))?; + Ok(snapshot_from_file(file)) } fn write_preferences_at( @@ -240,7 +242,7 @@ fn read_preferences_for_app( app: &tauri::AppHandle, ) -> Result { let path = workspace_preferences_path(app)?; - with_preferences_lock(|| Ok(read_preferences_at(&path))) + with_preferences_lock(|| read_preferences_at(&path)) } fn update_preferences_for_app( @@ -249,21 +251,23 @@ fn update_preferences_for_app( ) -> Result { let path = workspace_preferences_path(app)?; let snapshot = with_preferences_lock(|| { - let current = read_preferences_at(&path); + let current = read_preferences_at(&path)?; let next = update(current.clone()); if next == current { return Ok(current); } write_preferences_at(&path, next) })?; - let _ = app.emit(WORKSPACE_PREFERENCES_CHANGED_EVENT, snapshot.clone()); + if let Err(error) = app.emit(WORKSPACE_PREFERENCES_CHANGED_EVENT, snapshot.clone()) { + app_log!("workspace_preferences.emit_failed detail={error}"); + } Ok(snapshot) } -pub(crate) fn stored_project_creation_directory(app: &tauri::AppHandle) -> Option { - read_preferences_for_app(app) - .ok() - .and_then(|snapshot| snapshot.project_creation_directory) +pub(crate) fn stored_project_creation_directory( + app: &tauri::AppHandle, +) -> Result, String> { + Ok(read_preferences_for_app(app)?.project_creation_directory) } #[tauri::command] @@ -407,14 +411,13 @@ mod tests { } #[test] - fn malformed_preference_file_falls_back_to_empty_snapshot() { + fn malformed_preference_file_returns_parse_error_instead_of_silent_default() { let directory = tempfile::tempdir().expect("preference fixture"); let path = directory.path().join(WORKSPACE_PREFERENCES_FILE_NAME); fs::write(&path, b"{not json").expect("malformed preference fixture"); - assert_eq!( - read_preferences_at(&path), - WorkspacePreferencesSnapshot::default() - ); + assert!(read_preferences_at(&path) + .unwrap_err() + .contains("解析工作区偏好失败")); } #[test] @@ -435,13 +438,17 @@ mod tests { }), }; write_preferences_at(&path, snapshot.clone()).expect("write snapshot"); - assert_eq!(read_preferences_at(&path), snapshot); + assert_eq!(read_preferences_at(&path).unwrap(), snapshot); fs::write( &path, br#"{"schemaVersion":"agc-workspace-preferences.v1"}"#, ) .expect("legacy file without the new field"); - assert!(!read_preferences_at(&path).chat_prompt_polish_reminder_disabled); + assert!( + !read_preferences_at(&path) + .unwrap() + .chat_prompt_polish_reminder_disabled + ); } #[test] @@ -467,7 +474,7 @@ mod tests { Some(("custom", "http://127.0.0.1:10001")) ); write_preferences_at(&path, snapshot.clone()).expect("write snapshot"); - assert_eq!(read_preferences_at(&path), snapshot); + assert_eq!(read_preferences_at(&path).unwrap(), snapshot); // 非法预设与非法地址都不落盘。 assert!( @@ -513,7 +520,7 @@ mod tests { client_server_selection: None, }; write_preferences_at(&path, snapshot.clone()).expect("write snapshot"); - assert_eq!(read_preferences_at(&path), snapshot); + assert_eq!(read_preferences_at(&path).unwrap(), snapshot); let content = fs::read_to_string(path).expect("read snapshot"); assert!(content.contains(WORKSPACE_PREFERENCES_SCHEMA_VERSION)); } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 05e7bdca4..790fe9af7 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -81,6 +81,10 @@ import { type ResourceReferenceInsertManyEventDetail, } from './features/project-workspace/resourceReferences'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; +import { + appendWebviewLog, + visibleClientErrorMessage, +} from './services/errorReporting'; import { setAgcPluginProjectPath, startAvailableAgcEditorPlugins, @@ -241,15 +245,18 @@ export function App({ return; } setWorkspaceStatus( - `编辑器插件未就绪:${ - error instanceof Error ? error.message : String(error) - }`, + `编辑器插件未就绪:${visibleClientErrorMessage(error)}`, ); }); return () => { active = false; if (nextProjectPath || previousProjectPath) { - void setAgcPluginProjectPath(null).catch(() => undefined); + void setAgcPluginProjectPath(null).catch((error) => { + appendWebviewLog('error', [ + '[AGC] 编辑器插件项目路径清理失败', + visibleClientErrorMessage(error), + ]); + }); } localProjectPathRef.current = null; }; @@ -431,7 +438,12 @@ export function App({ } cleanup = unlisten; }) - .catch(() => { + .catch((error) => { + if (!disposed) { + setProjectChatError( + `策划 Agent 事件订阅失败:${visibleClientErrorMessage(error)}`, + ); + } resolveDesignAgentEventSubscriptionReady(); }); return () => { @@ -489,7 +501,11 @@ export function App({ setDesignAgentView(view); designAgentActiveRef.current = true; setDesignAgentActive(true); - setProjectChatError(view.session.lastError ?? ''); + setProjectChatError( + view.session.lastError + ? visibleClientErrorMessage(view.session.lastError) + : '', + ); setChatAgentBusy(view.running); const conversation = designMessagesToChat(view); setMessages(conversation); @@ -551,8 +567,9 @@ export function App({ if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) { return; } - const message = error instanceof Error ? error.message : String(error); - if (isRuntimeConfigMissingError(message)) { + const rawMessage = visibleClientErrorMessage(error); + const message = visibleClientErrorMessage(error); + if (isRuntimeConfigMissingError(rawMessage)) { requestRuntimeConfigOpen(); } setProjectChatError(message); @@ -763,7 +780,12 @@ export function App({ event, commandId, }), - ).catch(() => undefined); + ).catch((error) => { + appendWebviewLog('error', [ + '[AGC] 本地权限审计日志写入失败', + visibleClientErrorMessage(error), + ]); + }); } catch { // 权限日志只是审计旁路,写失败不能阻塞对话。 } @@ -845,7 +867,12 @@ export function App({ ]); directProjectChatRef.current?.announce(message); return true; - } catch { + } catch (error) { + if (localProjectPathRef.current === projectPath) { + const message = `项目权限策略读取失败:${visibleClientErrorMessage(error)}`; + setProjectChatError(message); + setWorkspaceStatus(message); + } return false; } } @@ -1022,7 +1049,12 @@ export function App({ setWorkspaceStatus('等待确认'); return; } - } catch { + } catch (error) { + if (localProjectPathRef.current === nextProjectPath) { + const message = `项目权限策略读取失败:${visibleClientErrorMessage(error)}`; + setProjectChatError(message); + setWorkspaceStatus(message); + } return; } } @@ -1042,7 +1074,7 @@ export function App({ } catch (error) { if (planningStartMode) { setProjectChatError( - `策划会话无法恢复:${error instanceof Error ? error.message : String(error)}`, + `策划会话无法恢复:${visibleClientErrorMessage(error)}`, ); } } @@ -1157,8 +1189,13 @@ export function App({ setManifest(openedProject.manifest); setMessages(conversationMessages); setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - void rememberRecentWorkspace(openedProject.projectPath).catch(() => { - // 最近项目是辅助投影;Rust 建项/打开结果不因偏好写入失败而回滚。 + void rememberRecentWorkspace(openedProject.projectPath).catch((error) => { + // 最近项目是辅助投影;Rust 建项/打开结果不因偏好写入失败而回滚, + // 但失败正文仍须进入诊断,不能静默丢掉本地 I/O 事实。 + appendWebviewLog('error', [ + '[AGC] 最近项目偏好写入失败', + visibleClientErrorMessage(error), + ]); }); setWorkspaceStatus(`已打开:${openedProject.projectPath}`); appendLocalPermissionLog( @@ -1180,7 +1217,7 @@ export function App({ if (projectScopeVersionRef.current !== projectScopeVersion) { return; } - const message = error instanceof Error ? error.message : String(error); + const message = visibleClientErrorMessage(error); setWorkspaceStatus(message); if (announceToChat) { setMessages((current) => [ @@ -1289,7 +1326,10 @@ export function App({ ); setWorkspaceStatus('等待确认'); return false; - } catch { + } catch (error) { + const message = `项目权限策略读取失败:${visibleClientErrorMessage(error)}`; + setWorkspaceStatus(message); + setProjectChatError(message); return false; } } @@ -1344,9 +1384,7 @@ export function App({ } catch (error) { if (localProjectPathRef.current === input.projectPath) { setProjectChatError( - `DirectProject 对话权限检查失败:${ - error instanceof Error ? error.message : String(error) - }`, + `DirectProject 对话权限检查失败:${visibleClientErrorMessage(error)}`, ); } return false; @@ -1437,24 +1475,19 @@ export function App({ invoke: TauriInvoke, nextProjectPath: string, ): Promise { - try { - const status = await invoke( - 'activate_local_game_preview', - { projectPath: nextProjectPath }, - ); - if ( - status.status !== 'running' || - !status.url || - !status.port || - !status.root - ) { - return null; - } - return { url: status.url, port: status.port, root: status.root }; - } catch { - // 没在跑、不属于这个项目,或权限位要求确认:都不是错误,回落到重新启动预览。 + const status = await invoke( + 'activate_local_game_preview', + { projectPath: nextProjectPath }, + ); + if ( + status.status !== 'running' || + !status.url || + !status.port || + !status.root + ) { return null; } + return { url: status.url, port: status.port, root: status.root }; } async function executeRunLocal() { @@ -1472,11 +1505,14 @@ export function App({ return; } + let previewActivationError: string | null = null; try { - const activePreview = await activateRunningPreview( - invoke, - nextProjectPath, - ); + let activePreview: LocalPreviewResult | null = null; + try { + activePreview = await activateRunningPreview(invoke, nextProjectPath); + } catch (error) { + previewActivationError = visibleClientErrorMessage(error); + } if (activePreview) { updateClientPreview(activePreview); // 运行成功的反馈走工作台壳的 toast(对话区只保留对话内容),因此不再往聊天里 @@ -1495,13 +1531,18 @@ export function App({ * `announceToChat` 约束(它是旧的「聊天播报」开关)。当前唯一调用点由播放请求驱动、 * 恒为 true(见 `executeRunLocalRef.current(true)`)。 */ - onRunNotice?.({ message: '运行通过,已载入客户端运行视图' }); + onRunNotice?.({ + message: previewActivationError + ? `已有预览复用失败,已重新启动运行视图:${previewActivationError}` + : '运行通过,已载入客户端运行视图', + }); } catch (error) { + const startError = visibleClientErrorMessage(error); onRunNotice?.({ tone: 'error', - message: `运行游戏失败:${ - error instanceof Error ? error.message : String(error) - }`, + message: previewActivationError + ? `已有预览复用失败:${previewActivationError};重新启动运行也失败:${startError}` + : `运行游戏失败:${startError}`, }); } } @@ -1551,7 +1592,13 @@ export function App({ } return; } - })().catch(() => undefined); + })().catch((error) => { + if (!cancelled && localProjectPathRef.current === nextProjectPath) { + setProjectChatError( + `项目清单同步失败:${visibleClientErrorMessage(error)}`, + ); + } + }); return () => { cancelled = true; }; @@ -1606,7 +1653,12 @@ export function App({ updatedAt: finalMessage.updatedAt, } satisfies ProjectAgentResultSummary, }; - } catch { + } catch (error) { + if (!disposed && localProjectPathRef.current === nextProjectPath) { + setProjectChatError( + `读取${candidate.label}结果失败:${visibleClientErrorMessage(error)}`, + ); + } return { agentId: candidate.agentId, status: 'failed' as const, @@ -1849,7 +1901,7 @@ export function App({ .catch((error) => { if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return; - setProjectChatError(String(error)); + setProjectChatError(visibleClientErrorMessage(error)); designReplyAnimation.discardUnpersisted(); }) .finally(() => { diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 31c4e82b4..c2f3b4394 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -34,6 +34,7 @@ import { import { captureClientError, installWebviewLogBridge, + visibleClientErrorMessage, } from '../services/errorReporting'; import type { AuthNetworkFailure } from '../services/generated/AuthNetworkFailure'; import type { AuthResponseInvalid } from '../services/generated/AuthResponseInvalid'; @@ -101,7 +102,7 @@ export class ClientRuntimeErrorBoundary extends Component< return { errorMessage: error instanceof Error && error.message.trim() - ? error.message + ? visibleClientErrorMessage(error) : '登录后页面发生未知错误', }; } @@ -179,7 +180,7 @@ export function AuthenticatedClient({ setServerSelection(next); return true; } catch (error) { - setLoginStatus(error instanceof Error ? error.message : String(error)); + setLoginStatus(visibleClientErrorMessage(error)); return false; } } @@ -264,9 +265,10 @@ export function AuthenticatedClient({ if (!isActiveRun()) return; // 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。 setAuthStatus('unauthenticated'); - // 启动检查超时等我们自己的失败:改动前会显示原因 + 重试按钮,这里恢复同样的可见反馈。 + // 启动检查超时、IPC 或网络失败都必须保留脱敏正文,并给出可重复的重试入口。 + setAuthCheckError(visibleClientErrorMessage(error)); if (!(error instanceof ClientAuthErrorWrapper)) { - const text = error instanceof Error ? error.message : String(error); + const text = visibleClientErrorMessage(error); setAuthCheckError(text); setLoginStatus(text); throw error; @@ -376,7 +378,9 @@ export function AuthenticatedClient({ } case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; - const text = `登录服务暂时不可用(HTTP ${payload.status})`; + const text = payload.serverMessage + ? `登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}` + : `登录服务暂时不可用(HTTP ${payload.status})`; setAuthCheckError(text); setLoginStatus(text); throw error; @@ -441,7 +445,10 @@ export function AuthenticatedClient({ default: { expectNever(failure); // 结构化映射外(Tauri 级失败等):仍然原样抛出上报,但界面给一行泛化提示,别停在空白状态。 - const text = '登录服务返回了未识别的错误,请重试或提交错误报告'; + const text = visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ); setAuthCheckError(text); setLoginStatus(text); throw error; @@ -487,7 +494,11 @@ export function AuthenticatedClient({ if (disposed) release(); else unsubscribe = release; }) - .catch(() => undefined); + .catch((error) => { + if (disposed) return; + setAuthStatus('unauthenticated'); + setAuthCheckError(visibleClientErrorMessage(error)); + }); return () => { disposed = true; unsubscribe?.(); @@ -541,7 +552,7 @@ export function AuthenticatedClient({ } catch (error) { // 本地运行时超时等我们自己的失败也要离开「正在发送验证码」并给出原因,改动前就是如此。 if (!(error instanceof ClientAuthErrorWrapper)) { - setLoginStatus(error instanceof Error ? error.message : String(error)); + setLoginStatus(visibleClientErrorMessage(error)); throw error; } const failure = error.error; @@ -642,7 +653,9 @@ export function AuthenticatedClient({ case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; setLoginStatus( - `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`, + payload.serverMessage + ? `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}` + : `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`, ); throw error; } @@ -698,7 +711,12 @@ export function AuthenticatedClient({ } default: { expectNever(failure); - setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); + setLoginStatus( + visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ), + ); throw error; } } @@ -768,7 +786,7 @@ export function AuthenticatedClient({ } catch (error) { // 本地运行时超时等我们自己的失败也要离开「正在登录」并给出原因,改动前就是如此。 if (!(error instanceof ClientAuthErrorWrapper)) { - setLoginStatus(error instanceof Error ? error.message : String(error)); + setLoginStatus(visibleClientErrorMessage(error)); throw error; } const failure = error.error; @@ -869,7 +887,9 @@ export function AuthenticatedClient({ case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; setLoginStatus( - `登录失败:登录服务暂时不可用(HTTP ${payload.status})`, + payload.serverMessage + ? `登录失败:登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}` + : `登录失败:登录服务暂时不可用(HTTP ${payload.status})`, ); throw error; } @@ -925,7 +945,12 @@ export function AuthenticatedClient({ } default: { expectNever(failure); - setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); + setLoginStatus( + visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ), + ); throw error; } } @@ -1027,11 +1052,7 @@ export function AuthenticatedClient({ normalizeClientServerBaseUrl(customServerUrl); persistServerSelection(); } catch (error) { - setLoginStatus( - error instanceof Error - ? error.message - : String(error), - ); + setLoginStatus(visibleClientErrorMessage(error)); } }} /> diff --git a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx index 27ce8c6e2..b7fe11562 100644 --- a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx @@ -9,6 +9,7 @@ import { installAppUpdate, subscribeToAppUpdate, } from '../services/appUpdate'; +import { visibleClientErrorMessage } from '../services/errorReporting'; type DownloadState = 'idle' | 'downloading' | 'completed' | 'error'; @@ -60,7 +61,7 @@ export function AppUpdateNotice() { await installAppUpdate(setDownloadProgress); setDownloadState('completed'); } catch (error) { - setDownloadError(error instanceof Error ? error.message : String(error)); + setDownloadError(visibleClientErrorMessage(error, '下载更新失败')); setDownloadState('error'); } } @@ -157,7 +158,7 @@ export function AppUpdateNotice() { ) : ( <> -

{downloadError || '下载更新失败,请稍后重试。'}

+

{downloadError || '下载更新失败:未提供具体错误正文。'}

) : null} + {loadError && notificationEvents.length === 0 ? ( + + ) : null} { - if (!cancelled) setCoverGenerationPrice(price); + if (!cancelled) { + setCoverGenerationPrice(price); + } }) - .catch(() => { - if (!cancelled) setCoverGenerationPrice(null); + .catch((error) => { + if (!cancelled) { + setCoverGenerationPrice(null); + // 价格读取失败必须带上真实原因:合并前这段走的是专用状态位,master 的表单错误 + // 出口已经统一成 error + onError,这里沿用同一出口,避免再挂一份无人消费的状态。 + const message = `封面价格读取失败:${visibleClientErrorMessage(error)}`; + setError(message); + onError(message); + } }); return () => { cancelled = true; }; - }, [open]); + }, [open, onError]); useEffect(() => { const objectUrls = objectUrlsRef.current; @@ -749,10 +760,10 @@ export function useGameDistributionPublishForm({ ...item, status: 'failed', assetObjectId: null, - error: - uploadError instanceof Error - ? uploadError.message - : '截图上传失败,请删除后重试', + error: visibleClientErrorMessage( + uploadError, + '截图上传失败,请删除后重试', + ), } : item, ), @@ -868,8 +879,9 @@ export function useGameDistributionPublishForm({ : null, }); }); - } catch { + } catch (progressError) { // 订阅失败只影响阶段条:发布本身照常进行,按钮上仍显示「正在发布…」。 + setError(`发布进度订阅失败:${visibleClientErrorMessage(progressError)}`); } try { const next = await publishLocalProjectGame({ diff --git a/apps/ai-game-creator-shell/src/components/windowChromeContext.ts b/apps/ai-game-creator-shell/src/components/windowChromeContext.ts index 1c2dcf911..23c77bcbb 100644 --- a/apps/ai-game-creator-shell/src/components/windowChromeContext.ts +++ b/apps/ai-game-creator-shell/src/components/windowChromeContext.ts @@ -25,6 +25,7 @@ export type WindowChromeActiveProjectRuns = { */ currentProjectName?: string | null; readFailed?: boolean; + readError?: string; onOpenProject?: (projectPath: string) => void; }; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index 926706f9d..b21d4bbb3 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { GameCreatorDirectActiveTurn, TauriInvoke } from '../../app/types'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { canSubscribeTauriEvents, subscribeTauriEvent, @@ -35,6 +36,7 @@ export function useDirectActiveTurns({ [], ); const [snapshotReadFailed, setSnapshotReadFailed] = useState(false); + const [snapshotReadError, setSnapshotReadError] = useState(''); const mountedRef = useRef(true); const inFlightRef = useRef | null>(null); /** @@ -93,10 +95,12 @@ export function useDirectActiveTurns({ setActiveTurns(nextTurns); } setSnapshotReadFailed(false); + setSnapshotReadError(''); inFlightRef.current = null; return; - } catch { + } catch (error) { if (!isCurrent()) return; + setSnapshotReadError(visibleClientErrorMessage(error)); if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { await new Promise((resolve) => { retryTimerRef.current = window.setTimeout(() => { @@ -132,6 +136,7 @@ export function useDirectActiveTurns({ // 空态也要保持引用稳定:已经空了就不要再换一个新数组。 setActiveTurns((current) => (current.length === 0 ? current : [])); setSnapshotReadFailed((current) => (current ? false : current)); + setSnapshotReadError((current) => (current ? '' : current)); return; } let disposed = false; @@ -152,8 +157,9 @@ export function useDirectActiveTurns({ return; } unsubscribe = release; - } catch { - // 事件桥不可用时仍做一次启动快照;不恢复定时轮询。 + } catch (error) { + // 事件桥不可用时仍做一次启动快照;同时保留 IPC 正文,不能让活动回合更新静默失联。 + if (!disposed) setSnapshotReadError(visibleClientErrorMessage(error)); } } if (!disposed) { @@ -172,5 +178,10 @@ export function useDirectActiveTurns({ }; }, [enabled, invoke, refreshActiveTurns]); - return { activeTurns, refreshActiveTurns, snapshotReadFailed }; + return { + activeTurns, + refreshActiveTurns, + snapshotReadFailed, + snapshotReadError, + }; } diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 7014d6605..aecb64b2c 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -87,58 +87,6 @@ export function isMudPointInsufficientRuntimeError(message: string) { ); } -const DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN = - /(?:^|[^\w])(?:proxy-authorization|authorization|set-cookie|cookie|access[_ -]?token|accesstoken|refresh[_ -]?token|refreshtoken|oauth[_ -]?token|id[_ -]?token|auth[_ -]?token|authtoken|client[_ -]?secret|clientsecret|private[_ -]?key|privatekey|secret[_ -]?key|secretkey|x-api-key|x_api_key|api[_ -]?key|apikey|credentials?|password|token|secret|bearer)\s*["']?\s*[:=]>?\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|(?:Bearer\s+(?:\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+)|\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+))/gi; - -const DIRECT_FAILURE_BEARER_PATTERN = - /(?:^|[^\w])Bearer\s+(\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+)/gi; - -function isRedactedDirectFailureValue(value: string) { - const normalized = value.trim(); - return ( - normalized.length === 0 || - /^(?:Bearer\s+)?(?:\[redacted-secret\]|\[redacted-sensitive-field\]|\[已隐藏凭据\]|\[已隐藏敏感字段\]|\[已隐藏链接\]|\[已隐藏路径\]|\[已隐藏配置\]|\[已隐藏敏感信息\]||||\[redacted-config\]|\[redacted sensitive context\])$/i.test( - normalized, - ) - ); -} - -function containsUnredactedDirectFailureSecret(value: string) { - // Provider payloads are sometimes embedded as escaped JSON in a single - // diagnostic line (`{\"token\":\"...\"}`). Normalize only the quote - // escapes for the detector; the displayed value still goes through the - // backend's redaction and marker conversion unchanged. - const normalizedValue = value.replace(/\\(["'])/g, '$1'); - DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN.lastIndex = 0; - let match: RegExpExecArray | null; - while ( - (match = - DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN.exec(normalizedValue)) !== - null - ) { - const rawCandidate = (match[1] ?? '').trim(); - const candidate = - rawCandidate.length >= 2 && - ((rawCandidate.startsWith('"') && rawCandidate.endsWith('"')) || - (rawCandidate.startsWith("'") && rawCandidate.endsWith("'"))) - ? rawCandidate.slice(1, -1).trim() - : rawCandidate; - if (!isRedactedDirectFailureValue(candidate)) { - return true; - } - } - - DIRECT_FAILURE_BEARER_PATTERN.lastIndex = 0; - while ( - (match = DIRECT_FAILURE_BEARER_PATTERN.exec(normalizedValue)) !== null - ) { - if (!isRedactedDirectFailureValue(match[1] ?? '')) { - return true; - } - } - return false; -} - function redactDirectFailureMarkers(value: string) { return value .replace(//gi, '[已隐藏链接]') @@ -149,6 +97,15 @@ function redactDirectFailureMarkers(value: string) { .replace(/\[redacted sensitive context\]/gi, '[已隐藏敏感信息]'); } +/** + * 跨 IPC 的宿主正文统一走同一套精确脱敏:遇到凭据、链接或路径时只替换敏感值, + * 不再因为一条正文里同时出现敏感字段就把 HTTP 状态、错误码和可行动原因整段丢弃。 + */ +function visibleRuntimeDetail(value: string, maxChars: number) { + const safe = runtimeFailureDetail(value)?.replace(/^:/u, '').trim() ?? ''; + return safe ? safe.slice(0, maxChars) : ''; +} + /** * 宿主收口文案(`direct-codex-failure:v1|v2 …`)解析出的可展示部分。 * @@ -159,6 +116,7 @@ type DirectDiagnosticParts = { stageLabel: string; summary: string; hint: string; + detail: string; retryable: boolean; }; @@ -175,33 +133,36 @@ function directCodexDiagnosticFailureParts( // v2 的现行形状(比 v1 多一段 `code=`)由上面那条覆盖;这里额外容忍**历史记录**里 // 出现过的诊断引用尾巴(`;详情:.agent/runtime/errors/.json`):宿主早已不再 // 往文案里拼引用,但旧会话记录仍可能带着它,解析时一并吃掉,`code=` 与引用都不进捕获组。 - /^direct-codex-failure:v2 stage=(request|art-preparation|code-generation|browser-validation|version-registration) code=[a-z0-9-]+ retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)(?:;详情:\S+)?$/u.exec( + /^direct-codex-failure:v2 stage=(request|art-preparation|code-generation|browser-validation|version-registration) code=[a-z0-9-]+ retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)(?:;详情:(.+))?$/u.exec( trimmed, ); if (!match) { return null; } - const [, stage, retryable, rawSummary, rawHint] = match; + const [, stage, retryable, rawSummary, rawHint, rawDetail = ''] = match; if (!stage || !retryable || !rawSummary || !rawHint) { return null; } - const summary = redactDirectFailureMarkers(rawSummary) + const summary = ( + visibleRuntimeDetail(rawSummary, 320) || + redactDirectFailureMarkers(rawSummary) + ) .replace(/\s+/g, ' ') .trim() .slice(0, 320); - const hint = redactDirectFailureMarkers(rawHint) + const hint = ( + visibleRuntimeDetail(rawHint, 180) || redactDirectFailureMarkers(rawHint) + ) .replace(/\s+/g, ' ') .trim() .slice(0, 180); - const combined = `${summary} ${hint}`; - if ( - containsUnredactedDirectFailureSecret(combined) || - /https?:\/\/|(?:^|[\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\/i.test( - combined, - ) - ) { - return null; - } + const detail = ( + visibleRuntimeDetail(rawDetail, 420) || + redactDirectFailureMarkers(rawDetail) + ) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 420); const stageLabel = { request: '智能创作请求失败', 'art-preparation': '平台资源准备失败', @@ -212,7 +173,7 @@ function directCodexDiagnosticFailureParts( if (!stageLabel || !summary || !hint) { return null; } - return { stageLabel, summary, hint, retryable: retryable === 'true' }; + return { stageLabel, summary, hint, detail, retryable: retryable === 'true' }; } /** 收口文案的一句话:阶段标签只有"回合失败"才成立,请求被拒那边传 `null`(见下面的导出函数)。 */ @@ -220,7 +181,8 @@ function directDiagnosticSentence( parts: DirectDiagnosticParts, stageLabel: string | null, ) { - return `${stageLabel ? `${stageLabel}:` : ''}${parts.summary}。${parts.hint}${ + const detail = parts.detail ? `;详情:${parts.detail}` : ''; + return `${stageLabel ? `${stageLabel}:` : ''}${parts.summary}。${parts.hint}${detail}${ parts.retryable ? '(可直接重试)' : '' }`; } @@ -264,15 +226,9 @@ function directPlatformFailureDetail(message: string) { ) { return null; } - if ( - containsUnredactedDirectFailureSecret(trimmed) || - /https?:\/\/|(?:^|[\\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\/i.test( - trimmed, - ) - ) { - return null; - } - const safe = redactDirectFailureMarkers(trimmed) + const safe = ( + visibleRuntimeDetail(trimmed, 420) || redactDirectFailureMarkers(trimmed) + ) .replace(/(?:;|;|\s)operationId\s*=\s*[^;;\s]+/gi, '') .replace(/(?:;|;)\s*externalGenerationJobId\s*=\s*[^;;\s]+/gi, '') .replace(/\s+/g, ' ') @@ -292,19 +248,9 @@ function directCodexFailureDetail(message: string) { if (!detail) { return null; } - if ( - /(?:authorization|bearer|api[_ -]?key|token|secret|cookie|set-cookie)/i.test( - detail, - ) - ) { - return null; - } - const safe = detail - .replace(/https?:\/\/[^\s]+/gi, '[已隐藏链接]') - .replace( - /(?:[A-Z]:\\|\\\\|\/(?:Users|home|var|tmp|private)\/)[^\s]+/gi, - '[已隐藏路径]', - ) + const safe = ( + visibleRuntimeDetail(detail, 420) || redactDirectFailureMarkers(detail) + ) .replace(/\s+/g, ' ') .trim() .slice(0, 280); @@ -323,14 +269,12 @@ function directRuntimeFailureDetail(message: string) { ) { return null; } - if ( - /(?:authorization|bearer|api[_ -]?key|token|secret|cookie|set-cookie|https?:\/\/|(?:^|[\\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\)/i.test( - trimmed, - ) - ) { - return null; - } - const safe = trimmed.replace(/\s+/g, ' ').trim().slice(0, 320); + const safe = ( + visibleRuntimeDetail(trimmed, 420) || redactDirectFailureMarkers(trimmed) + ) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 320); return safe || null; } @@ -374,7 +318,7 @@ export function projectRuntimeVisibleError( other: '智能创作执行失败,请查看运行详情后重试', }[codexAppServerKind]; if (detail) { - return `${subject} ${detail}`; + return `${subject} ${detail}${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } } const directCodexAppServerKind = visibleMessage.match( @@ -393,11 +337,11 @@ export function projectRuntimeVisibleError( other: '未完成本次执行,请查看项目文件是否已修改后再重试', }[directCodexAppServerKind]; if (detail) { - return `${subject} ${detail}`; + return `${subject} ${detail}${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } } if (visibleMessage.includes('codex-app-server-terminal-unknown:')) { - return `${subject}服务或网络在执行中断开,最终状态未知;请先检查项目文件是否已修改,再决定是否重试`; + return `${subject}服务或网络在执行中断开,最终状态未知;请先检查项目文件是否已修改,再决定是否重试${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } const directCodexDetail = directCodexFailureDetail(visibleMessage); if (directCodexDetail) { @@ -419,7 +363,7 @@ export function projectRuntimeVisibleError( !retryAttemptText || !maxRetriesText ) { - return `${subject} 执行失败,请稍后重试`; + return `${subject} 执行失败${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } const httpStatus = Number.parseInt(httpStatusText, 10); const retryAttempt = Number.parseInt(retryAttemptText, 10); @@ -442,10 +386,10 @@ export function projectRuntimeVisibleError( normalized.includes('network') || normalized.includes('tls') ) { - return `${subject} 服务连接失败,请稍后重试`; + return `${subject} 服务连接失败,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if (normalized.includes('timeout') || normalized.includes('超时')) { - return `${subject} 响应超时,请稍后重试`; + return `${subject} 响应超时,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('unauthorized') || @@ -453,7 +397,7 @@ export function projectRuntimeVisibleError( normalized.includes('401') || normalized.includes('鉴权') ) { - return `${subject} 鉴权失败,请检查运行时配置`; + return `${subject} 鉴权失败,请检查运行时配置${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('rate limit') || @@ -462,7 +406,7 @@ export function projectRuntimeVisibleError( normalized.includes('额度') || normalized.includes('限流') ) { - return `${subject} 服务繁忙,请稍后重试`; + return `${subject} 服务繁忙,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('needs-reconciliation') || @@ -470,14 +414,14 @@ export function projectRuntimeVisibleError( normalized.includes('终态未知') || normalized.includes('需要人工核对') ) { - return `${subject} 运行状态需要核对,请打开运行详情后重试`; + return `${subject} 运行状态需要核对,请打开运行详情后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('budget-exhausted') || normalized.includes('预算耗尽') || normalized.includes('预算已耗尽') ) { - return `${subject} 本轮预算已耗尽,请缩小任务范围后重试`; + return `${subject} 本轮预算已耗尽,请缩小任务范围后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('missing expected artifact') || @@ -485,7 +429,7 @@ export function projectRuntimeVisibleError( normalized.includes('缺少预期产物') || normalized.includes('缺少 expected artifact') ) { - return `${subject} 未生成要求的产物,请查看任务要求后重试`; + return `${subject} 未生成要求的产物,请查看任务要求后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('verification') || @@ -494,7 +438,7 @@ export function projectRuntimeVisibleError( normalized.includes('验证未通过') || normalized.includes('验证失败') ) { - return `${subject} 项目验证未通过,请查看运行详情并修复后重试`; + return `${subject} 项目验证未通过,请查看运行详情并修复后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('policy') || @@ -503,7 +447,7 @@ export function projectRuntimeVisibleError( normalized.includes('禁止') || normalized.includes('不允许') ) { - return `${subject} 被项目权限或安全策略阻止,请检查审批配置`; + return `${subject} 被项目权限或安全策略阻止,请检查审批配置${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('落盘失败') || @@ -515,19 +459,18 @@ export function projectRuntimeVisibleError( normalized.includes('未确认历史完整落盘') || normalized.includes('写入本项目对话历史失败') ) { - return `${subject} 保存运行记录失败,请检查项目目录后重试`; + return `${subject} 保存运行记录失败,请检查项目目录后重试${runtimeFailureDetailSuffix(message)}`; } - // 宿主 `Display` 的其余事实句:它们不含上面任何关键字,**不加模式就只会看到最后那句通用文案**。 - // 这里只认宿主写死的句首短语,不回落原文——原文里带 `exitStatus=` / `stderrClass=` 这类内部字段 - // (`TransportClosed` 就是这种)。 + // 宿主 `Display` 的其余事实句:优先识别宿主写死的句首短语;其余错误也保留精确脱敏正文, + // 避免 IPC/进程/操作系统错误只剩一条通用句。 if (visibleMessage.includes('执行通道已断开')) { - return `${subject} 服务连接已断开,请稍后重试`; + return `${subject} 服务连接已断开,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if (visibleMessage.includes('等待模型回合结束达到硬上限')) { - return `${subject} 响应超时,请稍后重试`; + return `${subject} 响应超时,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if (visibleMessage.includes('宿主任务提前结束')) { - return `${subject} 本轮执行已中断,请重试`; + return `${subject} 本轮执行已中断,请重试${runtimeFailureDetailSuffix(message)}`; } const containsInternalDiagnostics = normalized.includes('agentllm.') || @@ -556,7 +499,44 @@ export function projectRuntimeVisibleError( ) { return visibleMessage; } - return `${subject} 执行失败,请稍后重试`; + const safeDetail = runtimeFailureDetail(message); + return safeDetail + ? `${subject} 执行失败${safeDetail}` + : `${subject} 执行失败:宿主未提供具体错误正文`; +} + +/** 精确脱敏普通 Tauri/IPC/网络错误:保留错误码与正文,只替换敏感值。 */ +function runtimeFailureDetail(value: string) { + const trimmed = value.trim(); + if (!trimmed) return null; + const safe = trimmed + .replace( + /|||\$PROJECT_ROOT/giu, + '[已隐藏信息]', + ) + .replace(/\[redacted-sensitive-field\]/giu, '[已隐藏字段]') + .replace(/\[redacted-config\]/giu, '[已隐藏配置]') + .replace(/https?:\/\/[^\s"'<>]+/giu, '[已隐藏链接]') + .replace( + /(?:[A-Z]:\\|\\\\|\/(?:Users|home|var|tmp|private)\/)[^\s"'<>]+/giu, + '[已隐藏路径]', + ) + .replace( + /((?:proxy-authorization|authorization|set-cookie|cookie|access[_ -]?token|refresh[_ -]?token|api[_ -]?key|apikey|client[_ -]?secret|private[_ -]?key|secret[_ -]?key|password|token|secret|bearer)\s*[:=]\s*)(?:Bearer\s+)?(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;,;&}\]]+)/giu, + '$1[已隐藏凭据]', + ) + .replace(/(?:^|\s)kind=[a-z0-9_-]+/giu, ' ') + .replace(/(?:^|\s)(?:fingerprint|sha256)=[^\s]+/giu, ' ') + .replace(/\s+chars=\d+/giu, ' ') + .replace(/\s+/gu, ' ') + .trim() + .slice(0, 420); + if (!safe || safe === trimmed) return safe ? `:${safe}` : null; + return `:${safe}`; +} + +function runtimeFailureDetailSuffix(value: string) { + return runtimeFailureDetail(value) ?? ':宿主未提供具体错误正文'; } export function taskRowsFromManifest( diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx index 9d17d308b..68206e2e5 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx @@ -19,6 +19,7 @@ export type ActiveProjectRunsPanelProps = { /** 当前工作台打开的项目名;有值即顶栏主文案,运行中的项目不再抢它。 */ currentProjectName?: string | null; readFailed?: boolean; + readError?: string; onOpenProject?: (projectPath: string) => void; placement?: 'panel' | 'titlebar'; }; @@ -63,6 +64,7 @@ export function ActiveProjectRunsPanel({ currentProjectPath = null, currentProjectName = null, readFailed = false, + readError = '', onOpenProject, placement = 'panel', }: ActiveProjectRunsPanelProps) { @@ -116,7 +118,7 @@ export function ActiveProjectRunsPanel({ className="launcher-runs-titlebar launcher-runs-titlebar--error" role="status" > - 正在运行的项目读取失败 + {readError || '正在运行的项目读取失败'} ); } @@ -172,6 +174,11 @@ export function ActiveProjectRunsPanel({ aria-hidden="true" /> + {readError ? ( + + {readError} + + ) : null} {open ? (
@@ -259,6 +266,11 @@ export function ActiveProjectRunsPanel({
+ {homeInputError ? ( +

+ {homeInputError} +

+ ) : null} {recoverableCreatedProjectPath && !creationBusy ? ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx index bd1a89b66..dffe46055 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx @@ -1,6 +1,7 @@ import { ChevronLeft, LoaderCircle, Sparkles } from 'lucide-react'; import { type FormEvent, useState } from 'react'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { type LocalProjectResourceEditKind, resourceEditPromptMaxLength, @@ -50,11 +51,7 @@ export function ResourceEditSurface({ try { await onSubmit({ prompt: normalizedPrompt, assetName: normalizedName }); } catch (submitError) { - setError( - submitError instanceof Error - ? submitError.message - : String(submitError), - ); + setError(visibleClientErrorMessage(submitError, '资源编辑失败,请重试')); setSubmitting(false); } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx index 189d0f8ba..b146038db 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { renderResourceModelThumbnail } from './resourceModelThumbnail'; type ResourceModelPreviewProps = { @@ -42,6 +43,7 @@ export function ResourceModelPreview({ const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>( 'loading', ); + const [previewError, setPreviewError] = useState(''); /** * 渲染尺寸按**宿主真实几何**(含画布缩放后的视觉尺寸)取,并且跟着宿主的尺寸变化重渲。 * @@ -104,6 +106,7 @@ export function ResourceModelPreview({ let disposed = false; setThumbnail(null); setStatus('loading'); + setPreviewError(''); const request = { sourceUrl, mediaType, @@ -119,9 +122,10 @@ export function ResourceModelPreview({ setThumbnail(dataUrl); setStatus('ready'); }) - .catch(() => { + .catch((error) => { if (!disposed) { setStatus('failed'); + setPreviewError(visibleClientErrorMessage(error)); } }); return () => { @@ -134,6 +138,10 @@ export function ResourceModelPreview({ ref={hostRef} className="game-resource-card-model-visual" data-model-preview-status={status} + title={previewError ? `${fallbackLabel}:${previewError}` : fallbackLabel} + aria-label={ + previewError ? `${fallbackLabel}:${previewError}` : fallbackLabel + } // 渲染尺寸暴露到 DOM:缩略图糊 / 比例不对时,先看它和卡片实际几何是否一致。 data-model-render-size={ renderSize ? `${renderSize.width}x${renderSize.height}` : undefined diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx index 4b06525cc..03bef465f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { addResourceModelLights, disposeResourceModelObject, @@ -154,11 +155,7 @@ export function ResourceModelViewer({ } catch (viewerError) { if (!disposed) { setStatus('failed'); - setError( - viewerError instanceof Error - ? viewerError.message - : String(viewerError), - ); + setError(visibleClientErrorMessage(viewerError, '模型预览加载失败')); } } })(); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx index 03d7a0708..2e5e2165c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx @@ -35,6 +35,7 @@ import { DEFAULT_COMPOSER_REASONING_EFFORT, normalizeComposerReasoningEffort, } from '../../../../../features/project-workspace/composerReasoningEffort'; +import { visibleClientErrorMessage } from '../../../../../services/errorReporting'; import { LLM_CONFIG_CHANGED_EVENT } from '../../../../../services/llmModelCatalog'; import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import { @@ -287,9 +288,7 @@ export function ComposerVoiceButton({ recognitionRef.current = null; setRecording(false); onNoticeRef.current( - error instanceof Error && error.message - ? error.message - : '语音输入启动失败,请稍后重试', + visibleClientErrorMessage(error, '语音输入启动失败,请稍后重试'), ); } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx index 3dc7c91b2..c9409b1fb 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx @@ -204,6 +204,11 @@ describe('前插锚点的标记', () => { 't1:f', 'usage', ]); + expect( + view.container + .querySelector('[data-testid="turn-process"]') + ?.hasAttribute('open'), + ).toBe(true); }); }); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx index 3009776b6..2555bca97 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx @@ -26,6 +26,7 @@ export function DirectProjectConversation({ historyLoading, historyError, turnInFlight, + activeProgress = '', activeTurnStartedAt, onLoadEarlierHistory, onRetryEarlierHistory, @@ -46,6 +47,8 @@ export function DirectProjectConversation({ * `activeTurnStartedAt` 还是 0,卡片只报"正在处理";宿主开始事件一到就开始读秒。 */ turnInFlight: boolean; + /** Rust `game-creator-agent-progress` 的当前阶段;没有事件时保留通用忙态。 */ + activeProgress?: string; activeTurnStartedAt: number; onLoadEarlierHistory: () => void; onRetryEarlierHistory: () => void; @@ -105,7 +108,7 @@ export function DirectProjectConversation({ >
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx index cbf78edf9..6814a56df 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx @@ -3,9 +3,17 @@ import { ArrowDown } from 'lucide-react'; /** * 底部居中的「回到底部」胶囊。 * - * 用 `sticky bottom-*` 而不是浮层:工作台里 `.project-chat-conversation` 是 `display: block` - * 加 `height: 100%` 的几何,在列表外套一层定位容器会把列表的 `height: 100%` 塌成内容高度; - * 粘在列表内部的胶囊零结构改动,两块宿主(工作台侧栏与独立页面)都能拿到。 + * 它是消息列表的**最后一个 flex 项**,贴底靠两件事配合(见 `styles.css` 里 + * `.project-chat-scroll-to-bottom` 那组规则): + * - `margin-top: auto`:内容不足一屏时吃掉剩余空间,把胶囊顶到列表可见底边; + * - `sticky bottom-3`:内容溢出一屏时把胶囊从内容末尾**上拉**贴住可见底边。 + * 只用 `sticky` 不够——它只能上拉、不能下推,内容不足一屏时元素会停在文档流里(悬空)。 + * + * 用列表内的 flex 项而不是浮层:工作台里 `.project-chat-surface.is-direct-codex > + * .project-chat-conversation` 是纵向 flex,`.project-chat-message-list` 是其中 + * `flex: 1 1 auto` 的唯一滚动项;在列表外套一层定位容器会让列表塌成内容高度, + * 改公共类又会连带 `PlanningChatView`。粘在列表内部零结构改动,两块宿主 + * (工作台侧栏与导出面板)都能拿到。 * * 显隐与文案由调用方决定(距底超过阈值才出现;不跟随时来了新回复就换文案),这里只负责表现。 */ @@ -19,7 +27,7 @@ export function DirectProjectScrollToBottomCapsule({ return (