From eccaa5f79c4fc4334b1a55c7ab65e45921b12914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 15:41:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=9E=E9=80=80=20sandbox=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=A4=B1=E8=B4=A5=E8=AF=8A=E6=96=AD=E6=97=A5=E5=BF=97?= =?UTF-8?q?=EF=BC=8C=E6=81=A2=E5=A4=8D=20application.log=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 回退 6311bb583:移除 sandbox 启动失败时采集子进程 stdout/stderr 的宿主日志及保留绝对路径的变体; - application.log 恢复对所有行统一脱敏绝对路径的既有行为; - 真实 npm 启动故障改从宿主 Node 环境修复,不改日志代码。 --- .../src-tauri/src/command_exec.rs | 131 +----------------- .../src-tauri/src/main.rs | 44 +----- docs/project-memory/shared-memory/pitfalls.md | 8 -- 3 files changed, 7 insertions(+), 176 deletions(-) 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 2ab979478..14a4e6096 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 @@ -1853,18 +1853,18 @@ where let (mut gate, ready) = match ready_task { Ok(result) => result, Err(error) => { - let cause = format!("等待 sandbox-ready 任务失败:{error}"); - let termination = - terminate_project_command_launch_and_log_diagnostics(&mut child, &cause).await; + let termination = terminate_project_command_process_group(&mut child).await; return Err(ProjectCommandError::new( ProjectCommandErrorStage::Preflight, - project_command_launch_error_with_termination(cause, termination), + project_command_launch_error_with_termination( + format!("等待 sandbox-ready 任务失败:{error}"), + termination, + ), )); } }; if let Err(error) = ready { - let termination = - terminate_project_command_launch_and_log_diagnostics(&mut child, &error).await; + let termination = terminate_project_command_process_group(&mut child).await; return Err(ProjectCommandError::new( ProjectCommandErrorStage::Preflight, project_command_launch_error_with_termination(error, termination), @@ -2085,103 +2085,6 @@ fn terminate_project_command_process_group_after_commit( )) } -/// sandbox 启动失败时,bwrap / trampoline 子进程的 stderr / stdout 是唯一能说明 setup -/// 失败原因的证据。启动失败路径原先直接 kill + wait,管道里的诊断随 `Child` 一起被丢掉, -/// 只剩 `Connection reset by peer` 这类不透明的 OS 错误。这里只做有界采集,凭据脱敏但保留 -/// 绝对路径(bwrap 的 `Can't find source path` 里路径就是唯一证据),写入宿主 -/// application.log 供诊断包使用;不进入模型可见错误、command log、receipt 或 Agent DB。 -#[cfg(target_os = "linux")] -const SANDBOX_LAUNCH_DIAGNOSTIC_MAX_BYTES: usize = 8 * 1024; -#[cfg(target_os = "linux")] -const SANDBOX_LAUNCH_DIAGNOSTIC_MAX_WAIT: Duration = Duration::from_secs(2); - -#[cfg(target_os = "linux")] -async fn read_bounded_sandbox_launch_stream(mut reader: R, budget: usize) -> Vec -where - R: tokio::io::AsyncRead + Unpin, -{ - let mut collected = Vec::new(); - let mut buffer = [0_u8; 1024]; - while collected.len() < budget { - match reader.read(&mut buffer).await { - Ok(0) | Err(_) => break, - Ok(count) => { - let remaining = budget - collected.len(); - collected.extend_from_slice(&buffer[..count.min(remaining)]); - } - } - } - collected -} - -#[cfg(target_os = "linux")] -async fn collect_sandbox_launch_streams( - stdout: Option, - stderr: Option, -) -> (Vec, Vec) { - let read_stdout = async { - match stdout { - Some(reader) => { - read_bounded_sandbox_launch_stream(reader, SANDBOX_LAUNCH_DIAGNOSTIC_MAX_BYTES) - .await - } - None => Vec::new(), - } - }; - let read_stderr = async { - match stderr { - Some(reader) => { - read_bounded_sandbox_launch_stream(reader, SANDBOX_LAUNCH_DIAGNOSTIC_MAX_BYTES) - .await - } - None => Vec::new(), - } - }; - tokio::join!(read_stdout, read_stderr) -} - -/// 把 sandbox 子进程的输出整理成一条宿主日志:非空、单行、凭据已脱敏。 -/// 绝对路径必须保留——bwrap `Can't find source path` 的路径就是唯一证据。 -#[cfg(target_os = "linux")] -fn format_sandbox_launch_diagnostics(stdout: &[u8], stderr: &[u8]) -> Option { - let mut sections = Vec::new(); - for (stream_name, bytes) in [("stdout", stdout), ("stderr", stderr)] { - let text = String::from_utf8_lossy(bytes); - let text = text.trim(); - if text.is_empty() { - continue; - } - let sanitized = crate::sanitize_diagnostic_message_preserving_paths(text, None); - let sanitized = sanitized.trim(); - if !sanitized.is_empty() { - sections.push(format!("{stream_name}={sanitized}")); - } - } - (!sections.is_empty()).then(|| sections.join(";")) -} - -/// 启动失败路径统一入口:先取走管道(否则 `wait()` 回收后诊断随 `Child` 一起被丢掉), -/// 再终止受控进程组,最后在有界时间内读空管道并写入宿主日志。 -#[cfg(target_os = "linux")] -async fn terminate_project_command_launch_and_log_diagnostics( - child: &mut tokio::process::Child, - cause: &str, -) -> Result { - let stdout = child.stdout.take(); - let stderr = child.stderr.take(); - let termination = terminate_project_command_process_group(child).await; - let (stdout_bytes, stderr_bytes) = tokio::time::timeout( - SANDBOX_LAUNCH_DIAGNOSTIC_MAX_WAIT, - collect_sandbox_launch_streams(stdout, stderr), - ) - .await - .unwrap_or_default(); - let diagnostics = format_sandbox_launch_diagnostics(&stdout_bytes, &stderr_bytes) - .unwrap_or_else(|| "stdout/stderr 无输出".to_string()); - app_log_with_paths!("command.exec sandbox 启动失败诊断:cause={cause};{diagnostics}"); - termination -} - async fn read_bounded_project_command_output( mut reader: R, ) -> Result @@ -3664,26 +3567,4 @@ raise SystemExit(code)' assert!(output.text.contains("omitted")); assert!(output.truncated); } - - #[cfg(target_os = "linux")] - #[test] - fn sandbox_launch_diagnostics_keep_source_paths_and_redact_secrets() { - let diagnostics = format_sandbox_launch_diagnostics( - b"", - b"bwrap: Can't find source path /home/alice/project/game No such file or directory\n", - ) - .expect("non-empty stderr yields diagnostics"); - assert!(diagnostics.contains("bwrap"), "{diagnostics}"); - assert!( - diagnostics.contains("/home/alice/project/game"), - "source path is the diagnostic evidence and must survive: {diagnostics}" - ); - assert!( - format_sandbox_launch_diagnostics(b"", b" \n \t").is_none(), - "whitespace-only streams must not produce a diagnostic line" - ); - let secret = format_sandbox_launch_diagnostics(b"", b"authorization: Bearer abc123\n") - .expect("secret-bearing stderr still yields a redacted diagnostic"); - assert!(!secret.to_ascii_lowercase().contains("bearer"), "{secret}"); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 91c65c81b..a56425d58 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -68,17 +68,6 @@ macro_rules! app_log { }}; } -/// 与 `app_log!` 相同,但保留绝对路径。只给「路径本身就是诊断证据」的宿主失败用 -/// (例如 bwrap `Can't find source path`);凭据标记仍整行脱敏。 -#[cfg(target_os = "linux")] -macro_rules! app_log_with_paths { - ($($arg:tt)*) => {{ - let message = format!($($arg)*); - let _ = $crate::append_application_log_line_preserving_paths(&format!("RUST {}: {}", module_path!(), message)); - std::eprintln!("{}", message); - }}; -} - // 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。 // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs")); @@ -1656,17 +1645,6 @@ pub(crate) fn append_application_log_line(line: &str) -> std::io::Result<()> { append_bounded_diagnostic_line(&config_dir.join("diagnostics/application.log"), &sanitized) } -/// 与 `append_application_log_line` 相同,但保留绝对路径。只给「路径本身就是诊断证据」的 -/// 宿主失败用;凭据标记仍由 `sanitize_diagnostic_message_preserving_paths` 整行脱敏。 -#[cfg(target_os = "linux")] -pub(crate) fn append_application_log_line_preserving_paths(line: &str) -> std::io::Result<()> { - let Some(config_dir) = game_creator_runtime_config_dir() else { - return Ok(()); - }; - let sanitized = sanitize_diagnostic_message_preserving_paths(line, Some(&config_dir)); - append_bounded_diagnostic_line(&config_dir.join("diagnostics/application.log"), &sanitized) -} - fn redact_windows_absolute_paths(value: &str) -> String { let bytes = value.as_bytes(); let mut output = String::with_capacity(value.len()); @@ -1726,24 +1704,6 @@ fn redact_unix_absolute_paths(value: &str) -> String { } pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Path>) -> String { - sanitize_diagnostic_message_inner(value, private_root, true) -} - -/// 与 `sanitize_diagnostic_message` 相同,但保留绝对路径。路径本身就是诊断证据的宿主失败 -/// (例如 bwrap `Can't find source path`)必须走这一条;凭据标记仍然整行脱敏。 -#[cfg(target_os = "linux")] -pub(crate) fn sanitize_diagnostic_message_preserving_paths( - value: &str, - private_root: Option<&Path>, -) -> String { - sanitize_diagnostic_message_inner(value, private_root, false) -} - -fn sanitize_diagnostic_message_inner( - value: &str, - private_root: Option<&Path>, - redact_paths: bool, -) -> String { let mut sanitized = value.replace(['\r', '\n'], " "); if let Some(root) = private_root { let root = root.to_string_lossy(); @@ -1768,9 +1728,7 @@ fn sanitize_diagnostic_message_inner( { return "".to_string(); } - if redact_paths { - sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized)); - } + sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized)); sanitized.chars().take(2_048).collect() } diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 39086a3f5..874d626b1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4149,14 +4149,6 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:并发执行全部 project verification 测试;同时真实运行 bwrap staged marker 用例,确认 child-created、block、ready、commit、exec 和目标退出链均稳定,目标 argv/env/FD 不含 nonce 或控制 socket。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox_trampoline.rs`、`command_sandbox.rs`、`command_exec.rs`、`project.rs`。 -## sandbox 启动失败不能把子进程 stderr 一起丢掉 - -- 现象:`command.exec` 在 `wait_sandbox_ready` 阶段失败时,模型和日志只看到 `等待 command sandbox ready 失败:Connection reset by peer (os error 104)`;无法判断是 userns、AppArmor、mount 还是 trampoline 自身失败,真实 bwrap 报错完全不可见。 -- 原因:bwrap/trampoline 以 `Stdio::piped()` 启动,但启动失败路径只调用 `terminate_project_command_process_group`(`kill` + `wait`)后即返回;`Child` 被回收时管道里的 stderr/stdout 一并丢失。项目自己的真实 launcher 测试(`command_sandbox.rs` 的 opt-in staged gate 用例)反而会 `wait_with_output()` 并把 stderr 打进 panic。 -- 处理:启动失败统一走 `terminate_project_command_launch_and_log_diagnostics`:先 `take()` stdout/stderr,再 kill + wait,然后在有界时间(2s)和字节(8 KiB/流)内读空,经 `sanitize_diagnostic_message_preserving_paths` 只做凭据脱敏、保留绝对路径(bwrap 的 `Can't find source path` 里路径就是唯一证据),写入宿主 `application.log`;诊断只进宿主诊断包,不进入模型可见错误、command log、receipt 或 Agent DB。默认的 `sanitize_diagnostic_message` 仍然整行脱敏绝对路径,只有这条宿主失败日志走保留路径的变体。 -- 验证:`sandbox_launch_diagnostics_keep_source_paths_and_redact_secrets` 断言源路径保留、凭据整体脱敏、纯空白流不产出行;`GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1 cargo test ... real_linux_opt_in` 的真实 bwrap 握手用例保持通过。 -- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_exec.rs`、`command_sandbox.rs`、`command_sandbox_trampoline.rs`。 - ## bwrap 的命令分隔符不能从目标 argv 末尾反查 - 现象:普通命令握手正常,但 `cargo test -- --nocapture`、npm forwarded args 等包含独立 `--` 的合法目标参数可能在 sandbox preflight 或 staged launch 期间提前执行原目标。