保留环境预检和 Web 构建错误正文

保留 Node npm stdout stderr 退出状态和超时原因

预检回执同时返回机器码与脱敏诊断

补充环境失败正文回归测试
This commit is contained in:
kdletters
2026-10-05 17:42:26 +08:00
parent 11cf7a49fc
commit f3fbf51a17
4 changed files with 224 additions and 96 deletions
@@ -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<u8>,
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<String, String> {
// 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<String, Strin
.env("npm_config_update_notifier", "false")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true);
for name in ["SystemRoot", "WINDIR", "ComSpec", "TEMP", "TMP"] {
if let Some(value) = std::env::var_os(name) {
@@ -392,27 +466,40 @@ async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result<String, Strin
crate::configure_windows_background_tokio_command(&mut command, true);
let mut child = command
.spawn()
.map_err(|_| "runtime-version-start-failed")?;
let mut stdout = child
.stdout
.take()
.ok_or("runtime-version-output-missing")?
.take(MAX_PROBE_BYTES + 1);
let mut output = Vec::new();
.map_err(|error| environment_failure("runtime-version-start-failed", error))?;
let stdout = child.stdout.take().ok_or_else(|| {
environment_failure("runtime-version-output-missing", "stdout 管道未建立")
})?;
let stderr = child.stderr.take().ok_or_else(|| {
environment_failure("runtime-version-output-missing", "stderr 管道未建立")
})?;
let mut output = CapturedProcessOutput::default();
let mut error_output = CapturedProcessOutput::default();
let result = tokio::time::timeout(PROBE_TIMEOUT, async {
stdout
.read_to_end(&mut output)
.await
.map_err(|_| "runtime-version-output-failed")?;
if output.len() as u64 > 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<String, Strin
let _ = child.kill().await;
let _ = child.wait().await;
}
result
.map_err(|_| "runtime-version-timeout")?
.map_err(str::to_string)?;
let version = String::from_utf8(output)
.map_err(|_| "runtime-version-invalid")?
result.map_err(|_| {
process_failure_output(
"runtime-version-timeout",
format!("版本探测超过 {} ms", PROBE_TIMEOUT.as_millis()),
&output,
&error_output,
)
})??;
let version = String::from_utf8(output.bytes)
.map_err(|error| environment_failure("runtime-version-invalid", error))?
.trim()
.to_string();
if !valid_version(&version) {
return Err("runtime-version-invalid".into());
return Err(environment_failure(
"runtime-version-invalid",
format!("预期三段数字版本,实际输出={version}"),
));
}
Ok(version)
}
@@ -443,11 +538,16 @@ pub(crate) async fn check_environment(root: &Path) -> 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();
@@ -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<R: tokio::io::AsyncRead + Unpin>(
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<String> {
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!(
@@ -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 或历史错误行。
@@ -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` 不再只返回机器码。
## 实现顺序