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 5c5168d14..c03b02a91 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 @@ -241,11 +241,72 @@ impl ProjectCommandTree { #[cfg(not(windows))] { let _ = child; + #[cfg(target_os = "linux")] + { + let Self::Group { pid, .. } = self; + // 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。 + // 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。 + if !linux_project_command_group_has_live_members(*pid)? { + return Ok(()); + } + } self.request_owned_group_termination().map(|_| ()) } } } +#[cfg(target_os = "linux")] +fn linux_project_command_group_has_live_members(group: u32) -> Result { + let inspect = || -> std::io::Result { + let process_group = i32::try_from(group) + .ok() + .filter(|group| *group > 0) + .ok_or_else(|| std::io::Error::other("受控命令进程组身份无效"))?; + if unsafe { libc::kill(-process_group, 0) } != 0 { + let error = std::io::Error::last_os_error(); + return if error.raw_os_error() == Some(libc::ESRCH) { + Ok(false) + } else { + Err(error) + }; + } + for entry in fs::read_dir("/proc")? { + let entry = entry?; + if entry.file_name().to_string_lossy().parse::().is_err() { + continue; + } + let stat = match fs::read(entry.path().join("stat")) { + Ok(stat) => stat, + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + || error.raw_os_error() == Some(libc::ESRCH) => + { + continue; + } + Err(error) => return Err(error), + }; + let invalid_stat = || std::io::Error::other("无法解析 /proc 进程组状态"); + // comm 可以包含括号和非 UTF-8 字节;只解析最后一个分隔符后的 ASCII 字段。 + let end = stat + .windows(2) + .rposition(|pair| pair == b") ") + .ok_or_else(invalid_stat)?; + let tail = std::str::from_utf8(&stat[end + 2..]).map_err(|_| invalid_stat())?; + let mut fields = tail.split_whitespace(); + let state = fields.next().ok_or_else(invalid_stat)?; + let process_group = fields + .nth(1) + .and_then(|value| value.parse::().ok()) + .ok_or_else(invalid_stat)?; + if process_group == group && state != "Z" && state != "X" { + return Ok(true); + } + } + Ok(false) + }; + inspect().map_err(|error| format!("读取受控进程组存活状态失败:{error}")) +} + #[cfg(any(unix, test))] fn owned_project_command_group_identity_matches( expected: Option<&str>, @@ -2504,6 +2565,67 @@ where mod tests { use super::*; + #[cfg(target_os = "linux")] + #[tokio::test] + async fn exited_group_accepts_orphan_zombies_but_rejects_live_members() { + use std::os::unix::process::CommandExt; + + const TEST: &str = + "command_exec::tests::exited_group_accepts_orphan_zombies_but_rejects_live_members"; + const FIXTURE: &str = "AGC_COMMAND_ORPHAN_FIXTURE"; + if std::env::var_os(FIXTURE).is_none() { + // subreaper 只影响隔离夹具,避免接管并行测试的子进程。 + let output = tokio::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST, "--nocapture"]) + .env(FIXTURE, "1") + .output() + .await + .unwrap(); + assert!(output.status.success(), "{output:?}"); + return; + } + assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0); + let mut command = tokio::process::Command::new("/bin/sh"); + command + .args(["-c", "sleep 60 & echo $!; read release"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + command.as_std_mut().process_group(0); + let mut child = command.spawn().unwrap(); + let tree = ProjectCommandTree::attach(&child).unwrap(); + let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap()); + let mut line = String::new(); + tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line) + .await + .unwrap(); + let descendant: i32 = line.trim().parse().unwrap(); + drop(child.stdin.take()); + child.wait().await.unwrap(); + let live_result = tree.after_main_exit(&mut child).await; + assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0); + let mut info = unsafe { std::mem::zeroed::() }; + assert_eq!( + unsafe { + libc::waitid( + libc::P_PID, + descendant as u32, + &mut info, + libc::WEXITED | libc::WNOWAIT, + ) + }, + 0 + ); + let zombie_result = tree.after_main_exit(&mut child).await; + assert_eq!( + unsafe { libc::waitpid(descendant, std::ptr::null_mut(), 0) }, + descendant + ); + let error = live_result.expect_err("存活成员缺少 leader 身份时必须拒绝清理"); + assert!(error.contains("leader 身份未确认"), "{error}"); + zombie_result.expect("已回收 leader 的进程组只剩僵尸时不应要求人工核对"); + tree.after_main_exit(&mut child).await.unwrap(); + } + #[test] fn owned_process_group_refuses_missing_or_reused_leader_identity() { assert!(owned_project_command_group_identity_matches( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs index b64d1a51d..42aee42ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs @@ -1827,7 +1827,7 @@ async fn agent_runtime_command_exec_diagnostic_command_cannot_pass_verification_ ) .await; - assert_eq!(observation.status, "ok"); + assert_observation_status(&observation, "ok"); assert!(observation.summary.contains("只作为诊断结果")); assert!(observation .detail diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0205590b1..bacbfd2bc 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4143,6 +4143,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:活会话下 finalization 和 `runner.shutdown_if_idle` 必须失败关闭;分别验证 graceful handler 尾部输出、宽限超时后的 force、忽略 SIGHUP 的 npm 孙进程和 Windows Job 路径,只有 child 已终态、同组残留已处理且 PTY 尾部排空才出现唯一 terminal record。另用允许程序证明代理和固定 cwd 不是文件系统 / 网络沙箱,不得把该现象误写成测试失败或安全能力。 - 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`、`apps/ai-game-creator-shell/src-tauri/src/runner.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent.rs`。 +## 一次性命令不能把孤儿僵尸误判为仍在执行的进程组 + +- 现象:Linux CI 的命令已退出,但 `command.exec` 返回 `needs-reconciliation`,后续修复计划或输出读取请求一直等不到;普通 WSL 下相同命令可通过。 +- 原因:容器 PID 1 未回收孤儿 `bwrap` 僵尸,`kill(-pgid, 0)` 仍返回成功;主进程已被 wait 回收,后续 leader 启动身份核对必然失败,掩盖了真实退出结果与原本应触发的日志审计错误。 +- 处理:确认 target 终态且回收主进程后扫描 `/proc//stat`,空组或仅含 `Z / X` 成员无需发送信号;有存活成员仍保留 leader 身份门禁,读取失败保守进入 reconciliation,不放宽未知进程组的信号权限。 +- 验证:隔离 subreaper 夹具覆盖 leader 已回收时的存活后代拒绝、孤儿僵尸接受和空组接受;原有诊断命令、审计失败、命令修复与长输出/历史读取测试在不回收孤儿的 PID namespace 下验证。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_exec.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/command_runtime.rs`。 + ## 命令环境变量、代理和进程组不能冒充 OS 沙箱 - 现象:命令看似使用隔离 HOME / TMP、离线包管理器和不可达代理,仍能直接读取宿主用户文件、用原始 socket 联网,或由 `project.verify` 的平行 npm spawn 绕开 `command.exec` 限制。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 39a9f1760..b07266394 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -254,6 +254,7 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir - - `cwd` 必须是项目内规范相对目录,拒绝符号链接、绝对路径、`..`、Windows 盘符 / UNC / ADS 和整个 `.agent` 控制面;超时固定在 1-300 秒,stdin 关闭,stdout / stderr 采用有界头尾保留并先做凭据清洗。 - 默认权限为 `confirm`。确认摘要包含程序、argv 摘要、cwd 和超时;精确动作继续绑定 actionId、repository fingerprint、project revision 和 execution owner。Runner 在 `executing` 阶段退出时保持 `needs-reconciliation`,不得自动重放命令。 - 子进程继承环境清空;可执行文件必须从项目外安全绝对目录解析为绝对路径,子进程 PATH 只保留这些已规范化目录,并注入隔离 HOME / TMP / cache、离线包管理配置和不可达代理。超时或读流失败时 Runtime 请求终止受控进程组并检查终止调用结果,但这不等同于完整 detached-process / 容器隔离。首版安全等级与现有 `project.verify` 相同:固定程序和参数策略加用户确认,不宣称已经具备 Codex CLI 的完整 OS sandbox;在完成平台沙箱前不得把 `command.exec` 默认改为 `auto`。 +- Linux 一次性命令确认 target 终态并回收主进程后,按 `/proc//stat` 核对同 PGID 成员;空组或只剩 `Z / X` 成员无需再发送信号,不因容器 PID 1 未回收孤儿僵尸而误报 reconciliation。存在存活成员时仍必须核对原 leader 启动身份,身份缺失或不匹配时拒绝发送信号;读取或解析进程状态失败同样进入 reconciliation。此判断不扩展为 detached-process 隔离证明,也不改变诊断命令与验证 gate 的区分。 - Cargo / npm 缓存固定写入项目私有 `.agent/runtime/command-env/cache`,不复用或改写用户宿主缓存,也不允许联网补依赖。依赖未进入项目 vendor、现有 `node_modules` 或隔离缓存时,命令应以真实失败输出回到 Agent;首版不为“跑通命令”复制宿主的 Cargo registry、凭据或用户级配置。 - `command.exec` 的执行前后源码指纹各自最多遍历 20,000 个目录项、10,000 个受保护文件和 512 MiB 正文;执行前超预算直接拒绝启动,执行后无法完成指纹则进入 `needs-reconciliation`,不得把截断扫描当成完整验证凭证。 - 命令结束后重建安全项目文件指纹。若命令改写了受保护项目文件,则保持 verification gate 未通过并要求 Agent 重新检查;每次真正启动命令前已经保守推进一次 revision。可签发验证凭证的命令仅限 `cargo check/test/clippy/fmt/build`、`npm test`、命名为 `check/typecheck/test/lint/build/verify/validate` 的 npm 验证脚本及精确 `node --test`;`git`、`rg`、`cargo metadata` 和普通 `npm run` 即使退出码为 0 也只作为诊断结果。只有验证型命令退出码为 0、未超时、未改写受保护文件,且命令日志、manifest 投影和 Agent DB 审计全部成功后,才允许绑定当前 revision 的 passed gate;任一审计失败必须先保持 failed gate,再进入 `needs-reconciliation`。