From 6f7ccc2832fd70d941aecd3c7166c7419e47daf9 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:03:04 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B=E9=81=97?= =?UTF-8?q?=E7=95=99=E9=94=99=E8=AF=AF=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 外部 MCP HTTP 错误返回状态、错误码和中文正文 认证、素材上传和发行发布保留上游 401/403/5xx 详情 精确脱敏 Direct 错误并标记旧历史缺失正文 补充回归测试、诊断文档和真实客户端启动验证 --- .../src-tauri/src/account_api.rs | 30 ++-- .../src-tauri/src/agent/direct_tools_mcp.rs | 109 ++++++++++++-- .../src-tauri/src/auth_error.rs | 16 ++- .../src-tauri/src/auth_session.rs | 11 +- .../src/game_distribution_publish.rs | 18 ++- .../src-tauri/src/platform_asset_upload.rs | 91 +++++++++--- .../src/app/AuthenticatedClient.tsx | 12 +- .../src/features/agent-runtime/model.ts | 135 +++++------------- .../generated/AuthServiceUnavailable.ts | 4 +- .../chat/conversation/directThreadSanitize.ts | 9 +- .../conversation/directTurnEnqueueFailure.ts | 20 +-- .../chat/conversation/directTurnFailure.ts | 4 +- .../tests/agentRuntimeModel.test.ts | 24 ++++ .../tests/appSurface/chat-composer.suite.ts | 15 +- .../appSurface/project-conversation.suite.ts | 12 +- .../tests/clientAuthErrorCarrier.test.ts | 6 +- .../tests/directThreadSanitize.test.ts | 11 ++ ...GC命令错误结构化与错误报告口径-2026-10-01.md | 3 + ...€�里程碑】AGC错误具体文本展示-2026-10-04.md | 3 + 19 files changed, 355 insertions(+), 178 deletions(-) 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 f9204a992..99d258005 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 @@ -121,6 +121,9 @@ fn safe_error_body_detail(body: &str) -> Option { .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, @@ -131,21 +134,24 @@ fn safe_error_body_detail(body: &str) -> Option { 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) - .or_else(|| safe_error_body_detail(body)) - .unwrap_or_else(|| "当前账号无权执行此操作".to_string()) + server_detail.unwrap_or_else(|| "当前账号无权执行此操作".to_string()) ); } - let detail = error_message(body) - .or_else(|| error_code(body)) - .or_else(|| safe_error_body_detail(body)) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + let detail = server_detail.unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") } @@ -371,6 +377,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/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index e83d173e4..623421277 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 @@ -1697,7 +1697,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() @@ -1723,27 +1723,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 因此 @@ -1757,7 +1790,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()) } @@ -1912,7 +1951,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()), @@ -1932,6 +1981,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}; 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..8428740fe 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 { @@ -1317,7 +1318,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 +1428,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/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index 5a3c3a5f9..194fc73bd 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 @@ -321,7 +321,8 @@ fn parse_error_payload(body: &str) -> (Option, Option) { body.trim(), 600, ); - (!detail.trim().is_empty()).then_some(detail) + (!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\"")) + .then_some(detail) }); (code, message) } @@ -342,7 +343,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!( @@ -1616,6 +1622,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, 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 0b4f53aa4..90406c6d6 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,42 +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 parsed = serde_json::from_str::(body).ok(); let error = parsed.as_ref().and_then(|value| value.get("error")); - let detail = error - .and_then(|value| value.get("message")) - .or_else(|| parsed.as_ref().and_then(|value| value.get("message"))) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - .or_else(|| { - error - .and_then(|value| value.get("code")) - .or_else(|| parsed.as_ref().and_then(|value| value.get("code"))) + 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)) }) + .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()).then_some(safe) + (!safe.is_empty() && !matches!(safe.trim(), "{}" | "null" | "\"\"")).then_some(safe) }) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + .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" } @@ -477,4 +515,17 @@ mod tests { 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/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 205b6cc2c..1720efe3a 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -385,7 +385,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; @@ -660,7 +662,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; } @@ -898,7 +902,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; } 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 2f3c92b21..d5cc25cc7 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 …`)解析出的可展示部分。 * @@ -185,23 +142,19 @@ function directCodexDiagnosticFailureParts( 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 stageLabel = { request: '智能创作请求失败', 'art-preparation': '平台资源准备失败', @@ -262,15 +215,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, ' ') @@ -290,19 +237,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); @@ -319,14 +256,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; } @@ -370,7 +305,7 @@ export function projectRuntimeVisibleError( other: '智能创作执行失败,请查看运行详情后重试', }[codexAppServerKind]; if (detail) { - return `${subject} ${detail}${runtimeFailureDetail(message) ?? ''}`; + return `${subject} ${detail}${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } } const directCodexAppServerKind = visibleMessage.match( @@ -389,11 +324,11 @@ export function projectRuntimeVisibleError( other: '未完成本次执行,请查看项目文件是否已修改后再重试', }[directCodexAppServerKind]; if (detail) { - return `${subject} ${detail}${runtimeFailureDetail(message) ?? ''}`; + return `${subject} ${detail}${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } } if (visibleMessage.includes('codex-app-server-terminal-unknown:')) { - return `${subject}服务或网络在执行中断开,最终状态未知;请先检查项目文件是否已修改,再决定是否重试${runtimeFailureDetail(message) ?? ''}`; + return `${subject}服务或网络在执行中断开,最终状态未知;请先检查项目文件是否已修改,再决定是否重试${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } const directCodexDetail = directCodexFailureDetail(visibleMessage); if (directCodexDetail) { @@ -415,7 +350,7 @@ export function projectRuntimeVisibleError( !retryAttemptText || !maxRetriesText ) { - return `${subject} 执行失败${runtimeFailureDetail(message) ?? ',请稍后重试'}`; + return `${subject} 执行失败${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } const httpStatus = Number.parseInt(httpStatusText, 10); const retryAttempt = Number.parseInt(retryAttemptText, 10); @@ -554,7 +489,7 @@ export function projectRuntimeVisibleError( const safeDetail = runtimeFailureDetail(message); return safeDetail ? `${subject} 执行失败${safeDetail}` - : `${subject} 执行失败,请稍后重试`; + : `${subject} 执行失败:宿主未提供具体错误正文`; } /** 精确脱敏普通 Tauri/IPC/网络错误:保留错误码与正文,只替换敏感值。 */ @@ -583,12 +518,12 @@ function runtimeFailureDetail(value: string) { .replace(/\s+/gu, ' ') .trim() .slice(0, 420); - if (!safe || safe === trimmed) return safe ? `:${safe}` : ''; + if (!safe || safe === trimmed) return safe ? `:${safe}` : null; return `:${safe}`; } function runtimeFailureDetailSuffix(value: string) { - return runtimeFailureDetail(value); + return runtimeFailureDetail(value) ?? ':宿主未提供具体错误正文'; } export function taskRowsFromManifest( diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts b/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts index 4bd75d065..835422835 100644 --- a/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts +++ b/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * 登录服务 5xx 的状态码。 + * 登录服务 5xx 的状态码与服务端原文。 */ -export type AuthServiceUnavailable = { status: number, }; +export type AuthServiceUnavailable = { status: number, serverMessage: string | null, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadSanitize.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadSanitize.ts index 5a8780be6..c182e7b53 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadSanitize.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadSanitize.ts @@ -512,7 +512,14 @@ export function sanitizeDirectThreadText( ); const withoutAbsolutePaths = redactAbsolutePaths(withoutProjectRoot); const withoutSecrets = redactSecretTokens(withoutAbsolutePaths); - return sanitizeSensitiveLines(withoutSecrets); + const sanitized = sanitizeSensitiveLines(withoutSecrets); + // 旧版已经落盘的失败条目无法凭空恢复被删掉的原因;把它们明确标成“宿主未提供正文”, + // 避免历史记录继续伪装成当前仍在吞错的完整兜底句。新事件带 detail 时不命中这里。 + const legacyGenericFailure = + '陶泥儿智能创作 执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断'; + return sanitized.trim() === legacyGenericFailure + ? `${sanitized}:宿主未提供具体错误正文` + : sanitized; } /** 条目投影的脱敏:只改自由文本字段,身份与时间原样保留。 */ diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts index b27087055..326d52340 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts @@ -77,10 +77,14 @@ export function enqueueFailureNotice(failure: EnqueueError): string | null { return `待发消息已达上限(最多 ${failure.limit} 条),请等前面几条发完再发送`; case 'projectRootUnusable': return '当前项目目录不存在或不是绝对路径'; - case 'permissionRejected': - return failure.policyDetail?.trim() || null; - case 'inputRejected': - return failure.detail?.trim() || null; + case 'permissionRejected': { + const detail = visibleDirectFailureDetail(failure.policyDetail); + return detail ? detail : '项目权限策略拒绝执行:宿主未提供具体错误正文'; + } + case 'inputRejected': { + const detail = visibleDirectFailureDetail(failure.detail); + return detail ? detail : '输入被宿主拒绝:宿主未提供具体错误正文'; + } case 'contentEmpty': return '聊天内容不能为空'; // 宿主 / 环境事实:必须走上报通道,文案由 `enqueueSystemFailureNoticeText` 给固定句。 @@ -109,13 +113,9 @@ export function enqueueSystemFailureNoticeText( ): string | null { switch (failure.type) { case 'environmentNotReady': - return visibleDirectFailureDetail(failure.detail) - ? `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试:${visibleDirectFailureDetail(failure.detail)}` - : `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试`; + return `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试:${visibleDirectFailureDetail(failure.detail) ?? '宿主未提供具体错误正文'}`; case 'hostStateUnavailable': - return visibleDirectFailureDetail(failure.detail) - ? `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试:${visibleDirectFailureDetail(failure.detail)}` - : `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试`; + return `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试:${visibleDirectFailureDetail(failure.detail) ?? '宿主未提供具体错误正文'}`; // 业务变体由 `enqueueFailureNotice` 认领,这里不重复给句;认不出形状时同理。 // 穷尽性由那个函数的 `default: expectNever` 保证——新增变体时那边会先编译失败。 default: diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts index 4d65fe27a..23781c1de 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts @@ -100,7 +100,9 @@ function withVisibleFailureDetail( detail: string | null | undefined, ) { const safeDetail = visibleDirectFailureDetail(detail); - return safeDetail ? `${message}:${safeDetail}` : message; + return safeDetail + ? `${message}:${safeDetail}` + : `${message}:宿主未提供具体错误正文`; } function withVisibleFailureDetailOrMissing( diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index 97c924321..b419300ea 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -24,4 +24,28 @@ describe('Agent Runtime 错误正文', () => { expect(text).toContain('status=401'); expect(text).toContain('detail=invalid token'); }); + + it('正文带敏感字段时只替换敏感值,不丢掉上游状态和原因', () => { + const text = projectRuntimeVisibleError( + 'direct-codex-error: HTTP 502 upstream overloaded;authorization=Bearer provider-secret;https://provider.example/private;C:\\Users\\demo\\game', + '陶泥儿智能创作', + ); + + expect(text).toContain('HTTP 502'); + expect(text).toContain('upstream overloaded'); + expect(text).toContain('[已隐藏凭据]'); + expect(text).not.toContain('provider-secret'); + expect(text).not.toContain('provider.example'); + expect(text).not.toContain('C:\\Users\\demo'); + }); + + it('已知分类但宿主没有正文时明确标记正文缺失', () => { + const text = projectRuntimeVisibleError( + 'kind=codex-app-server-unauthorized', + '陶泥儿智能创作', + ); + + expect(text).toContain('鉴权失败'); + expect(text).toContain('宿主未提供具体错误正文'); + }); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 3d875e617..50fe4a63a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -148,8 +148,7 @@ function submitComposerForm(composer: HTMLElement) { function directTurnInputText(args?: Record) { const content = ( args?.userItem as - | { content?: Array<{ type: string; text?: string }> } - | undefined + { content?: Array<{ type: string; text?: string }> } | undefined )?.content; return (content ?? []) .map((part) => (part.type === 'input_text' ? (part.text ?? '') : '')) @@ -546,13 +545,13 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect( within(conversation).getByText( - '陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试', + '陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试:Codex app-server 启动失败:找不到可执行文件', ), ).not.toBeNull(); }); - // 宿主原文(`detail` 里的可执行文件路径、机器字段)只留在诊断里,不进聊天。 - expect(conversation.textContent ?? '').not.toContain( - 'Codex app-server 启动失败', + // 宿主原文经过精确脱敏后保留在聊天里,便于用户直接定位启动失败原因。 + expect(conversation.textContent ?? '').toContain( + 'Codex app-server 启动失败:找不到可执行文件', ); expect(conversation.textContent ?? '').not.toContain( 'direct-codex-failure', @@ -789,7 +788,7 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect( within(surface).getAllByText( - '陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试', + '陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试:codex-app-server-error:context-window-exceeded', ).length, ).toBeGreaterThan(0); }); @@ -853,7 +852,7 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect( within(conversation).getAllByText( - '陶泥儿智能创作:当前环境未就绪,本轮未完成;请检查本机运行环境后重试', + '陶泥儿智能创作:当前环境未就绪,本轮未完成;请检查本机运行环境后重试:写入本项目对话历史失败:项目对话历史追加写失败', ), ).toHaveLength(1); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index e0d3ae2d2..e29322cfb 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -55,12 +55,12 @@ export function registerProjectConversationTests() { detail: ' 用户条目不合法 ', }), ).toBe('用户条目不合法'); - // 空原文按"没有提示"处理:要么给一条能显示的话,要么给 null。 + // 空原文也不能静默:明确标出宿主未提供正文,避免用户只看到统一兜底句。 expect( enqueueFailureNotice({ type: 'permissionRejected', policyDetail: ' ' }), - ).toBeNull(); + ).toBe('项目权限策略拒绝执行:宿主未提供具体错误正文'); - // 宿主 / 环境事实不在这里认领:它们必须走上报通道,聊天里只给固定句,不回落 `detail`。 + // 宿主 / 环境事实不在这里认领:它们必须走上报通道,聊天里保留精确脱敏 detail。 expect( enqueueFailureNotice({ type: 'environmentNotReady', @@ -79,14 +79,16 @@ export function registerProjectConversationTests() { detail: 'Codex app-server 启动失败:找不到可执行文件', }), ).toBe( - '陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试', + '陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试:Codex app-server 启动失败:找不到可执行文件', ); expect( enqueueSystemFailureNoticeText({ type: 'hostStateUnavailable', detail: '账本损坏:exitStatus=signal: 9 (SIGKILL)', }), - ).toBe('陶泥儿智能创作:宿主执行状态不可用,这一轮没有开始;请稍后重试'); + ).toBe( + '陶泥儿智能创作:宿主执行状态不可用,这一轮没有开始;请稍后重试:账本损坏:exitStatus=signal: 9 (SIGKILL)', + ); // JS 侧载体:结构化载荷装进 Error,`error` 与 `message`(序列化的机器事实)都带出去。 const wrapper = new EnqueueFailureWrapper({ diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index d3a43aa58..fff8a2a9d 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -196,7 +196,11 @@ describe('变体分流', () => { variantMessage({ type: 'passwordLoginRejected', serverMessage: null }), ).toBe('登录输入被拒绝'); expect( - variantMessage({ type: 'authServiceUnavailable', status: 503 }), + variantMessage({ + type: 'authServiceUnavailable', + status: 503, + serverMessage: null, + }), ).toBe('HTTP 503'); }); diff --git a/apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts b/apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts index 434c7acb0..f7d5fe8b8 100644 --- a/apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts @@ -92,6 +92,17 @@ describe('directThreadSanitize', () => { ); }); + it('旧版通用中断条目明确标记宿主没有记录正文', () => { + expect( + sanitizeDirectThreadText( + ROOT, + '陶泥儿智能创作 执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断', + ), + ).toBe( + '陶泥儿智能创作 执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断:宿主未提供具体错误正文', + ); + }); + it('多行文本保留换行', () => { const value = '第一段\nkey=sk-abcdefghijklmnop\n第三段\n'; expect(sanitizeDirectThreadText(ROOT, value)).toBe( diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index ac6ac1aab..73bd925cc 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -128,6 +128,9 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - AGC 项目资源编辑、上传、预览、恢复队列和生成结果回读的面板错误统一经过 `normalizeDiagnosticText`,保留状态码与安全正文,替换凭据、URL、路径和长标识;资源业务提示不再直接展示 `Error.message`。 - 应用壳、运行配置、认证状态、插件启动、运行预览和策划会话的普通客户端错误统一经过 `visibleClientErrorMessage`;结构化认证拒绝仍按 typed reason 分流,非结构化错误保留安全正文后再进入状态提示或错误上报。 - 资产导入、邀请码、策划工作区、发布封面/截图和素材命令的用户可见错误也复用该出口,避免业务面板把原始 `Error.message` 重新带回界面。 +- 外部 MCP loopback 的 401/429/桥上下文缺失必须返回带正文的 JSON 错误体(同时保留 HTTP 状态);不能让 Claude Code 侧只看到空 body 的状态码。 +- 认证 5xx、账户/素材上传/发行发布的 401/403 保留服务端 `message`/`code` 后再做精确脱敏;空 `{}`、`null` 等无信息响应不算错误正文。 +- 旧版已落盘的通用“执行通道中断”文本无法恢复历史原因,读取时明确追加“宿主未提供具体错误正文”;新事件只在确实没有 detail 时使用同样标记。 ## 后果与边界 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index a8feaef46..4fbdfa09b 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -49,3 +49,6 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 真实 AGC dev smoke:客户端使用 `3080`、后端 `8084`、数据库 `3101`、后台 `3103` 启动;真实 CC 回合复现 `Reached maximum number of turns (8)` 被旧代码错误记为 `transport-closed`,修复后 Tauri 已热重编译重启。修复后的真实 Provider 回放未再次发送,避免无必要的付费请求。 - 真实 AGC 项目诊断发现真实请求 HTTP 400 曾被摘要为 `codex-app-server-error:other detail=fields=codexErrorInfo`,另有 401 仅显示 `codex-app-server-error:unauthorized`;该漏损已在 app-server 投影边界修复。真实 Provider、真实 IPC 断链和真实内存压力仍未在本轮主动制造。 - 继续扫描发现素材/参考图/资源编辑上传、模型目录初始化和 Direct MCP stdio 仍有固定 `map_err(|_| ...)` 丢正文,已改为保留底层网络、serde、写入和 OS 错误;平台素材上传定向 3 项、模型目录定向 4 项、Direct MCP 断输出定向 1 项通过。资源编辑全套并行测试本轮 54 项通过、11 项因共享 fixture/本地端口或账号环境超时失败,不能把该套结果记为全绿。 +- 本轮继续扫出三类未收敛口:外部 MCP HTTP 的 401/429/桥上下文缺失原先只有空状态码响应,现改为保留 HTTP 状态、稳定错误码和中文正文;认证 5xx 结构化载荷、账户/素材上传/发行发布的 401/403 现保留服务端 message/code(仍精确脱敏)。 +- 前端历史错误文本此前会把含有敏感字段的整条 Direct 诊断直接丢弃,现改为只替换凭据、链接和路径;旧版已经落盘且无法恢复正文的“执行通道中断”条目会明确追加“宿主未提供具体错误正文”,不再伪装成当前仍在吞错。 +- 新增/通过:外部 MCP HTTP 401/429 定向 2 项、前端 Direct/Runtime 错误正文 25 项、旧历史通用中断脱敏回归 1 项;认证 Rust 定向 19 项、素材上传响应错误定向 1 项通过。`appSurface` 全套仍有环境/fixture 相关失败,不能据此宣称全绿。