From f3fbf51a1736874d124faa1bff6742b8ef4df770 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:42:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E7=95=99=E7=8E=AF=E5=A2=83=E9=A2=84?= =?UTF-8?q?=E6=A3=80=E5=92=8C=20Web=20=E6=9E=84=E5=BB=BA=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=AD=A3=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 Node npm stdout stderr 退出状态和超时原因 预检回执同时返回机器码与脱敏诊断 补充环境失败正文回归测试 --- .../src-tauri/src/environment_check.rs | 200 ++++++++++++++---- .../src/environment_check/web_creation.rs | 118 +++++------ ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...实施计划】AGC错误具体文本展示-2026-10-04.md | 1 + 4 files changed, 224 insertions(+), 96 deletions(-) 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..e187a12a1 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, @@ -357,11 +428,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 +451,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 +508,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 +558,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 +571,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 } } }; @@ -623,6 +727,28 @@ mod tests { 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(); 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/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 50a3ff361..e91109988 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -110,6 +110,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败、失败终态、缺失终态、超时和空回执都保留解析错误、失败事件正文与有界安全片段;`LlmError` 没有 detail 字段的超时/空回执由 Direct `ModelCallFailed.detail` 补回,不再把这些协议事实统一压成单一类别。 - 账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥共用同一原则:网络错误保留底层因链,JSON/协议解析保留 serde 原因,HTTP 错误保留状态码与安全正文;只有响应完全没有正文时才说明“未提供 error/code/message”,不得退成“无法连接/格式无效/服务器未返回错误信息”。 - 浏览器启动/DevTools 握手与 Codex model-catalog 子进程失败保留 stderr、退出状态、解析错误和阶段;稳定机器码仍用于分类,但不能单独成为用户可见正文。 +- Node/npm 环境探测和 Web scaffold 构建持续排空并保存有界 stdout/stderr 尾部,失败返回退出状态、超时阶段和脱敏正文;环境预检回执同时下发 `code` 与 `diagnostic`。 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 - `hostDropped` 携带可选的脱敏 `detail`:panic hook 能取得的负载和位置随原回合占用进入失败事件;普通 Drop 仅记录“退出时未写终态、未观察到 panic”,不推断为网络中断。旧版本无 `detail` 的事件继续可读,界面明确标注旧事件未记录原因。 - DirectProject 控制器的取消待发、终止、附件上传、项目历史读取和发送前置异常也复用同一份前端精确脱敏出口;这些非回合错误不得直接把 `Error.message` 原文写入状态栏、composer 或历史错误行。 diff --git a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md index 3bc04036e..ffe252f3d 100644 --- a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -20,6 +20,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` 11. Codex CLI / Claude sidecar 的解析失败、失败终态、缺失终态、超时和空回执保留有界脱敏正文;即使平台 `LlmError` 变体没有 detail 字段,也由统一 Direct `ModelCallFailed.detail` 继续携带原始原因。 12. 扩展统一正文口径到账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥,保留 HTTP 状态、响应安全片段、JSON 解析原因、网络因链和 IPC/桥接原因。 13. 浏览器启动/DevTools 握手与 Codex model-catalog 子进程错误保留 stderr、退出状态、解析原因和阶段信息,机器码只作为分类字段。 +14. Node/npm 探测与 Web scaffold 构建失败保留有界脱敏 stdout/stderr、退出状态和超时原因;`preflight_web_game_creation` 不再只返回机器码。 ## 实现顺序