修复 Linux 命令执行将孤儿僵尸误判为未回收进程
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m59s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m46s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m39s
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m59s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m46s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m39s
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
命令主进程退出后区分存活成员与僵尸成员,保留进程组身份校验和失败关闭语义 处理 procfs 扫描期间进程退出及非 UTF-8 进程名,空进程组直接返回 新增隔离 subreaper 回归测试并补全命令 observation 断言诊断 同步 Runtime 技术方案与共享排障记录
This commit is contained in:
@@ -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<bool, String> {
|
||||
let inspect = || -> std::io::Result<bool> {
|
||||
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::<u32>().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::<u32>().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::<libc::siginfo_t>() };
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<pid>/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` 限制。
|
||||
|
||||
@@ -254,6 +254,7 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> -
|
||||
- `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/<pid>/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`。
|
||||
|
||||
Reference in New Issue
Block a user