From 689082901f43c2c6803211e9b1ce0fe1ac376cf5 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Tue, 14 Jul 2026 09:41:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BA=E6=8C=81=E4=B9=85=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8F=AF=E4=BF=A1=E5=90=AF=E5=8A=A8=E6=8F=A1?= =?UTF-8?q?=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 PTY 外私有 bridge 与 sandbox ready/commit/exec 协议 升级 process record v3 并收紧恢复、幂等与 final/idle 门禁 完善 target 前台进程组、graceful 后代收束和审计失败处理 补齐跨平台回归测试及 Runtime 文档 --- .../src-tauri/src/agent.rs | 72 +- .../src-tauri/src/command_sandbox.rs | 125 +- .../src/command_sandbox_trampoline.rs | 412 ++- .../src-tauri/src/main.rs | 13 +- .../src-tauri/src/process_session.rs | 2283 +++++++++++++++-- .../src-tauri/src/process_session_bridge.rs | 874 +++++++ .../src-tauri/src/tests.rs | 189 ++ .../shared-memory/decision-log.md | 14 + docs/project-memory/shared-memory/pitfalls.md | 24 + ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 8 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + 11 files changed, 3805 insertions(+), 211 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 8bad5fa35..d73253319 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -12584,6 +12584,9 @@ fn agent_runtime_process_poll_detail( "sandboxMode": result.sandbox_mode, "networkAccess": result.network_access, "sandboxProfileVersion": result.sandbox_profile_version, + "sandboxEstablishment": result.sandbox_establishment, + "targetExec": result.target_exec, + "launchFailureKind": result.launch_failure_kind, }); if include_output { detail @@ -12629,6 +12632,9 @@ fn append_agent_runtime_process_poll_audit( "sandboxMode": result.sandbox_mode, "networkAccess": result.network_access, "sandboxProfileVersion": result.sandbox_profile_version, + "sandboxEstablishment": result.sandbox_establishment, + "targetExec": result.target_exec, + "launchFailureKind": result.launch_failure_kind, }), ) } @@ -12772,11 +12778,6 @@ fn observe_agent_runtime_command_start( }; } }; - if let Err(error) = - prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "command.start") - { - return agent_runtime_mutation_gate_failure_observation(root, "command.start", &error); - } let source_fingerprint = match project_command_source_fingerprint(root) { Ok(fingerprint) => fingerprint, Err(error) => { @@ -12786,7 +12787,7 @@ fn observe_agent_runtime_command_start( summary: "command.start 无法计算启动前源码指纹,进程未启动".to_string(), detail: Some( serde_json::json!({ - "revisionAdvanced": true, + "revisionAdvanced": false, "error": redact_agent_runtime_project_paths(root, &error, 500), }) .to_string(), @@ -12794,40 +12795,85 @@ fn observe_agent_runtime_command_start( }; } }; - let result = match start_prepared_process_session_at( + let revision_before = match read_game_creator_agent_runtime_project_revision(root) { + Ok(revision) => revision.revision, + Err(error) => { + return agent_runtime_mutation_gate_failure_observation(root, "command.start", &error); + } + }; + let mut result = match start_prepared_process_session_at( root, identity, &command_spec, &command_launch, source_fingerprint, + || { + prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "command.start") + .map(|_| ()) + }, ) { Ok(result) => result, Err(error) => { + let revision_advanced = read_game_creator_agent_runtime_project_revision(root) + .map(|revision| (revision.revision > revision_before).to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + let needs_reconciliation = error.needs_reconciliation(); return AgentRuntimeToolObservation { tool: "command.start".to_string(), - status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), - summary: "command.start 启动结果无法完整确认".to_string(), + status: if needs_reconciliation { + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + } else { + "failed" + } + .to_string(), + summary: if needs_reconciliation { + "command.start 启动结果无法完整确认".to_string() + } else { + "command.start 未进入目标执行".to_string() + }, detail: Some( serde_json::json!({ - "revisionAdvanced": true, - "error": redact_agent_runtime_project_paths(root, &error, 500), + "revisionAdvanced": revision_advanced, + "launchFailureKind": error.stage().as_str(), + "error": redact_agent_runtime_project_paths(root, error.message(), 500), }) .to_string(), ), }; } }; + let revision_advanced = read_game_creator_agent_runtime_project_revision(root) + .map(|revision| revision.revision > revision_before) + .unwrap_or(true); let audit = append_agent_runtime_process_poll_audit(root, "command.start", pending, &result); + if let Err(error) = &audit { + let _ = mark_process_session_start_audit_failure_at(root, &result.process_id, error); + result.status = "needs-reconciliation".to_string(); + result.stdin_open = false; + result.needs_reconciliation = true; + result.launch_failure_kind = Some("start-audit-failed".to_string()); + } let needs_reconciliation = result.needs_reconciliation || audit.is_err(); AgentRuntimeToolObservation { tool: "command.start".to_string(), - status: process_session_observation_status(needs_reconciliation), + status: if needs_reconciliation { + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + } else if result.status == "failed" { + "failed" + } else { + "ok" + } + .to_string(), summary: if audit.is_err() { "command.start 已返回,但安全审计无法完整落盘".to_string() } else { format!("进程会话 {} 状态为 {}", result.process_id, result.status) }, - detail: Some(agent_runtime_process_poll_detail(&result, false, true)), + detail: Some(agent_runtime_process_poll_detail( + &result, + false, + revision_advanced, + )), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs index e923f1f73..11725bddf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs @@ -135,17 +135,42 @@ pub(crate) fn stage_command_sandbox_launch( target_executable: &Path, target_arguments: &[OsString], ) -> Result { - linux::stage_linux_command_sandbox_launch(launch, target_executable, target_arguments) + let metadata = launch.metadata.clone(); + let gate = LaunchGate::new_for_sandbox_stdin(target_executable, target_arguments) + .map_err(|error| CommandSandboxError::new(error, metadata))?; + linux::stage_linux_command_sandbox_launch( + launch, + gate, + linux::StagedSandboxTrampoline::SandboxStdin, + ) +} + +#[cfg(target_os = "linux")] +pub(crate) fn stage_command_sandbox_launch_for_process_session( + launch: CommandSandboxLaunch, + target_executable: &Path, + target_arguments: &[OsString], +) -> Result { + let metadata = launch.metadata.clone(); + let gate = LaunchGate::new_for_sandbox_stdin(target_executable, target_arguments) + .map_err(|error| CommandSandboxError::new(error, metadata))?; + linux::stage_linux_command_sandbox_launch( + launch, + gate, + linux::StagedSandboxTrampoline::ProcessSession, + ) } #[cfg(target_os = "linux")] mod linux { use super::*; - #[cfg(test)] - use crate::command_sandbox_trampoline::sandbox_trampoline_test_environment; use crate::command_sandbox_trampoline::{ - sandbox_trampoline_arguments, LaunchGate, BWRAP_BLOCK_FD, BWRAP_STATUS_FD, TRAMPOLINE_PATH, - TRAMPOLINE_SOURCE_FD, + process_session_trampoline_arguments, sandbox_trampoline_arguments, LaunchGate, + BWRAP_BLOCK_FD, BWRAP_STATUS_FD, TRAMPOLINE_PATH, TRAMPOLINE_SOURCE_FD, + }; + #[cfg(test)] + use crate::command_sandbox_trampoline::{ + process_session_trampoline_test_environment, sandbox_trampoline_test_environment, }; use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -192,10 +217,16 @@ mod linux { fixed_system_read_only: Vec, } + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub(super) enum StagedSandboxTrampoline { + SandboxStdin, + ProcessSession, + } + pub(super) fn stage_linux_command_sandbox_launch( mut launch: CommandSandboxLaunch, - target_executable: &Path, - target_arguments: &[OsString], + gate: LaunchGate, + trampoline: StagedSandboxTrampoline, ) -> Result { let metadata = launch.metadata.clone(); let separator = launch @@ -242,7 +273,12 @@ mod linux { ); #[cfg(test)] { - let (name, value) = sandbox_trampoline_test_environment(); + let (name, value) = match trampoline { + StagedSandboxTrampoline::SandboxStdin => sandbox_trampoline_test_environment(), + StagedSandboxTrampoline::ProcessSession => { + process_session_trampoline_test_environment() + } + }; push_option( &mut launch.arguments, "--setenv", @@ -251,10 +287,11 @@ mod linux { } launch.arguments.push(OsString::from("--")); launch.arguments.push(OsString::from(TRAMPOLINE_PATH)); - launch.arguments.extend(sandbox_trampoline_arguments()); + launch.arguments.extend(match trampoline { + StagedSandboxTrampoline::SandboxStdin => sandbox_trampoline_arguments(), + StagedSandboxTrampoline::ProcessSession => process_session_trampoline_arguments(), + }); - let gate = LaunchGate::new_for_sandbox_stdin(target_executable, target_arguments) - .map_err(|error| CommandSandboxError::new(error, metadata))?; Ok(StagedCommandSandboxLaunch { launch, gate }) } @@ -1254,10 +1291,13 @@ print("SANDBOX_OK") ) .expect("prepare real Linux sandbox"); let target_arguments = arguments.iter().map(OsString::from).collect::>(); + let gate = + LaunchGate::new_for_sandbox_stdin(Path::new("/usr/bin/python3"), &target_arguments) + .expect("create real Linux sandbox gate"); let staged = stage_linux_command_sandbox_launch( launch, - Path::new("/usr/bin/python3"), - &target_arguments, + gate, + StagedSandboxTrampoline::SandboxStdin, ) .expect("stage real Linux sandbox"); assert!(!staged @@ -1315,6 +1355,65 @@ print("SANDBOX_OK") "COMMITTED" ); } + + #[test] + fn staged_gate_variants_share_bwrap_control_mounts() { + let launch = CommandSandboxLaunch { + executable: PathBuf::from("/usr/bin/bwrap"), + arguments: vec![ + OsString::from("--unshare-all"), + OsString::from("--"), + OsString::from("/usr/bin/true"), + ], + cwd: PathBuf::from("/workspace/project"), + environment: Vec::new(), + metadata: CommandSandboxMetadata::enforced_linux(), + }; + let target_arguments = [OsString::from("--version")]; + + let stdin = stage_command_sandbox_launch( + launch.clone(), + Path::new("/usr/bin/true"), + &target_arguments, + ) + .expect("stage stdin sandbox launch"); + let process_session = stage_command_sandbox_launch_for_process_session( + launch, + Path::new("/usr/bin/true"), + &target_arguments, + ) + .expect("stage process-session sandbox launch"); + + for staged in [&stdin, &process_session] { + assert_eq!(staged.launch.executable, Path::new("/usr/bin/bwrap")); + assert_eq!(staged.launch.cwd, Path::new("/workspace/project")); + assert_eq!(staged.launch.metadata, stdin.launch.metadata); + assert!(has_sequence( + &staged.launch.arguments, + &["--json-status-fd", "4", "--block-fd", "5"] + )); + assert!(has_sequence( + &staged.launch.arguments, + &["--ro-bind-fd", "6", TRAMPOLINE_PATH] + )); + } + assert!(has_sequence( + &stdin.launch.arguments, + &[ + "--setenv", + "GENARRATIVE_COMMAND_SANDBOX_TRAMPOLINE_FIXTURE", + "stdin" + ] + )); + assert!(has_sequence( + &process_session.launch.arguments, + &[ + "--setenv", + "GENARRATIVE_COMMAND_SANDBOX_TRAMPOLINE_FIXTURE", + "process-session" + ] + )); + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox_trampoline.rs b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox_trampoline.rs index ac9e14bbd..83f16c420 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox_trampoline.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox_trampoline.rs @@ -11,9 +11,11 @@ mod linux { use std::os::unix::process::{CommandExt, ExitStatusExt}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; - use std::time::Duration; + use std::time::{Duration, Instant}; pub(crate) const TRAMPOLINE_MODE_ARG: &str = "--command-sandbox-trampoline"; + pub(crate) const PROCESS_SESSION_TRAMPOLINE_MODE_ARG: &str = + "--command-sandbox-process-session-trampoline"; pub(crate) const TRAMPOLINE_PATH: &str = "/run/genarrative-launch/trampoline"; pub(crate) const CONTROL_FD: RawFd = 3; pub(crate) const BWRAP_STATUS_FD: RawFd = 4; @@ -22,6 +24,7 @@ mod linux { const SANDBOX_CONTROL_FD: RawFd = libc::STDIN_FILENO; const CONTROL_FRAME_LIMIT: usize = 64 * 1024; const NONCE_BYTES: usize = 32; + const PROCESS_SESSION_TARGET_TERMINATE_GRACE_MS: u64 = 800; #[cfg(test)] const TEST_FIXTURE_ENV: &str = "GENARRATIVE_COMMAND_SANDBOX_TRAMPOLINE_FIXTURE"; @@ -46,6 +49,9 @@ mod linux { nonce: Vec, errno: i32, }, + TerminateTarget { + nonce: Vec, + }, TargetExited { nonce: Vec, code: i32, @@ -85,6 +91,12 @@ mod linux { Fail, } + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum TargetStdinMode { + Null, + DuplicateStdout, + } + impl GatePhase { fn apply(&mut self, event: GateEvent) -> Result<(), String> { let next = match (*self, event) { @@ -158,6 +170,14 @@ mod linux { pub(crate) fn new_for_sandbox_stdin( executable: &Path, arguments: &[OsString], + ) -> Result { + Self::new_for_staged_sandbox(executable, arguments, SANDBOX_CONTROL_FD) + } + + fn new_for_staged_sandbox( + executable: &Path, + arguments: &[OsString], + child_control_fd: RawFd, ) -> Result { let trampoline_source = File::open("/proc/self/exe") .map_err(|error| format!("打开 command sandbox trampoline source 失败:{error}"))?; @@ -165,7 +185,7 @@ mod linux { .map_err(|error| format!("创建 bwrap status 通道失败:{error}"))?; let (bwrap_block, bwrap_block_child) = UnixStream::pair() .map_err(|error| format!("创建 bwrap block 通道失败:{error}"))?; - let mut gate = Self::new_with_control_fd(executable, arguments, SANDBOX_CONTROL_FD)?; + let mut gate = Self::new_with_control_fd(executable, arguments, child_control_fd)?; gate.trampoline_source = Some(trampoline_source); gate.bwrap_status = Some(bwrap_status); gate.bwrap_status_child = Some(bwrap_status_child); @@ -355,6 +375,23 @@ mod linux { Ok(terminal) } + pub(crate) fn terminate_target(&mut self) -> Result<(), String> { + if self.phase != GatePhase::ExecEstablished { + return Err("command sandbox target 尚未 exec-established".to_string()); + } + write_frame( + &mut self.control, + &ControlFrame::TerminateTarget { + nonce: self.nonce.clone(), + }, + ) + .map_err(|error| { + self.fail(format!( + "发送 command sandbox target terminate 失败:{error}" + )) + }) + } + pub(crate) fn abort(&mut self, child: &mut Child) -> Result<(), String> { child .kill() @@ -376,6 +413,10 @@ mod linux { arguments == [TRAMPOLINE_MODE_ARG] } + pub(crate) fn is_process_session_trampoline_mode(arguments: &[String]) -> bool { + arguments == [PROCESS_SESSION_TRAMPOLINE_MODE_ARG] + } + pub(crate) fn sandbox_trampoline_arguments() -> Vec { #[cfg(not(test))] { @@ -394,16 +435,46 @@ mod linux { } } + pub(crate) fn process_session_trampoline_arguments() -> Vec { + #[cfg(not(test))] + { + vec![OsString::from(PROCESS_SESSION_TRAMPOLINE_MODE_ARG)] + } + #[cfg(test)] + { + vec![ + OsString::from("--exact"), + OsString::from( + "command_sandbox_trampoline::linux::tests::trampoline_child_fixture", + ), + OsString::from("--ignored"), + OsString::from("--nocapture"), + ] + } + } + #[cfg(test)] pub(crate) fn sandbox_trampoline_test_environment() -> (&'static str, &'static str) { (TEST_FIXTURE_ENV, "stdin") } - pub(crate) fn run_trampoline() -> Result { - run_trampoline_from_fd(SANDBOX_CONTROL_FD) + #[cfg(test)] + pub(crate) fn process_session_trampoline_test_environment() -> (&'static str, &'static str) { + (TEST_FIXTURE_ENV, "process-session") } - fn run_trampoline_from_fd(control_fd: RawFd) -> Result { + pub(crate) fn run_trampoline() -> Result { + run_trampoline_from_fd(SANDBOX_CONTROL_FD, TargetStdinMode::Null) + } + + pub(crate) fn run_process_session_trampoline() -> Result { + run_trampoline_from_fd(SANDBOX_CONTROL_FD, TargetStdinMode::DuplicateStdout) + } + + fn run_trampoline_from_fd( + control_fd: RawFd, + target_stdin_mode: TargetStdinMode, + ) -> Result { let mut control = unsafe { UnixStream::from_raw_fd(control_fd) }; set_close_on_exec(control.as_raw_fd()) .map_err(|error| format!("保护 command sandbox 控制 fd 失败:{error}"))?; @@ -441,11 +512,44 @@ mod linux { .map(OsString::from_vec) .collect::>(); - let mut target = match Command::new(&executable) - .args(&arguments) - .stdin(Stdio::null()) - .spawn() - { + let target_stdin = match target_stdin_mode { + TargetStdinMode::Null => Stdio::null(), + TargetStdinMode::DuplicateStdout => Stdio::from( + duplicate_pty_stdin_from_stdout() + .map_err(|error| format!("复制 process-session PTY stdin 失败:{error}"))?, + ), + }; + let mut target_command = Command::new(&executable); + target_command.args(&arguments).stdin(target_stdin); + if target_stdin_mode == TargetStdinMode::DuplicateStdout { + unsafe { + target_command.pre_exec(|| { + let mut blocked = std::mem::zeroed::(); + let mut previous = std::mem::zeroed::(); + if libc::sigemptyset(&mut blocked) != 0 + || libc::sigaddset(&mut blocked, libc::SIGTTOU) != 0 + || libc::sigprocmask(libc::SIG_BLOCK, &blocked, &mut previous) != 0 + { + return Err(io::Error::last_os_error()); + } + if libc::setpgid(0, 0) != 0 { + let error = io::Error::last_os_error(); + libc::sigprocmask(libc::SIG_SETMASK, &previous, std::ptr::null_mut()); + return Err(error); + } + if libc::tcsetpgrp(libc::STDOUT_FILENO, libc::getpid()) != 0 { + let error = io::Error::last_os_error(); + libc::sigprocmask(libc::SIG_SETMASK, &previous, std::ptr::null_mut()); + return Err(error); + } + if libc::sigprocmask(libc::SIG_SETMASK, &previous, std::ptr::null_mut()) != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + } + let mut target = match target_command.spawn() { Ok(target) => target, Err(error) => { write_frame( @@ -469,9 +573,61 @@ mod linux { ) .map_err(|error| format!("发送 command sandbox exec-established 失败:{error}"))?; - let status = target - .wait() - .map_err(|error| format!("等待 command sandbox target 失败:{error}"))?; + let status = if target_stdin_mode == TargetStdinMode::DuplicateStdout { + let process_group = i32::try_from(target.id()) + .map_err(|_| "command sandbox target 进程组身份无效".to_string())?; + let mut target_status = None; + let mut graceful_deadline = None; + loop { + if target_status.is_none() { + target_status = target + .try_wait() + .map_err(|error| format!("检查 command sandbox target 失败:{error}"))?; + } + if let Some(status) = target_status { + let group_alive = unsafe { libc::kill(-process_group, 0) } == 0 + || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM); + if !group_alive { + break status; + } + let deadline = *graceful_deadline.get_or_insert_with(|| { + Instant::now() + + Duration::from_millis(PROCESS_SESSION_TARGET_TERMINATE_GRACE_MS) + }); + if Instant::now() >= deadline { + break status; + } + } + let Some(frame) = read_frame_if_available(&mut control, Duration::from_millis(25))? + else { + continue; + }; + match frame { + ControlFrame::TerminateTarget { + nonce: terminate_nonce, + } if terminate_nonce == nonce => { + let result = unsafe { libc::kill(-process_group, libc::SIGTERM) }; + if result != 0 + && io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) + { + return Err(format!( + "发送 command sandbox target SIGTERM 失败:{}", + io::Error::last_os_error() + )); + } + graceful_deadline = Some( + Instant::now() + + Duration::from_millis(PROCESS_SESSION_TARGET_TERMINATE_GRACE_MS), + ); + } + _ => return Err("command sandbox target control 帧无效".to_string()), + } + } + } else { + target + .wait() + .map_err(|error| format!("等待 command sandbox target 失败:{error}"))? + }; if let Some(code) = status.code() { write_frame(&mut control, &ControlFrame::TargetExited { nonce, code }) .map_err(|error| format!("发送 command sandbox target exit 失败:{error}"))?; @@ -495,6 +651,16 @@ mod linux { Ok(unsafe { OwnedFd::from_raw_fd(duplicated) }) } + fn duplicate_pty_stdin_from_stdout() -> io::Result { + if unsafe { libc::isatty(libc::STDOUT_FILENO) } != 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "process-session stdout 不是 PTY", + )); + } + duplicate_fd_high(libc::STDOUT_FILENO) + } + fn install_fixed_fds(mappings: [(Option, RawFd); 4]) -> io::Result<()> { for (source, target) in mappings { let Some(source) = source else { @@ -599,6 +765,34 @@ mod linux { .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) } + fn read_frame_if_available( + stream: &mut UnixStream, + timeout: Duration, + ) -> Result, String> { + let timeout_ms = i32::try_from(timeout.as_millis()).unwrap_or(i32::MAX); + let mut descriptor = libc::pollfd { + fd: stream.as_raw_fd(), + events: libc::POLLIN | libc::POLLHUP, + revents: 0, + }; + let result = unsafe { libc::poll(&mut descriptor, 1, timeout_ms) }; + if result < 0 { + return Err(format!( + "等待 command sandbox target control 失败:{}", + io::Error::last_os_error() + )); + } + if result == 0 { + return Ok(None); + } + if descriptor.revents & (libc::POLLERR | libc::POLLNVAL) != 0 { + return Err("command sandbox target control 通道失效".to_string()); + } + read_frame(stream) + .map(Some) + .map_err(|error| format!("读取 command sandbox target control 失败:{error}")) + } + fn read_frame_with_timeout( stream: &mut UnixStream, timeout: Duration, @@ -628,12 +822,12 @@ mod linux { let Some(mode) = std::env::var_os(TEST_FIXTURE_ENV) else { return; }; - let control_fd = if mode == "stdin" { - SANDBOX_CONTROL_FD - } else { - CONTROL_FD + let (control_fd, target_stdin_mode) = match mode.to_str() { + Some("stdin") => (SANDBOX_CONTROL_FD, TargetStdinMode::Null), + Some("process-session") => (SANDBOX_CONTROL_FD, TargetStdinMode::DuplicateStdout), + _ => (CONTROL_FD, TargetStdinMode::Null), }; - match run_trampoline_from_fd(control_fd) { + match run_trampoline_from_fd(control_fd, target_stdin_mode) { Ok(exit_code) => std::process::exit(exit_code), Err(_) => std::process::exit(125), } @@ -713,6 +907,138 @@ mod linux { ); } + #[test] + fn stdin_modes_keep_one_shot_null_and_process_session_pty_input() { + let mut one_shot_gate = LaunchGate::new_with_control_fd( + Path::new("/bin/sh"), + &[ + OsString::from("-c"), + OsString::from("test \"$(readlink /proc/self/fd/0)\" = /dev/null"), + ], + SANDBOX_CONTROL_FD, + ) + .expect("create one-shot gate"); + let mut one_shot_command = + Command::new(std::env::current_exe().expect("current test binary")); + one_shot_command + .arg("--exact") + .arg(FIXTURE_TEST) + .arg("--ignored") + .arg("--nocapture") + .env(TEST_FIXTURE_ENV, "stdin") + .stdin(Stdio::null()); + one_shot_gate + .install_on_command(&mut one_shot_command) + .expect("install one-shot gate"); + let mut one_shot_child = one_shot_command.spawn().expect("spawn one-shot fixture"); + one_shot_gate + .child_created() + .expect("mark one-shot child-created"); + one_shot_gate + .wait_sandbox_ready(Duration::from_secs(2)) + .expect("one-shot sandbox ready"); + one_shot_gate.commit_exec().expect("commit one-shot exec"); + assert_eq!( + one_shot_gate + .wait_target_exec(Duration::from_secs(2)) + .expect("one-shot target exec"), + TargetExecState::Established + ); + assert_eq!( + one_shot_gate + .wait_terminal(Duration::from_secs(2)) + .expect("one-shot target terminal"), + TargetTerminalState::Exited { code: 0 } + ); + assert!(wait_child(&mut one_shot_child, Duration::from_secs(2)).success()); + + let mut process_session_gate = LaunchGate::new_with_control_fd( + Path::new("/bin/sh"), + &[ + OsString::from("-c"), + OsString::from( + "for fd in 3 4 5 6; do test ! -e /proc/self/fd/$fd || exit $((80 + fd)); done; IFS= read -r value; test \"$value\" = PTY_INPUT", + ), + ], + SANDBOX_CONTROL_FD, + ) + .expect("create process-session gate"); + let mut pty_master = 0; + let mut pty_slave = 0; + let open_result = unsafe { + libc::openpty( + &mut pty_master, + &mut pty_slave, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), + ) + }; + assert_eq!(open_result, 0, "open process-session test PTY"); + let mut pty_master = unsafe { File::from_raw_fd(pty_master) }; + let pty_slave = unsafe { OwnedFd::from_raw_fd(pty_slave) }; + set_close_on_exec(pty_master.as_raw_fd()).expect("protect PTY master"); + set_close_on_exec(pty_slave.as_raw_fd()).expect("protect PTY slave"); + let mut process_session_command = + Command::new(std::env::current_exe().expect("current test binary")); + process_session_command + .arg("--exact") + .arg(FIXTURE_TEST) + .arg("--ignored") + .arg("--nocapture") + .env(TEST_FIXTURE_ENV, "process-session") + .stdin(Stdio::null()) + .stdout(Stdio::from( + duplicate_fd_high(pty_slave.as_raw_fd()).expect("duplicate PTY stdout"), + )) + .stderr(Stdio::from( + duplicate_fd_high(pty_slave.as_raw_fd()).expect("duplicate PTY stderr"), + )); + unsafe { + process_session_command.pre_exec(|| { + if libc::setsid() < 0 { + return Err(io::Error::last_os_error()); + } + if libc::ioctl(libc::STDOUT_FILENO, libc::TIOCSCTTY, 0) < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + drop(pty_slave); + process_session_gate + .install_on_command(&mut process_session_command) + .expect("install process-session gate"); + let mut process_session_child = process_session_command + .spawn() + .expect("spawn process-session fixture"); + process_session_gate + .child_created() + .expect("mark process-session child-created"); + process_session_gate + .wait_sandbox_ready(Duration::from_secs(2)) + .expect("process-session sandbox ready"); + process_session_gate + .commit_exec() + .expect("commit process-session exec"); + assert_eq!( + process_session_gate + .wait_target_exec(Duration::from_secs(2)) + .expect("process-session target exec"), + TargetExecState::Established + ); + pty_master + .write_all(b"PTY_INPUT\n") + .expect("write process-session PTY input"); + assert_eq!( + process_session_gate + .wait_terminal(Duration::from_secs(2)) + .expect("process-session target terminal"), + TargetTerminalState::Exited { code: 0 } + ); + assert!(wait_child(&mut process_session_child, Duration::from_secs(2)).success()); + } + #[test] fn abort_before_commit_reaps_child_without_running_target() { let directory = tempfile::tempdir().expect("temp directory"); @@ -806,13 +1132,55 @@ mod linux { phase.apply(GateEvent::SandboxReady).expect("ready"); assert!(phase.apply(GateEvent::ExecEstablished).is_err()); } + + #[test] + fn staged_sandbox_gate_variants_construct_fd0_control_resources() { + let stdin_gate = LaunchGate::new_for_sandbox_stdin(Path::new("/usr/bin/true"), &[]) + .expect("create stdin sandbox gate"); + let process_session_gate = + crate::command_sandbox::stage_command_sandbox_launch_for_process_session( + crate::command_sandbox::CommandSandboxLaunch { + executable: PathBuf::from("/usr/bin/bwrap"), + arguments: vec![OsString::from("--"), OsString::from("/usr/bin/true")], + cwd: PathBuf::from("/workspace/project"), + environment: Vec::new(), + metadata: crate::command_sandbox::command_sandbox_platform_metadata(), + }, + Path::new("/usr/bin/true"), + &[], + ) + .expect("stage process-session sandbox gate") + .gate; + + for gate in [stdin_gate, process_session_gate] { + assert_eq!(gate.child_control_fd, SANDBOX_CONTROL_FD); + assert_eq!(gate.phase, GatePhase::Prepared); + assert!(gate.child_control.is_some()); + assert!(gate.bwrap_status.is_some()); + assert!(gate.bwrap_status_child.is_some()); + assert!(gate.bwrap_block.is_some()); + assert!(gate.bwrap_block_child.is_some()); + assert!(gate.trampoline_source.is_some()); + } + + assert!(is_process_session_trampoline_mode(&[ + PROCESS_SESSION_TRAMPOLINE_MODE_ARG.to_string() + ])); + assert!(!is_trampoline_mode(&[ + PROCESS_SESSION_TRAMPOLINE_MODE_ARG.to_string() + ])); + } } } -#[cfg(all(test, target_os = "linux"))] -pub(crate) use linux::sandbox_trampoline_test_environment; #[cfg(target_os = "linux")] pub(crate) use linux::{ - is_trampoline_mode, run_trampoline, sandbox_trampoline_arguments, LaunchGate, TargetExecState, - TargetTerminalState, BWRAP_BLOCK_FD, BWRAP_STATUS_FD, TRAMPOLINE_PATH, TRAMPOLINE_SOURCE_FD, + is_process_session_trampoline_mode, is_trampoline_mode, process_session_trampoline_arguments, + run_process_session_trampoline, run_trampoline, sandbox_trampoline_arguments, LaunchGate, + TargetExecState, TargetTerminalState, BWRAP_BLOCK_FD, BWRAP_STATUS_FD, TRAMPOLINE_PATH, + TRAMPOLINE_SOURCE_FD, +}; +#[cfg(all(test, target_os = "linux"))] +pub(crate) use linux::{ + process_session_trampoline_test_environment, sandbox_trampoline_test_environment, }; 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 888aa4352..676c006f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -58,6 +58,7 @@ mod isolated_agent; mod patchset; mod preview; mod process_session; +mod process_session_bridge; mod project; mod repository_context; mod runner; @@ -1263,13 +1264,17 @@ fn main() { } } #[cfg(target_os = "linux")] + if command_sandbox_trampoline::is_process_session_trampoline_mode(&args) { + match command_sandbox_trampoline::run_process_session_trampoline() { + Ok(exit_code) => std::process::exit(exit_code), + Err(_) => std::process::exit(125), + } + } + #[cfg(target_os = "linux")] if is_process_session_child_mode(&args) { match run_process_session_child(&args) { Ok(exit_code) => std::process::exit(exit_code), - Err(error) => { - eprintln!("process.session.child.failed: {error}"); - std::process::exit(1); - } + Err(_) => std::process::exit(125), } } let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session.rs index 6c51b8266..527a28b51 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session.rs @@ -4,7 +4,11 @@ use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Condvar; -const PROCESS_SESSION_SCHEMA_VERSION: &str = "2"; +#[cfg(target_os = "linux")] +use crate::process_session_bridge::*; + +const PROCESS_SESSION_SCHEMA_VERSION: &str = "3"; +const PROCESS_SESSION_TRANSCRIPT_SCHEMA_VERSION: &str = "2"; const PROCESS_SESSION_CURSOR_VERSION: &str = "v1"; const PROCESS_SESSION_MAX_PER_PROJECT: usize = 4; const PROCESS_SESSION_MAX_PER_AGENT: usize = 2; @@ -21,8 +25,6 @@ const PROCESS_SESSION_TERMINATE_GRACE_MS: u64 = 800; const PROCESS_SESSION_OWNER_PID_ENV: &str = "GENARRATIVE_PROCESS_SESSION_OWNER_PID"; #[cfg(target_os = "linux")] const PROCESS_SESSION_CHILD_MODE: &str = "--process-session-child"; -#[cfg(all(target_os = "linux", test))] -const PROCESS_SESSION_TEST_CHILD_ARGV_ENV: &str = "GENARRATIVE_PROCESS_SESSION_TEST_CHILD_ARGV"; #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ProcessSessionIdentity { @@ -59,6 +61,16 @@ pub(crate) struct ProcessSessionRecord { pub(crate) network_access: String, #[serde(default)] pub(crate) sandbox_profile_version: String, + #[serde(default)] + pub(crate) sandbox_establishment: String, + #[serde(default)] + pub(crate) target_exec: String, + #[serde(default)] + pub(crate) launch_failure_kind: Option, + #[serde(default)] + pub(crate) sandbox_ready_at: Option, + #[serde(default)] + pub(crate) exec_established_at: Option, pub(crate) status: String, pub(crate) exit_code: Option, pub(crate) signal: Option, @@ -113,6 +125,9 @@ pub(crate) struct ProcessSessionPollResult { pub(crate) sandbox_mode: String, pub(crate) network_access: String, pub(crate) sandbox_profile_version: String, + pub(crate) sandbox_establishment: String, + pub(crate) target_exec: String, + pub(crate) launch_failure_kind: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -141,6 +156,7 @@ struct ProcessOutputState { source_fingerprint_after: Option, source_changed: Option, needs_reconciliation: bool, + launch_failure_kind: Option, } impl ProcessOutputState { @@ -156,6 +172,7 @@ impl ProcessOutputState { source_fingerprint_after: None, source_changed: None, needs_reconciliation: false, + launch_failure_kind: None, } } } @@ -178,6 +195,10 @@ struct LiveProcessSession { sandbox_mode: String, network_access: String, sandbox_profile_version: String, + sandbox_establishment: String, + target_exec: String, + sandbox_ready_at: Option, + exec_established_at: Option, source_fingerprint_before: String, started_at: u64, output: Mutex, @@ -280,13 +301,102 @@ struct ProcessSessionRegistry { sessions: HashMap>, } +#[cfg(target_os = "linux")] +#[derive(Clone, Debug)] +struct PendingProcessLaunch { + root: PathBuf, + agent_id: String, + process_group_leader: Option, + shutdown_requested: bool, +} + +#[cfg(target_os = "linux")] +#[derive(Default)] +struct PendingProcessLaunchRegistry { + launches: HashMap, +} + +#[cfg(target_os = "linux")] +struct PendingProcessLaunchGuard { + process_id: String, +} + +#[cfg(target_os = "linux")] +impl Drop for PendingProcessLaunchGuard { + fn drop(&mut self) { + if let Ok(mut registry) = pending_process_launch_registry().lock() { + registry.launches.remove(&self.process_id); + } + } +} + static PROCESS_SESSION_REGISTRY: OnceLock> = OnceLock::new(); static PROCESS_SESSION_BOOT_ID: OnceLock = OnceLock::new(); +#[cfg(target_os = "linux")] +static PENDING_PROCESS_LAUNCH_REGISTRY: OnceLock> = + OnceLock::new(); fn process_session_registry() -> &'static Mutex { PROCESS_SESSION_REGISTRY.get_or_init(|| Mutex::new(ProcessSessionRegistry::default())) } +#[cfg(target_os = "linux")] +fn pending_process_launch_registry() -> &'static Mutex { + PENDING_PROCESS_LAUNCH_REGISTRY + .get_or_init(|| Mutex::new(PendingProcessLaunchRegistry::default())) +} + +#[cfg(target_os = "linux")] +fn reserve_pending_process_launch( + root: &Path, + agent_id: &str, + process_id: &str, +) -> Result { + let mut registry = pending_process_launch_registry() + .lock() + .map_err(|_| "pending process launch registry 锁已损坏".to_string())?; + if registry.launches.contains_key(process_id) { + return Err("command.start pending launch 身份冲突".to_string()); + } + registry.launches.insert( + process_id.to_string(), + PendingProcessLaunch { + root: root.to_path_buf(), + agent_id: agent_id.to_string(), + process_group_leader: None, + shutdown_requested: false, + }, + ); + Ok(PendingProcessLaunchGuard { + process_id: process_id.to_string(), + }) +} + +#[cfg(target_os = "linux")] +fn activate_pending_process_launch( + process_id: &str, + process_group_leader: i32, +) -> Result<(), String> { + if process_group_leader <= 1 { + return Err("command.start wrapper 进程组身份无效".to_string()); + } + let mut registry = pending_process_launch_registry() + .lock() + .map_err(|_| "pending process launch registry 锁已损坏".to_string())?; + let launch = registry + .launches + .get_mut(process_id) + .ok_or_else(|| "command.start pending launch reservation 缺失".to_string())?; + launch.process_group_leader = Some(process_group_leader); + if launch.shutdown_requested { + unsafe { + libc::kill(-process_group_leader, libc::SIGKILL); + } + return Err("Runner shutdown 已取消 pending process launch".to_string()); + } + Ok(()) +} + pub(crate) fn process_session_boot_id() -> &'static str { PROCESS_SESSION_BOOT_ID .get_or_init(|| { @@ -328,8 +438,8 @@ pub(crate) fn is_process_session_child_mode(args: &[String]) -> bool { #[cfg(target_os = "linux")] pub(crate) fn run_process_session_child(args: &[String]) -> Result { - if args.len() < 2 { - return Err("process session child 缺少 executable".to_string()); + if args != [PROCESS_SESSION_CHILD_MODE] { + return Err("process session child 参数无效".to_string()); } let expected_parent = std::env::var(PROCESS_SESSION_OWNER_PID_ENV) .map_err(|_| "process session child 缺少 owner pid".to_string())? @@ -345,28 +455,7 @@ pub(crate) fn run_process_session_child(args: &[String]) -> Result libc::signal(libc::SIGHUP, libc::SIG_IGN); } std::env::remove_var(PROCESS_SESSION_OWNER_PID_ENV); - let mut child = std::process::Command::new(&args[1]) - .args(&args[2..]) - .spawn() - .map_err(|error| format!("process session child spawn 失败:{error}"))?; - loop { - if unsafe { libc::getppid() } != expected_parent { - unsafe { - libc::kill(0, libc::SIGKILL); - } - return Err("process session owner 已退出但进程组终止失败".to_string()); - } - match child.try_wait() { - Ok(Some(status)) => return Ok(status.code().unwrap_or(128)), - Ok(None) => thread::sleep(Duration::from_millis(25)), - Err(error) => { - unsafe { - libc::kill(0, libc::SIGKILL); - } - return Err(format!("process session child wait 失败:{error}")); - } - } - } + run_process_session_bridge_child(expected_parent) } fn validate_process_session_identity(identity: &ProcessSessionIdentity) -> Result<(), String> { @@ -488,20 +577,51 @@ fn read_process_session_record( PROCESS_SESSION_RECORD_MAX_BYTES, )?; if let Some(record) = record.as_mut() { - normalize_process_session_record(record); + let normalized = normalize_process_session_record(record); validate_process_session_record(root, record, process_id)?; + if normalized { + write_process_session_record(root, record)?; + } } Ok(record) } -fn normalize_process_session_record(record: &mut ProcessSessionRecord) { +fn normalize_process_session_record(record: &mut ProcessSessionRecord) -> bool { + let legacy_schema = matches!(record.schema_version.as_str(), "1" | "2"); + if !legacy_schema { + return false; + } if record.schema_version == "1" { - record.schema_version = PROCESS_SESSION_SCHEMA_VERSION.to_string(); record.sandbox_backend = "legacy-unknown".to_string(); record.sandbox_mode = "unknown".to_string(); record.network_access = "unknown".to_string(); record.sandbox_profile_version = "legacy-v1".to_string(); } + let legacy_active = matches!( + record.status.as_str(), + "prepared" | "launching" | "running" | "terminating" + ); + record.schema_version = PROCESS_SESSION_SCHEMA_VERSION.to_string(); + record.sandbox_establishment = "unknown".to_string(); + record.target_exec = "unknown".to_string(); + record.launch_failure_kind = Some( + if legacy_active { + "legacy-active-record" + } else { + "legacy-record" + } + .to_string(), + ); + record.sandbox_ready_at = None; + record.exec_established_at = None; + if legacy_active { + record.status = "needs-reconciliation".to_string(); + record.stdin_open = false; + record.needs_reconciliation = true; + record.terminal_at = Some(unix_timestamp()); + record.updated_at = unix_timestamp(); + } + legacy_active } fn validate_process_session_record( @@ -516,6 +636,26 @@ fn validate_process_session_record( || record.sandbox_mode.is_empty() || record.network_access.is_empty() || record.sandbox_profile_version.is_empty() + || !matches!( + record.sandbox_establishment.as_str(), + "not-established" | "established" | "unknown" + ) + || !matches!( + record.target_exec.as_str(), + "not-attempted" | "established" | "failed" | "unknown" + ) + || record.launch_failure_kind.as_deref().is_some_and(|value| { + !matches!( + value, + "pre-exec-failed" + | "durable-commit-failed" + | "target-exec-failed" + | "launch-unknown" + | "legacy-active-record" + | "legacy-record" + | "start-audit-failed" + ) + }) { return Err("Agent Runtime process session 身份不匹配".to_string()); } @@ -549,9 +689,164 @@ fn validate_process_session_record( { return Err("终态 process session 缺少 terminalAt".to_string()); } + let launch_state_valid = match record.launch_failure_kind.as_deref() { + None => match record.status.as_str() { + "prepared" => { + record.sandbox_establishment == "not-established" + && record.target_exec == "not-attempted" + && !record.needs_reconciliation + } + "launching" => { + matches!( + record.sandbox_establishment.as_str(), + "not-established" | "established" + ) && record.target_exec == "not-attempted" + && !record.needs_reconciliation + } + "running" | "terminating" => { + record.sandbox_establishment == "established" + && record.target_exec == "established" + && !record.needs_reconciliation + } + "needs-reconciliation" => { + record.sandbox_establishment == "established" + && record.target_exec == "established" + && record.needs_reconciliation + } + "exited" | "terminated" | "timed-out" | "output-limit-exceeded" | "failed" => { + record.sandbox_establishment == "established" && record.target_exec == "established" + } + _ => false, + }, + Some("pre-exec-failed") => { + record.status == "failed" + && record.sandbox_establishment == "not-established" + && record.target_exec == "not-attempted" + && !record.needs_reconciliation + } + Some("durable-commit-failed") => { + record.status == "failed" + && matches!( + record.sandbox_establishment.as_str(), + "not-established" | "established" + ) + && record.target_exec == "not-attempted" + && !record.needs_reconciliation + } + Some("target-exec-failed") => { + matches!(record.status.as_str(), "failed" | "needs-reconciliation") + && record.sandbox_establishment == "established" + && record.target_exec == "failed" + && (record.status == "needs-reconciliation") == record.needs_reconciliation + } + Some("launch-unknown") => { + record.status == "needs-reconciliation" + && record.needs_reconciliation + && record.target_exec == "unknown" + && matches!( + record.sandbox_establishment.as_str(), + "established" | "unknown" + ) + } + Some("legacy-active-record") => { + record.status == "needs-reconciliation" + && record.needs_reconciliation + && record.sandbox_establishment == "unknown" + && record.target_exec == "unknown" + } + Some("legacy-record") => { + !matches!( + record.status.as_str(), + "prepared" | "launching" | "running" | "terminating" + ) && record.sandbox_establishment == "unknown" + && record.target_exec == "unknown" + && (record.status != "needs-reconciliation" || record.needs_reconciliation) + } + Some("start-audit-failed") => { + record.status == "needs-reconciliation" + && record.needs_reconciliation + && record.sandbox_establishment == "established" + && record.target_exec == "established" + } + Some(_) => false, + }; + if !launch_state_valid { + return Err("process session 可信 launch 状态组合无效".to_string()); + } + if record.started_at > record.updated_at + || record.terminal_at.is_some_and(|terminal_at| { + record.started_at > terminal_at || terminal_at > record.updated_at + }) + { + return Err("process session 生命周期时间顺序无效".to_string()); + } + match record.sandbox_establishment.as_str() { + "established" if record.sandbox_ready_at.is_none() => { + return Err("已建立的 process session sandbox 缺少 ready 时间".to_string()); + } + "not-established" | "unknown" if record.sandbox_ready_at.is_some() => { + return Err("未建立或未知的 process session sandbox 不应有 ready 时间".to_string()); + } + _ => {} + } + match record.target_exec.as_str() { + "established" if record.exec_established_at.is_none() => { + return Err("已建立的 process session target 缺少 exec 时间".to_string()); + } + "not-attempted" | "failed" | "unknown" if record.exec_established_at.is_some() => { + return Err("未建立的 process session target 不应有 exec 时间".to_string()); + } + _ => {} + } + if let Some(sandbox_ready_at) = record.sandbox_ready_at { + if record.started_at > sandbox_ready_at + || sandbox_ready_at > record.updated_at + || record + .terminal_at + .is_some_and(|terminal_at| sandbox_ready_at > terminal_at) + { + return Err("process session sandbox-ready 时间顺序无效".to_string()); + } + } + if let Some(exec_established_at) = record.exec_established_at { + if record + .sandbox_ready_at + .is_none_or(|sandbox_ready_at| sandbox_ready_at > exec_established_at) + || exec_established_at > record.updated_at + || record + .terminal_at + .is_some_and(|terminal_at| exec_established_at > terminal_at) + { + return Err("process session exec-established 时间顺序无效".to_string()); + } + } Ok(()) } +fn reconcile_stale_active_process_session(record: &mut ProcessSessionRecord) { + let previous_status = record.status.clone(); + record.status = "needs-reconciliation".to_string(); + record.stdin_open = false; + record.needs_reconciliation = true; + if previous_status == "prepared" { + record.sandbox_establishment = "unknown".to_string(); + record.sandbox_ready_at = None; + record.target_exec = "unknown".to_string(); + record.exec_established_at = None; + record.launch_failure_kind = Some("launch-unknown".to_string()); + } else if previous_status == "launching" { + if record.sandbox_establishment != "established" { + record.sandbox_establishment = "unknown".to_string(); + record.sandbox_ready_at = None; + } + record.target_exec = "unknown".to_string(); + record.exec_established_at = None; + record.launch_failure_kind = Some("launch-unknown".to_string()); + } + record.terminal_at = Some(unix_timestamp()); + record.updated_at = unix_timestamp(); +} + fn process_session_record_from_live( live: &LiveProcessSession, output: &ProcessOutputState, @@ -576,6 +871,11 @@ fn process_session_record_from_live( sandbox_mode: live.sandbox_mode.clone(), network_access: live.network_access.clone(), sandbox_profile_version: live.sandbox_profile_version.clone(), + sandbox_establishment: live.sandbox_establishment.clone(), + target_exec: live.target_exec.clone(), + launch_failure_kind: output.launch_failure_kind.clone(), + sandbox_ready_at: live.sandbox_ready_at, + exec_established_at: live.exec_established_at, status: output.status.clone(), exit_code: output.exit_code, signal: output.signal.clone(), @@ -633,6 +933,11 @@ fn initial_process_session_record( sandbox_mode, network_access, sandbox_profile_version, + sandbox_establishment: "not-established".to_string(), + target_exec: "not-attempted".to_string(), + launch_failure_kind: None, + sandbox_ready_at: None, + exec_established_at: None, status: status.to_string(), exit_code: None, signal: None, @@ -650,12 +955,15 @@ fn initial_process_session_record( } } +#[cfg(not(target_os = "linux"))] fn process_session_launch_failed( root: &Path, record: &mut ProcessSessionRecord, error: String, ) -> String { record.status = "failed".to_string(); + record.target_exec = "not-attempted".to_string(); + record.launch_failure_kind = Some("pre-exec-failed".to_string()); record.stdin_open = false; record.terminal_at = Some(unix_timestamp()); record.updated_at = unix_timestamp(); @@ -713,7 +1021,7 @@ fn validate_process_session_transcript( record: &ProcessSessionRecord, ) -> Result<(), String> { let output_sha256 = format!("{:x}", Sha256::digest(transcript.output.as_bytes())); - if transcript.schema_version != PROCESS_SESSION_SCHEMA_VERSION + if !matches!(transcript.schema_version.as_str(), "1" | "2") || transcript.project_id != record.project_id || transcript.agent_id != record.agent_id || transcript.task_id != record.task_id @@ -825,22 +1133,16 @@ pub(crate) fn validate_process_session_command_spec( fn process_session_command_builder( launch: &ProjectCommandLaunchSpec, + #[cfg(target_os = "linux")] bridge: &ProcessSessionBridgeServer, ) -> Result { #[cfg(target_os = "linux")] let mut command = { let current_executable = std::env::current_exe() .map_err(|error| format!("定位 process session child wrapper 失败:{error}"))?; - #[cfg(not(test))] - let executable = launch - .executable - .to_str() - .ok_or_else(|| "command.start executable 必须是 UTF-8 路径".to_string())?; let mut command = CommandBuilder::new(current_executable); #[cfg(not(test))] { command.arg(PROCESS_SESSION_CHILD_MODE); - command.arg(executable); - command.args(&launch.arguments); } #[cfg(test)] { @@ -861,34 +1163,19 @@ fn process_session_command_builder( }; command.cwd(&launch.cwd); command.env_clear(); + #[cfg(not(target_os = "linux"))] for (name, value) in &launch.environment { command.env(name, value); } - #[cfg(all(target_os = "linux", test))] - { - let executable = launch - .executable - .to_str() - .ok_or_else(|| "command.start executable 必须是 UTF-8 路径".to_string())?; - let argv = std::iter::once(executable.to_string()) - .chain( - launch - .arguments - .iter() - .map(|argument| argument.to_string_lossy().into_owned()), - ) - .collect::>(); - command.env( - PROCESS_SESSION_TEST_CHILD_ARGV_ENV, - serde_json::to_string(&argv) - .map_err(|error| format!("序列化 process session 测试 argv 失败:{error}"))?, - ); - } #[cfg(target_os = "linux")] - command.env( - PROCESS_SESSION_OWNER_PID_ENV, - std::process::id().to_string(), - ); + { + command.env( + PROCESS_SESSION_OWNER_PID_ENV, + std::process::id().to_string(), + ); + command.env(PROCESS_SESSION_BRIDGE_ENDPOINT_ENV, bridge.endpoint()); + command.env(PROCESS_SESSION_BRIDGE_NONCE_ENV, bridge.nonce_hex()); + } Ok(command) } @@ -906,6 +1193,15 @@ pub(crate) fn validate_process_session_start_preflight_at( return Ok(()); } let records = active_process_session_records_at(root, None, None)?; + #[cfg(target_os = "linux")] + let pending = pending_process_launch_registry() + .lock() + .map_err(|_| "pending process launch registry 锁已损坏".to_string())? + .launches + .values() + .filter(|launch| launch.root == root) + .cloned() + .collect::>(); if let Some(record) = records .iter() .find(|record| record.needs_reconciliation || record.status == "needs-reconciliation") @@ -915,7 +1211,11 @@ pub(crate) fn validate_process_session_start_preflight_at( record.process_id )); } - if records.len() >= PROCESS_SESSION_MAX_PER_PROJECT { + #[cfg(target_os = "linux")] + let pending_project_count = pending.len(); + #[cfg(not(target_os = "linux"))] + let pending_project_count = 0; + if records.len().saturating_add(pending_project_count) >= PROCESS_SESSION_MAX_PER_PROJECT { return Err(format!( "当前项目最多同时运行 {PROCESS_SESSION_MAX_PER_PROJECT} 个 process session" )); @@ -924,6 +1224,13 @@ pub(crate) fn validate_process_session_start_preflight_at( .iter() .filter(|record| record.agent_id == identity.agent_id) .count(); + #[cfg(target_os = "linux")] + let agent_count = agent_count.saturating_add( + pending + .iter() + .filter(|launch| launch.agent_id == identity.agent_id) + .count(), + ); if agent_count >= PROCESS_SESSION_MAX_PER_AGENT { return Err(format!( "当前 Agent 最多同时运行 {PROCESS_SESSION_MAX_PER_AGENT} 个 process session" @@ -940,37 +1247,103 @@ pub(crate) fn start_process_session_at( ) -> Result { let launch = prepare_project_command_launch_spec(root, spec).map_err(|error| error.to_string())?; - start_prepared_process_session_at(root, identity, spec, &launch, source_fingerprint_before) + start_prepared_process_session_at( + root, + identity, + spec, + &launch, + source_fingerprint_before, + || Ok(()), + ) + .map_err(|error| error.to_string()) } -pub(crate) fn start_prepared_process_session_at( +pub(crate) fn start_prepared_process_session_at( root: &Path, identity: ProcessSessionIdentity, spec: &ProjectCommandSpec, launch: &ProjectCommandLaunchSpec, source_fingerprint_before: String, -) -> Result { - validate_process_session_start_preflight_at(root, &identity, spec)?; - if let Some(existing) = find_existing_start_action_record(root, &identity)? { + durable_commit: F, +) -> Result +where + F: FnOnce() -> Result<(), String>, +{ + #[cfg(target_os = "linux")] + { + start_linux_process_session_at( + root, + identity, + spec, + launch, + source_fingerprint_before, + durable_commit, + ) + } + #[cfg(not(target_os = "linux"))] + { + start_legacy_process_session_at( + root, + identity, + spec, + launch, + source_fingerprint_before, + durable_commit, + ) + } +} + +#[cfg(target_os = "linux")] +fn start_linux_process_session_at( + root: &Path, + identity: ProcessSessionIdentity, + spec: &ProjectCommandSpec, + launch: &ProjectCommandLaunchSpec, + source_fingerprint_before: String, + durable_commit: F, +) -> Result +where + F: FnOnce() -> Result<(), String>, +{ + validate_process_session_start_preflight_at(root, &identity, spec) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; + if let Some(mut existing) = find_existing_start_action_record(root, &identity) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))? + { if matches!( existing.status.as_str(), "prepared" | "launching" | "running" | "terminating" ) { if existing.owner_boot_id == process_session_boot_id() - && live_process_session(&existing.process_id)?.is_some() + && live_process_session(&existing.process_id) + .map_err(|error| { + ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error) + })? + .is_some() { return poll_process_session_at( root, &identity, &existing.process_id, None, - Some(1), Some(0), - ); + Some(0), + ) + .map_err(|error| { + ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error) + }); } - return Err( - "command.start 已进入可能启动阶段但缺少当前 Runner 句柄,禁止自动重放".to_string(), - ); + reconcile_stale_active_process_session(&mut existing); + write_process_session_record(root, &existing).map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::AuditLog, + format!("command.start 旧启动状态无法写入 reconciliation:{error}"), + ) + })?; + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::LaunchUnknown, + "command.start 已提交启动但缺少当前 Runner 句柄,禁止自动重放", + )); } return poll_process_session_at( root, @@ -979,9 +1352,458 @@ pub(crate) fn start_prepared_process_session_at( None, Some(0), Some(0), - ); + ) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error)); } + let process_id = process_session_id(&identity); + let command_id = format!( + "cmd-{}", + &format!( + "{:x}", + Sha256::digest( + serde_json::to_vec(&serde_json::json!({ + "program": spec.program, + "args": spec.arguments, + "cwd": spec.cwd_relative, + })) + .unwrap_or_default() + ) + )[..24] + ); + let _pending_launch = reserve_pending_process_launch(root, &identity.agent_id, &process_id) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; + let bridge_server = ProcessSessionBridgeServer::bind() + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; + let pair = native_pty_system() + .openpty(PtySize { + rows: 30, + cols: 120, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + format!("创建 command.start PTY 失败:{error}"), + ) + })?; + let reader = pair.master.try_clone_reader().map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + format!("克隆 command.start PTY reader 失败:{error}"), + ) + })?; + let writer = pair.master.take_writer().map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + format!("取得 command.start PTY writer 失败:{error}"), + ) + })?; + let command = process_session_command_builder(launch, &bridge_server) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; + let mut child = pair.slave.spawn_command(command).map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::Spawn, + format!("启动 command.start wrapper 失败:{error}"), + ) + })?; + drop(pair.slave); + let process_group_leader = pair.master.process_group_leader().or_else(|| { + child + .process_id() + .and_then(|value| i32::try_from(value).ok()) + }); + let peer_pid = child.process_id().ok_or_else(|| { + let termination = terminate_pending_process_session_child(&mut child, process_group_leader); + project_command_error_after_pending_termination( + ProjectCommandErrorStage::Preflight, + "command.start wrapper 缺少 pid", + termination, + ) + })?; + let process_group_leader = Some( + process_group_leader + .or_else(|| i32::try_from(peer_pid).ok()) + .ok_or_else(|| { + let termination = terminate_pending_process_session_child(&mut child, None); + project_command_error_after_pending_termination( + ProjectCommandErrorStage::Preflight, + "command.start wrapper 缺少进程组身份", + termination, + ) + })?, + ); + activate_pending_process_launch( + &process_id, + process_group_leader.expect("process group leader validated"), + ) + .map_err(|error| { + let termination = terminate_pending_process_session_child(&mut child, process_group_leader); + project_command_error_after_pending_termination( + ProjectCommandErrorStage::Preflight, + error, + termination, + ) + })?; + let mut bridge = match bridge_server.accept(peer_pid, Duration::from_secs(3)) { + Ok(bridge) => bridge, + Err(error) => { + let termination = + terminate_pending_process_session_child(&mut child, process_group_leader); + return Err(project_command_error_after_pending_termination( + ProjectCommandErrorStage::Preflight, + error, + termination, + )); + } + }; + if let Err(error) = bridge.send_prepare(launch, spec) { + let termination = terminate_pending_process_session_child(&mut child, process_group_leader); + return Err(project_command_error_after_pending_termination( + ProjectCommandErrorStage::Preflight, + error, + termination, + )); + } + match bridge.wait_sandbox_ready(Duration::from_secs(4)) { + Ok(ProcessSessionSandboxReadyVerdict::Ready) => {} + Ok(ProcessSessionSandboxReadyVerdict::Failed { failure_kind }) => { + let termination = + terminate_pending_process_session_child(&mut child, process_group_leader); + return Err(project_command_error_after_pending_termination( + ProjectCommandErrorStage::Preflight, + format!("command.start sandbox ready 前失败:{failure_kind}"), + termination, + )); + } + Err(error) => { + let termination = + terminate_pending_process_session_child(&mut child, process_group_leader); + return Err(project_command_error_after_pending_termination( + ProjectCommandErrorStage::Preflight, + error, + termination, + )); + } + } + + let sandbox_ready_at = unix_timestamp(); + let mut durable_record = initial_process_session_record( + &identity, + &process_id, + &command_id, + spec, + Some(launch), + &source_fingerprint_before, + "launching", + ); + durable_record.sandbox_establishment = "established".to_string(); + durable_record.target_exec = "not-attempted".to_string(); + durable_record.started_at = sandbox_ready_at; + durable_record.sandbox_ready_at = Some(sandbox_ready_at); + if let Err(error) = durable_commit() { + let _ = bridge.abort_launch(); + let termination = terminate_pending_process_session_child(&mut child, process_group_leader); + return Err(project_command_error_after_pending_termination( + ProjectCommandErrorStage::DurableCommit, + error, + termination, + )); + } + if let Err(error) = write_process_session_record(root, &durable_record) { + let _ = bridge.abort_launch(); + let termination = terminate_pending_process_session_child(&mut child, process_group_leader); + return Err(project_command_error_after_pending_termination( + ProjectCommandErrorStage::DurableCommit, + format!("写入 command.start commit record 失败:{error}"), + termination, + )); + } + + if let Err(error) = bridge.commit_exec() { + let termination = terminate_pending_process_session_child(&mut child, process_group_leader); + persist_process_session_launch_unknown(root, &mut durable_record); + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::LaunchUnknown, + format!("{error};{termination}"), + )); + } + let exec = bridge.wait_exec(Duration::from_secs(4)); + match exec { + Ok(ProcessSessionExecVerdict::TargetExecFailed { errno }) => { + let termination = + terminate_pending_process_session_child(&mut child, process_group_leader); + let needs_reconciliation = !termination.confirmed; + mark_process_session_launch_record( + &mut durable_record, + if needs_reconciliation { + "needs-reconciliation" + } else { + "failed" + }, + "failed", + Some("target-exec-failed"), + needs_reconciliation, + ); + write_process_session_record(root, &durable_record).map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::AuditLog, + format!( + "command.start target exec 失败后终态无法落盘:errno={errno};{termination};{error}" + ), + ) + })?; + if needs_reconciliation { + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + format!( + "command.start target exec 失败但 wrapper 回收无法确认:errno={errno};{termination}" + ), + )); + } + return poll_process_session_at(root, &identity, &process_id, None, Some(0), Some(0)) + .map_err(|error| { + ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error) + }); + } + Ok(ProcessSessionExecVerdict::LaunchUnknown) => { + let termination = + terminate_pending_process_session_child(&mut child, process_group_leader); + persist_process_session_launch_unknown(root, &mut durable_record); + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::LaunchUnknown, + format!("process session wrapper 报告 launch unknown;{termination}"), + )); + } + Err(error) => { + let termination = + terminate_pending_process_session_child(&mut child, process_group_leader); + persist_process_session_launch_unknown(root, &mut durable_record); + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::LaunchUnknown, + format!("{error};{termination}"), + )); + } + Ok(ProcessSessionExecVerdict::Established) => {} + } + + let exec_established_at = unix_timestamp(); + durable_record.status = "running".to_string(); + durable_record.target_exec = "established".to_string(); + durable_record.exec_established_at = Some(exec_established_at); + durable_record.stdin_open = true; + durable_record.updated_at = exec_established_at; + if let Err(error) = write_process_session_record(root, &durable_record) { + let termination = terminate_pending_process_session_child(&mut child, process_group_leader); + persist_process_session_launch_unknown(root, &mut durable_record); + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + format!("command.start exec-established 状态无法落盘:{error};{termination}"), + )); + } + + let (control_tx, control_rx) = std::sync::mpsc::channel(); + let live = Arc::new(LiveProcessSession { + root: root.to_path_buf(), + identity, + process_id: process_id.clone(), + command_id, + program: spec.program.clone(), + cwd: spec.cwd_relative.clone(), + sandbox_backend: launch.sandbox_backend.clone(), + sandbox_mode: launch.sandbox_mode.clone(), + network_access: launch.network_access.clone(), + sandbox_profile_version: launch.sandbox_profile_version.clone(), + sandbox_establishment: "established".to_string(), + target_exec: "established".to_string(), + sandbox_ready_at: Some(sandbox_ready_at), + exec_established_at: Some(exec_established_at), + source_fingerprint_before, + started_at: durable_record.started_at, + output: Mutex::new(ProcessOutputState::running()), + output_changed: Condvar::new(), + writer: Mutex::new(Some(writer)), + master: Mutex::new(Some(pair.master)), + control: control_tx, + }); + process_session_registry() + .lock() + .map_err(|_| { + let termination = + terminate_pending_process_session_child(&mut child, process_group_leader); + persist_process_session_launch_unknown(root, &mut durable_record); + ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + format!("process session registry 锁已损坏;{termination}"), + ) + })? + .sessions + .insert(process_id.clone(), Arc::clone(&live)); + + let reader_live = Arc::clone(&live); + thread::spawn(move || drain_process_session_output(reader_live, reader)); + let supervisor_live = Arc::clone(&live); + let timeout_seconds = spec.timeout_seconds; + thread::spawn(move || { + supervise_process_session( + supervisor_live, + &mut child, + control_rx, + timeout_seconds, + process_group_leader, + bridge, + ) + }); + + poll_process_session_at(root, &live.identity, &process_id, None, Some(0), Some(0)) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error)) +} + +#[cfg(target_os = "linux")] +struct PendingProcessTermination { + confirmed: bool, + summary: String, +} + +#[cfg(target_os = "linux")] +impl std::fmt::Display for PendingProcessTermination { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.summary) + } +} + +#[cfg(target_os = "linux")] +fn terminate_pending_process_session_child( + child: &mut Box, + process_group_leader: Option, +) -> PendingProcessTermination { + let group = process_group_leader.filter(|value| *value > 0); + let group_result = group.map(|group| { + let result = unsafe { libc::kill(-group, libc::SIGKILL) }; + if result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + }); + let _ = child.kill(); + let wait = child.wait(); + let (confirmed, summary) = match (group_result, wait) { + (Some(Ok(())), Ok(_)) => (true, "wrapper 进程组已终止并回收".to_string()), + (None, Ok(_)) => (false, "wrapper 主进程已回收但缺少进程组身份".to_string()), + (Some(Err(error)), Ok(_)) => ( + false, + format!("wrapper 主进程已回收但进程组终止失败:{error}"), + ), + (_, Err(error)) => (false, format!("wrapper 进程回收失败:{error}")), + }; + PendingProcessTermination { confirmed, summary } +} + +#[cfg(target_os = "linux")] +fn project_command_error_after_pending_termination( + confirmed_stage: ProjectCommandErrorStage, + message: impl Into, + termination: PendingProcessTermination, +) -> ProjectCommandError { + let stage = if termination.confirmed { + confirmed_stage + } else { + ProjectCommandErrorStage::LaunchUnknown + }; + ProjectCommandError::new(stage, format!("{};{termination}", message.into())) +} + +#[cfg(target_os = "linux")] +fn mark_process_session_launch_record( + record: &mut ProcessSessionRecord, + status: &str, + target_exec: &str, + launch_failure_kind: Option<&str>, + needs_reconciliation: bool, +) { + record.status = status.to_string(); + record.target_exec = target_exec.to_string(); + record.launch_failure_kind = launch_failure_kind.map(str::to_string); + record.stdin_open = false; + record.needs_reconciliation = needs_reconciliation; + record.terminal_at = Some(unix_timestamp()); + record.updated_at = unix_timestamp(); +} + +#[cfg(target_os = "linux")] +fn persist_process_session_launch_unknown(root: &Path, record: &mut ProcessSessionRecord) { + mark_process_session_launch_record( + record, + "needs-reconciliation", + "unknown", + Some("launch-unknown"), + true, + ); + let _ = write_process_session_record(root, record); +} + +#[cfg(not(target_os = "linux"))] +fn start_legacy_process_session_at( + root: &Path, + identity: ProcessSessionIdentity, + spec: &ProjectCommandSpec, + launch: &ProjectCommandLaunchSpec, + source_fingerprint_before: String, + durable_commit: F, +) -> Result +where + F: FnOnce() -> Result<(), String>, +{ + validate_process_session_start_preflight_at(root, &identity, spec) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; + if let Some(existing) = find_existing_start_action_record(root, &identity) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))? + { + if matches!( + existing.status.as_str(), + "prepared" | "launching" | "running" | "terminating" + ) { + if existing.owner_boot_id == process_session_boot_id() + && live_process_session(&existing.process_id) + .map_err(|error| { + ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error) + })? + .is_some() + { + return poll_process_session_at( + root, + &identity, + &existing.process_id, + None, + Some(0), + Some(0), + ) + .map_err(|error| { + ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error) + }); + } + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::LaunchUnknown, + "command.start 已进入可能启动阶段但缺少当前 Runner 句柄,禁止自动重放", + )); + } + return poll_process_session_at( + root, + &identity, + &existing.process_id, + None, + Some(0), + Some(0), + ) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error)); + } + + durable_commit().map_err(|error| { + ProjectCommandError::new(ProjectCommandErrorStage::DurableCommit, error) + })?; + let process_id = process_session_id(&identity); let command_id = format!( "cmd-{}", @@ -1007,10 +1829,12 @@ pub(crate) fn start_prepared_process_session_at( &source_fingerprint_before, "prepared", ); - write_process_session_record(root, &durable_record)?; + write_process_session_record(root, &durable_record) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error))?; durable_record.status = "launching".to_string(); durable_record.updated_at = unix_timestamp(); - write_process_session_record(root, &durable_record)?; + write_process_session_record(root, &durable_record) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error))?; let pair = native_pty_system() .openpty(PtySize { @@ -1025,30 +1849,44 @@ pub(crate) fn start_prepared_process_session_at( &mut durable_record, format!("创建 command.start PTY 失败:{error}"), ) - })?; - let reader = pair.master.try_clone_reader().map_err(|error| { - process_session_launch_failed( - root, - &mut durable_record, - format!("克隆 command.start PTY reader 失败:{error}"), - ) - })?; - let writer = pair.master.take_writer().map_err(|error| { - process_session_launch_failed( - root, - &mut durable_record, - format!("取得 command.start PTY writer 失败:{error}"), - ) - })?; + }) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; + let reader = pair + .master + .try_clone_reader() + .map_err(|error| { + process_session_launch_failed( + root, + &mut durable_record, + format!("克隆 command.start PTY reader 失败:{error}"), + ) + }) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; + let writer = pair + .master + .take_writer() + .map_err(|error| { + process_session_launch_failed( + root, + &mut durable_record, + format!("取得 command.start PTY writer 失败:{error}"), + ) + }) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; let command = process_session_command_builder(launch) - .map_err(|error| process_session_launch_failed(root, &mut durable_record, error))?; - let mut child = pair.slave.spawn_command(command).map_err(|error| { - process_session_launch_failed( - root, - &mut durable_record, - format!("启动 command.start {} 失败:{error}", spec.program), - ) - })?; + .map_err(|error| process_session_launch_failed(root, &mut durable_record, error)) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; + let mut child = pair + .slave + .spawn_command(command) + .map_err(|error| { + process_session_launch_failed( + root, + &mut durable_record, + format!("启动 command.start {} 失败:{error}", spec.program), + ) + }) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; drop(pair.slave); #[cfg(windows)] let windows_job = match WindowsProcessJob::assign(child.as_ref()) { @@ -1057,12 +1895,16 @@ pub(crate) fn start_prepared_process_session_at( let _ = child.kill(); let _ = child.wait(); durable_record.status = "needs-reconciliation".to_string(); + durable_record.sandbox_establishment = "unknown".to_string(); + durable_record.target_exec = "unknown".to_string(); + durable_record.launch_failure_kind = Some("launch-unknown".to_string()); durable_record.needs_reconciliation = true; durable_record.terminal_at = Some(unix_timestamp()); durable_record.updated_at = unix_timestamp(); let _ = write_process_session_record(root, &durable_record); - return Err(format!( - "command.start 已创建进程但无法纳入 Windows Job Object:{error}" + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::LaunchUnknown, + format!("command.start 已创建进程但无法纳入 Windows Job Object:{error}"), )); } }; @@ -1076,6 +1918,7 @@ pub(crate) fn start_prepared_process_session_at( let process_group_leader: Option = None; let (control_tx, control_rx) = std::sync::mpsc::channel(); + let launch_established_at = unix_timestamp(); let live = Arc::new(LiveProcessSession { root: root.to_path_buf(), identity, @@ -1087,8 +1930,12 @@ pub(crate) fn start_prepared_process_session_at( sandbox_mode: launch.sandbox_mode.clone(), network_access: launch.network_access.clone(), sandbox_profile_version: launch.sandbox_profile_version.clone(), + sandbox_establishment: "established".to_string(), + target_exec: "established".to_string(), + sandbox_ready_at: Some(launch_established_at), + exec_established_at: Some(launch_established_at), source_fingerprint_before, - started_at: unix_timestamp(), + started_at: launch_established_at, output: Mutex::new(ProcessOutputState::running()), output_changed: Condvar::new(), writer: Mutex::new(Some(writer)), @@ -1099,27 +1946,38 @@ pub(crate) fn start_prepared_process_session_at( }); let record = { - let output = live - .output - .lock() - .map_err(|_| "process session output 锁已损坏".to_string())?; + let output = live.output.lock().map_err(|_| { + ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + "process session output 锁已损坏", + ) + })?; process_session_record_from_live(&live, &output) }; if let Err(error) = write_process_session_record(root, &record) { let _ = child.kill(); let _ = child.wait(); durable_record.status = "needs-reconciliation".to_string(); + durable_record.sandbox_establishment = "unknown".to_string(); + durable_record.target_exec = "unknown".to_string(); + durable_record.launch_failure_kind = Some("launch-unknown".to_string()); durable_record.needs_reconciliation = true; durable_record.terminal_at = Some(unix_timestamp()); durable_record.updated_at = unix_timestamp(); let _ = write_process_session_record(root, &durable_record); - return Err(format!( - "command.start 已启动但 running 状态无法落盘,需要人工核对:{error}" + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::LaunchUnknown, + format!("command.start 已启动但 running 状态无法落盘,需要人工核对:{error}"), )); } process_session_registry() .lock() - .map_err(|_| "process session registry 锁已损坏".to_string())? + .map_err(|_| { + ProjectCommandError::new( + ProjectCommandErrorStage::LaunchUnknown, + "process session registry 锁已损坏", + ) + })? .sessions .insert(process_id.clone(), Arc::clone(&live)); @@ -1138,6 +1996,7 @@ pub(crate) fn start_prepared_process_session_at( }); poll_process_session_at(root, &live.identity, &process_id, None, Some(0), Some(0)) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error)) } fn append_process_output_line(live: &LiveProcessSession, line: &[u8]) -> bool { @@ -1184,7 +2043,7 @@ fn persist_live_process_snapshot(live: &LiveProcessSession) -> Result<(), String .map_err(|_| "process session output 锁已损坏".to_string())?; let output_sha256 = format!("{:x}", Sha256::digest(output.text.as_bytes())); let transcript = ProcessSessionTranscript { - schema_version: PROCESS_SESSION_SCHEMA_VERSION.to_string(), + schema_version: PROCESS_SESSION_TRANSCRIPT_SCHEMA_VERSION.to_string(), project_id: live.identity.project_id.clone(), agent_id: live.identity.agent_id.clone(), task_id: live.identity.task_id.clone(), @@ -1305,17 +2164,46 @@ fn supervise_process_session( control_rx: std::sync::mpsc::Receiver, timeout_seconds: u64, #[cfg_attr(not(unix), allow(unused_variables))] process_group_leader: Option, + #[cfg(target_os = "linux")] mut launch_bridge: ProcessSessionBridge, ) { let deadline = std::time::Instant::now() + Duration::from_secs(timeout_seconds); let (terminal_status, exit_code, signal) = loop { match child.try_wait() { Ok(Some(status)) => { - if let Err(error) = - terminate_process_session_child(&live, child, process_group_leader, true) - { + #[cfg(target_os = "linux")] + let _ = &status; + #[cfg(target_os = "linux")] + let terminal = match launch_bridge.wait_terminal(Duration::from_secs(2)) { + Ok(ProcessSessionTerminalVerdict::Exited { code }) => { + ("exited".to_string(), Some(code), None) + } + Ok(ProcessSessionTerminalVerdict::Signaled { signal }) => { + ("exited".to_string(), None, Some(format!("signal-{signal}"))) + } + Ok(ProcessSessionTerminalVerdict::Unknown) => { + let error = "process session target terminal 无法确认".to_string(); + mark_process_session_reconciliation(&live, &error); + ("needs-reconciliation".to_string(), None, Some(error)) + } + Err(error) => { + mark_process_session_reconciliation(&live, &error); + ("needs-reconciliation".to_string(), None, Some(error)) + } + }; + if let Err(error) = terminate_process_session_child( + &live, + child, + process_group_leader, + #[cfg(target_os = "linux")] + &mut launch_bridge, + true, + ) { mark_process_session_reconciliation(&live, &error); break ("needs-reconciliation".to_string(), None, Some(error)); } + #[cfg(target_os = "linux")] + break terminal; + #[cfg(not(target_os = "linux"))] break ( "exited".to_string(), i32::try_from(status.exit_code()).ok(), @@ -1325,9 +2213,14 @@ fn supervise_process_session( Ok(None) => {} Err(error) => { let wait_error = format!("wait failed: {error}"); - if let Err(termination_error) = - terminate_process_session_child(&live, child, process_group_leader, true) - { + if let Err(termination_error) = terminate_process_session_child( + &live, + child, + process_group_leader, + #[cfg(target_os = "linux")] + &mut launch_bridge, + true, + ) { let detail = format!("{wait_error}; {termination_error}"); mark_process_session_reconciliation(&live, &detail); break ("needs-reconciliation".to_string(), None, Some(detail)); @@ -1340,7 +2233,14 @@ fn supervise_process_session( let wait = remaining.min(Duration::from_millis(50)); match control_rx.recv_timeout(wait) { Ok(ProcessControl::Terminate) => { - match terminate_process_session_child(&live, child, process_group_leader, false) { + match terminate_process_session_child( + &live, + child, + process_group_leader, + #[cfg(target_os = "linux")] + &mut launch_bridge, + false, + ) { Ok(()) => break ("terminated".to_string(), None, None), Err(error) => { mark_process_session_reconciliation(&live, &error); @@ -1349,7 +2249,14 @@ fn supervise_process_session( } } Ok(ProcessControl::OutputLimit) => { - match terminate_process_session_child(&live, child, process_group_leader, true) { + match terminate_process_session_child( + &live, + child, + process_group_leader, + #[cfg(target_os = "linux")] + &mut launch_bridge, + true, + ) { Ok(()) => break ("output-limit-exceeded".to_string(), None, None), Err(error) => { mark_process_session_reconciliation(&live, &error); @@ -1358,7 +2265,14 @@ fn supervise_process_session( } } Ok(ProcessControl::Shutdown) => { - match terminate_process_session_child(&live, child, process_group_leader, true) { + match terminate_process_session_child( + &live, + child, + process_group_leader, + #[cfg(target_os = "linux")] + &mut launch_bridge, + true, + ) { Ok(()) => { break ( "terminated".to_string(), @@ -1373,7 +2287,14 @@ fn supervise_process_session( } } Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - match terminate_process_session_child(&live, child, process_group_leader, true) { + match terminate_process_session_child( + &live, + child, + process_group_leader, + #[cfg(target_os = "linux")] + &mut launch_bridge, + true, + ) { Ok(()) => { break ( "terminated".to_string(), @@ -1390,7 +2311,14 @@ fn supervise_process_session( Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} } if std::time::Instant::now() >= deadline { - match terminate_process_session_child(&live, child, process_group_leader, true) { + match terminate_process_session_child( + &live, + child, + process_group_leader, + #[cfg(target_os = "linux")] + &mut launch_bridge, + true, + ) { Ok(()) => break ("timed-out".to_string(), None, None), Err(error) => { mark_process_session_reconciliation(&live, &error); @@ -1416,6 +2344,7 @@ fn terminate_process_session_child( live: &LiveProcessSession, child: &mut Box, #[cfg_attr(not(unix), allow(unused_variables))] process_group_leader: Option, + #[cfg(target_os = "linux")] launch_bridge: &mut ProcessSessionBridge, #[cfg_attr(windows, allow(unused_variables))] force: bool, ) -> Result<(), String> { #[cfg(not(windows))] @@ -1436,6 +2365,19 @@ fn terminate_process_session_child( #[cfg(unix)] { tree_contained = if let Some(group) = process_group_leader.filter(|value| *value > 0) { + #[cfg(target_os = "linux")] + if !force && !child_reaped { + if let Err(error) = launch_bridge.terminate_target() { + match child.try_wait() { + Ok(Some(_)) => child_reaped = true, + Ok(None) => return Err(error), + Err(wait_error) => { + return Err(format!("{error};检查 wrapper 终态失败:{wait_error}")); + } + } + } + } + #[cfg(all(unix, not(target_os = "linux")))] if !force && !child_reaped { unsafe { libc::kill(-group, libc::SIGTERM); @@ -1448,6 +2390,7 @@ fn terminate_process_session_child( if !child_reaped { if let Ok(Some(_)) = child.try_wait() { child_reaped = true; + break; } } thread::sleep(Duration::from_millis(25)); @@ -1539,7 +2482,7 @@ fn finalize_live_process_session( let output_sha256 = format!("{:x}", Sha256::digest(output.text.as_bytes())); let transcript = ProcessSessionTranscript { - schema_version: PROCESS_SESSION_SCHEMA_VERSION.to_string(), + schema_version: PROCESS_SESSION_TRANSCRIPT_SCHEMA_VERSION.to_string(), project_id: live.identity.project_id.clone(), agent_id: live.identity.agent_id.clone(), task_id: live.identity.task_id.clone(), @@ -1596,6 +2539,9 @@ fn poll_result_from_output( sandbox_mode: &str, network_access: &str, sandbox_profile_version: &str, + sandbox_establishment: &str, + target_exec: &str, + launch_failure_kind: Option<&str>, cursor: Option<&str>, max_chars: usize, ) -> Result { @@ -1624,6 +2570,9 @@ fn poll_result_from_output( sandbox_mode: sandbox_mode.to_string(), network_access: network_access.to_string(), sandbox_profile_version: sandbox_profile_version.to_string(), + sandbox_establishment: sandbox_establishment.to_string(), + target_exec: target_exec.to_string(), + launch_failure_kind: launch_failure_kind.map(str::to_string), }) } @@ -1638,7 +2587,7 @@ pub(crate) fn poll_process_session_at( validate_process_session_identity(identity)?; let max_chars = max_chars .unwrap_or(PROCESS_SESSION_DEFAULT_POLL_CHARS) - .clamp(1, PROCESS_SESSION_MAX_POLL_CHARS); + .min(PROCESS_SESSION_MAX_POLL_CHARS); let wait_ms = wait_ms.unwrap_or(0).min(PROCESS_SESSION_MAX_POLL_WAIT_MS); if let Some(live) = live_process_session(process_id)? { if live.root != root || live.identity != *identity { @@ -1664,6 +2613,9 @@ pub(crate) fn poll_process_session_at( &live.sandbox_mode, &live.network_access, &live.sandbox_profile_version, + &live.sandbox_establishment, + &live.target_exec, + output.launch_failure_kind.as_deref(), cursor, max_chars, ); @@ -1677,11 +2629,7 @@ pub(crate) fn poll_process_session_at( "prepared" | "launching" | "running" | "terminating" ) && record.owner_boot_id != process_session_boot_id() { - record.status = "needs-reconciliation".to_string(); - record.stdin_open = false; - record.needs_reconciliation = true; - record.terminal_at = Some(unix_timestamp()); - record.updated_at = unix_timestamp(); + reconcile_stale_active_process_session(&mut record); write_process_session_record(root, &record)?; } let transcript = if let Some(output_ref) = record.output_ref.as_deref() { @@ -1720,6 +2668,7 @@ pub(crate) fn poll_process_session_at( source_fingerprint_after: record.source_fingerprint_after, source_changed: record.source_changed, needs_reconciliation: record.needs_reconciliation, + launch_failure_kind: record.launch_failure_kind.clone(), }; poll_result_from_output( process_id, @@ -1729,6 +2678,9 @@ pub(crate) fn poll_process_session_at( &record.sandbox_mode, &record.network_access, &record.sandbox_profile_version, + &record.sandbox_establishment, + &record.target_exec, + record.launch_failure_kind.as_deref(), cursor, max_chars, ) @@ -1742,6 +2694,29 @@ pub(crate) fn write_process_session_stdin_at( append_newline: bool, eof: bool, ) -> Result { + write_process_session_stdin_at_with_after_write( + root, + identity, + process_id, + data, + append_newline, + eof, + |_| {}, + ) +} + +fn write_process_session_stdin_at_with_after_write( + root: &Path, + identity: &ProcessSessionIdentity, + process_id: &str, + data: &str, + append_newline: bool, + eof: bool, + after_write: F, +) -> Result +where + F: FnOnce(&LiveProcessSession), +{ validate_process_session_identity(identity)?; let live = live_process_session(process_id)? .ok_or_else(|| "process session 不在当前 Runner 中运行".to_string())?; @@ -1787,17 +2762,12 @@ pub(crate) fn write_process_session_stdin_at( writer.take(); } drop(writer); + after_write(&live); let mut output = live .output .lock() .map_err(|_| "process session output 锁已损坏".to_string())?; - if output.status != "running" { - output.needs_reconciliation = true; - let reconciliation = process_session_record_from_live(&live, &output); - let _ = write_process_session_record(root, &reconciliation); - return Err("command.stdin 写入期间 process session 进入终态,需要人工核对".to_string()); - } - if eof { + if eof || output.status != "running" { output.stdin_open = false; } let record = process_session_record_from_live(&live, &output); @@ -1885,18 +2855,71 @@ pub(crate) fn terminate_process_session_at( Ok(result) } +pub(crate) fn mark_process_session_start_audit_failure_at( + root: &Path, + process_id: &str, + error: &str, +) -> Result<(), String> { + if let Some(live) = live_process_session(process_id)? { + if live.root != root { + return Err("process session 不属于当前项目".to_string()); + } + mark_process_session_reconciliation( + &live, + &format!("command.start audit persistence failed: {error}"), + ); + if let Ok(mut output) = live.output.lock() { + output.launch_failure_kind = Some("start-audit-failed".to_string()); + } + let _ = live.control.send(ProcessControl::Terminate); + } + let mut record = read_process_session_record(root, process_id)? + .ok_or_else(|| "command.start audit 失败后 process record 缺失".to_string())?; + record.status = "needs-reconciliation".to_string(); + record.stdin_open = false; + record.needs_reconciliation = true; + record.launch_failure_kind = Some("start-audit-failed".to_string()); + record.signal = Some(redact_agent_runtime_project_paths(root, error, 240)); + record.terminal_at = Some(unix_timestamp()); + record.updated_at = unix_timestamp(); + write_process_session_record(root, &record) +} + pub(crate) fn active_process_session_records_at( root: &Path, agent_id: Option<&str>, run_id: Option<&str>, ) -> Result, String> { + let live_sessions = process_session_registry() + .lock() + .map_err(|_| "process session registry 锁已损坏".to_string())? + .sessions + .values() + .filter(|live| live.root == root) + .cloned() + .collect::>(); + let mut records = Vec::with_capacity(live_sessions.len()); + for live in live_sessions { + if agent_id.is_some_and(|value| value != live.identity.agent_id.as_str()) + || run_id.is_some_and(|value| value != live.identity.run_id.as_str()) + { + continue; + } + let output = live + .output + .lock() + .map_err(|_| "process session output 锁已损坏".to_string())?; + records.push(process_session_record_from_live(&live, &output)); + } let directory = root.join(".agent/runtime/process-sessions"); let entries = match fs::read_dir(&directory) { Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + records.sort_by(|left, right| left.started_at.cmp(&right.started_at)); + return Ok(records); + } Err(error) => return Err(format!("读取 process session 目录失败:{error}")), }; - let mut records = Vec::new(); for entry in entries { let entry = entry.map_err(|error| format!("读取 process session 目录项失败:{error}"))?; let name = entry.file_name(); @@ -1912,6 +2935,9 @@ pub(crate) fn active_process_session_records_at( if validate_process_id(process_id).is_err() { continue; } + if records.iter().any(|record| record.process_id == process_id) { + continue; + } let Some(mut record) = read_process_session_record(root, process_id)? else { continue; }; @@ -1920,11 +2946,7 @@ pub(crate) fn active_process_session_records_at( "prepared" | "launching" | "running" | "terminating" ) && record.owner_boot_id != process_session_boot_id() { - record.status = "needs-reconciliation".to_string(); - record.stdin_open = false; - record.needs_reconciliation = true; - record.terminal_at = Some(unix_timestamp()); - record.updated_at = unix_timestamp(); + reconcile_stale_active_process_session(&mut record); write_process_session_record(root, &record)?; } if (record.needs_reconciliation @@ -1943,7 +2965,20 @@ pub(crate) fn active_process_session_records_at( } pub(crate) fn has_active_process_sessions_at(root: &Path) -> Result { - Ok(!active_process_session_records_at(root, None, None)?.is_empty()) + if !active_process_session_records_at(root, None, None)?.is_empty() { + return Ok(true); + } + #[cfg(target_os = "linux")] + { + return Ok(pending_process_launch_registry() + .lock() + .map_err(|_| "pending process launch registry 锁已损坏".to_string())? + .launches + .values() + .any(|launch| launch.root == root)); + } + #[cfg(not(target_os = "linux"))] + Ok(false) } pub(crate) fn terminate_process_sessions_for_run_at( @@ -1984,6 +3019,28 @@ pub(crate) fn terminate_process_sessions_for_run_at( } pub(crate) fn shutdown_all_process_sessions() { + #[cfg(target_os = "linux")] + { + let pending = pending_process_launch_registry() + .lock() + .ok() + .map(|mut registry| { + registry + .launches + .values_mut() + .filter_map(|launch| { + launch.shutdown_requested = true; + launch.process_group_leader + }) + .collect::>() + }) + .unwrap_or_default(); + for process_group_leader in pending { + unsafe { + libc::kill(-process_group_leader, libc::SIGKILL); + } + } + } let sessions = process_session_registry() .lock() .ok() @@ -2017,7 +3074,17 @@ pub(crate) fn shutdown_all_process_sessions_and_wait(timeout: Duration) -> Resul .unwrap_or(true) }); if running.count() == 0 { - return Ok(()); + #[cfg(target_os = "linux")] + let pending_empty = pending_process_launch_registry() + .lock() + .map_err(|_| "pending process launch registry 锁已损坏".to_string())? + .launches + .is_empty(); + #[cfg(not(target_os = "linux"))] + let pending_empty = true; + if pending_empty { + return Ok(()); + } } if std::time::Instant::now() >= deadline { return Err("Runner 退出前未能回收全部 process session".to_string()); @@ -2032,11 +3099,16 @@ pub(crate) fn clear_process_session_registry_for_tests() { if let Ok(mut registry) = process_session_registry().lock() { registry.sessions.clear(); } + #[cfg(target_os = "linux")] + if let Ok(mut registry) = pending_process_launch_registry().lock() { + registry.launches.clear(); + } } #[cfg(test)] mod tests { use super::*; + #[cfg(target_os = "linux")] use std::process::Stdio; static PROCESS_SESSION_TEST_LOCK: OnceLock> = OnceLock::new(); @@ -2074,6 +3146,7 @@ mod tests { source_fingerprint_after: None, source_changed: None, needs_reconciliation: false, + launch_failure_kind: None, }; let first = poll_result_from_output( process_id, @@ -2083,6 +3156,9 @@ mod tests { "test", "test", "test-v1", + "established", + "established", + None, None, 2, ) @@ -2097,6 +3173,9 @@ mod tests { "test", "test", "test-v1", + "established", + "established", + None, Some(&first.next_cursor), 3, ) @@ -2105,6 +3184,269 @@ mod tests { assert!(!second.has_more); } + fn write_legacy_process_session_record( + root: &Path, + record: &ProcessSessionRecord, + schema_version: &str, + ) { + let mut value = serde_json::to_value(record).expect("serialize legacy record"); + let object = value.as_object_mut().expect("legacy record object"); + object.insert( + "schemaVersion".to_string(), + serde_json::Value::String(schema_version.to_string()), + ); + for field in [ + "sandboxEstablishment", + "targetExec", + "launchFailureKind", + "sandboxReadyAt", + "execEstablishedAt", + ] { + object.remove(field); + } + if schema_version == "1" { + for field in [ + "sandboxBackend", + "sandboxMode", + "networkAccess", + "sandboxProfileVersion", + ] { + object.remove(field); + } + } + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &process_session_record_relative_path(&record.process_id), + "legacy process session", + &value, + PROCESS_SESSION_RECORD_MAX_BYTES, + ) + .expect("write legacy process record"); + } + + #[test] + fn process_session_v1_v2_active_records_migrate_to_v3_reconciliation() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "legacy-active-project", "Legacy Active Project") + .expect("initialize project"); + for (index, schema_version) in ["1", "2"].into_iter().enumerate() { + let mut identity = process_identity("legacy-active-project"); + identity.start_action_id = format!("legacy-active-action-{index}"); + identity.start_action_fingerprint = format!("{}", index + 1).repeat(64); + let process_id = format!("proc-{:032x}", index + 1); + let record = initial_process_session_record( + &identity, + &process_id, + &format!("cmd-legacy-active-{index}"), + &resolve_project_command_spec_at(root, "bash", &["--version".to_string()], ".", 30) + .expect("resolve command"), + None, + &"a".repeat(64), + "running", + ); + write_legacy_process_session_record(root, &record, schema_version); + + let migrated = read_process_session_record(root, &process_id) + .expect("read migrated active record") + .expect("active record exists"); + assert_eq!(migrated.schema_version, "3"); + assert_eq!(migrated.status, "needs-reconciliation"); + assert_eq!(migrated.sandbox_establishment, "unknown"); + assert_eq!(migrated.target_exec, "unknown"); + assert_eq!( + migrated.launch_failure_kind.as_deref(), + Some("legacy-active-record") + ); + assert!(migrated.needs_reconciliation); + assert!(migrated.terminal_at.is_some()); + let repeated = read_process_session_record(root, &process_id) + .expect("read migrated active record again") + .expect("active record remains"); + assert_eq!(repeated, migrated); + } + clear_process_session_registry_for_tests(); + } + + #[test] + fn process_session_v1_v2_terminal_records_remain_readable_without_reconciliation() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "legacy-terminal-project", "Legacy Terminal Project") + .expect("initialize project"); + for (index, schema_version) in ["1", "2"].into_iter().enumerate() { + let mut identity = process_identity("legacy-terminal-project"); + identity.start_action_id = format!("legacy-terminal-action-{index}"); + identity.start_action_fingerprint = format!("{}", index + 3).repeat(64); + let process_id = format!("proc-{:032x}", index + 16); + let mut record = initial_process_session_record( + &identity, + &process_id, + &format!("cmd-legacy-terminal-{index}"), + &resolve_project_command_spec_at(root, "bash", &["--version".to_string()], ".", 30) + .expect("resolve command"), + None, + &"b".repeat(64), + "exited", + ); + record.exit_code = Some(index as i32); + record.terminal_at = Some(record.started_at); + let output = format!("LEGACY-TERMINAL-{schema_version}"); + record.output_bytes = output.len(); + record.output_sha256 = format!("{:x}", Sha256::digest(output.as_bytes())); + record.output_ref = Some(process_session_transcript_relative_path(&process_id)); + let transcript = ProcessSessionTranscript { + schema_version: schema_version.to_string(), + project_id: identity.project_id.clone(), + agent_id: identity.agent_id.clone(), + task_id: identity.task_id.clone(), + conversation_session_id: identity.conversation_session_id.clone(), + run_id: identity.run_id.clone(), + start_action_id: identity.start_action_id.clone(), + start_action_fingerprint: identity.start_action_fingerprint.clone(), + process_id: process_id.clone(), + output: output.clone(), + output_sha256: record.output_sha256.clone(), + output_bytes: output.len(), + updated_at: record.updated_at, + }; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + record.output_ref.as_deref().expect("legacy output ref"), + "legacy process transcript", + &transcript, + PROCESS_SESSION_TRANSCRIPT_MAX_BYTES, + ) + .expect("write legacy process transcript"); + write_legacy_process_session_record(root, &record, schema_version); + + let migrated = read_process_session_record(root, &process_id) + .expect("read migrated terminal record") + .expect("terminal record exists"); + assert_eq!(migrated.schema_version, "3"); + assert_eq!(migrated.status, "exited"); + assert_eq!(migrated.sandbox_establishment, "unknown"); + assert_eq!(migrated.target_exec, "unknown"); + assert_eq!( + migrated.launch_failure_kind.as_deref(), + Some("legacy-record") + ); + assert!(!migrated.needs_reconciliation); + let poll = + poll_process_session_at(root, &identity, &process_id, None, Some(8_000), Some(0)) + .expect("poll migrated terminal record"); + assert_eq!(poll.status, "exited"); + assert_eq!(poll.exit_code, Some(index as i32)); + assert_eq!(poll.output, output); + } + clear_process_session_registry_for_tests(); + } + + #[test] + fn process_session_v3_rejects_untrusted_state_combinations_and_timestamps() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "v3-validation-project", "V3 Validation Project") + .expect("initialize project"); + let identity = process_identity("v3-validation-project"); + let process_id = "proc-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let spec = + resolve_project_command_spec_at(root, "bash", &["--version".to_string()], ".", 30) + .expect("resolve command"); + let mut record = initial_process_session_record( + &identity, + process_id, + "cmd-v3-validation", + &spec, + None, + &"c".repeat(64), + "running", + ); + assert!(validate_process_session_record(root, &record, process_id).is_err()); + + record.sandbox_establishment = "established".to_string(); + record.target_exec = "established".to_string(); + record.sandbox_ready_at = Some(record.started_at); + record.exec_established_at = Some(record.started_at); + record.launch_failure_kind = Some("arbitrary".to_string()); + assert!(validate_process_session_record(root, &record, process_id).is_err()); + + record.launch_failure_kind = None; + record.sandbox_ready_at = Some(record.started_at.saturating_add(2)); + record.exec_established_at = Some(record.started_at.saturating_add(1)); + assert!(validate_process_session_record(root, &record, process_id).is_err()); + + record.status = "failed".to_string(); + record.sandbox_establishment = "unknown".to_string(); + record.target_exec = "unknown".to_string(); + record.launch_failure_kind = Some("launch-unknown".to_string()); + record.sandbox_ready_at = None; + record.exec_established_at = None; + record.terminal_at = Some(record.started_at); + record.needs_reconciliation = false; + assert!(validate_process_session_record(root, &record, process_id).is_err()); + + record.status = "needs-reconciliation".to_string(); + record.needs_reconciliation = true; + assert!(validate_process_session_record(root, &record, process_id).is_ok()); + record.needs_reconciliation = false; + assert!(validate_process_session_record(root, &record, process_id).is_err()); + + record.needs_reconciliation = true; + record.sandbox_establishment = "established".to_string(); + record.target_exec = "established".to_string(); + record.launch_failure_kind = Some("start-audit-failed".to_string()); + record.sandbox_ready_at = Some(record.started_at); + record.exec_established_at = Some(record.started_at); + assert!(validate_process_session_record(root, &record, process_id).is_ok()); + record.status = "failed".to_string(); + assert!(validate_process_session_record(root, &record, process_id).is_err()); + + record.status = "launching".to_string(); + record.needs_reconciliation = false; + record.target_exec = "not-attempted".to_string(); + record.launch_failure_kind = None; + record.exec_established_at = None; + record.terminal_at = None; + assert!(validate_process_session_record(root, &record, process_id).is_ok()); + record.sandbox_ready_at = None; + assert!(validate_process_session_record(root, &record, process_id).is_err()); + clear_process_session_registry_for_tests(); + } + + #[cfg(target_os = "linux")] + #[test] + fn pending_process_launch_blocks_idle_until_guard_is_dropped() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "pending-launch-project", "Pending Launch Project") + .expect("initialize project"); + let pending = reserve_pending_process_launch( + root, + "code-prototype", + "proc-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .expect("register pending launch"); + assert!(has_active_process_sessions_at(root).expect("pending launch is active")); + shutdown_all_process_sessions(); + assert!( + activate_pending_process_launch("proc-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", i32::MAX,) + .expect_err("shutdown cancels a launch before pid activation") + .contains("shutdown") + ); + drop(pending); + assert!(!has_active_process_sessions_at(root).expect("pending launch removed")); + clear_process_session_registry_for_tests(); + } + #[test] fn process_session_ansi_stripper_handles_split_csi_and_osc() { let mut stripper = AnsiStripper::default(); @@ -2139,6 +3481,8 @@ mod tests { r#" process.stdin.setEncoding('utf8'); console.log('\u001b[31mREADY\u001b[0m'); +console.log(`BRIDGE_ENV:${Object.keys(globalThis['process']['env']).filter((name) => name.includes('PROCESS_SESSION_BRIDGE')).join(',')}`); +console.log(`TARGET_ARGV:${process.argv.join('|')}`); process.stdin.on('data', (chunk) => console.log(`ECHO:${chunk.trim()}`)); process.on('SIGTERM', () => { console.log('STOPPED'); process.exit(0); }); setInterval(() => {}, 1000); @@ -2159,12 +3503,17 @@ setInterval(() => {}, 1000); project_command_source_fingerprint(root).expect("source fingerprint"); let mut poll = start_process_session_at(root, identity.clone(), &spec, source_fingerprint) .expect("start process session"); + assert!(poll.output.is_empty()); + assert_eq!(poll.cursor, poll.next_cursor); #[cfg(target_os = "linux")] { assert_eq!(poll.sandbox_backend, "bubblewrap"); assert_eq!(poll.sandbox_mode, "workspace-write"); assert_eq!(poll.network_access, "disabled"); assert_eq!(poll.sandbox_profile_version, "workspace-v1"); + assert_eq!(poll.sandbox_establishment, "established"); + assert_eq!(poll.target_exec, "established"); + assert_eq!(poll.launch_failure_kind, None); } assert!(has_active_process_sessions_at(root).expect("active process probe")); let mut combined = poll.output.clone(); @@ -2274,10 +3623,469 @@ setInterval(() => {}, 1000); "{:?}", transcript.output ); + assert!(transcript_lines.contains(&"BRIDGE_ENV:")); + let record_json = + fs::read_to_string(root.join(process_session_record_relative_path(&record.process_id))) + .expect("read process record json"); + for private_marker in [ + "GENARRATIVE_PROCESS_SESSION_BRIDGE_ENDPOINT", + "GENARRATIVE_PROCESS_SESSION_BRIDGE_NONCE", + "genarrative-ps-", + "sandbox_ready", + "commit_exec", + "exec_established", + ] { + assert!( + !transcript.output.contains(private_marker), + "private marker leaked: {private_marker}: {:?}", + transcript.output + ); + assert!( + !record_json.contains(private_marker), + "private marker leaked to record: {private_marker}: {record_json}" + ); + } assert!(!has_active_process_sessions_at(root).expect("terminal process probe")); clear_process_session_registry_for_tests(); } + #[cfg(target_os = "linux")] + #[test] + fn process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "graceful-process-project", "Graceful Process Project") + .expect("initialize project"); + let spec = resolve_project_command_spec_at( + root, + "bash", + &[ + "-lc".to_string(), + "(trap 'sleep 0.4; printf done > graceful-marker.txt; exit 0' TERM; while :; do sleep 1; done) & printf 'READY\\n'; exit 0".to_string(), + ], + ".", + 30, + ) + .expect("resolve graceful command"); + let identity = process_identity("graceful-process-project"); + let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); + let mut poll = + start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start"); + for _ in 0..20 { + if poll.output.contains("READY") { + break; + } + poll = poll_process_session_at( + root, + &identity, + &poll.process_id, + Some(&poll.next_cursor), + Some(8_000), + Some(250), + ) + .expect("poll graceful ready"); + } + assert!(poll.output.contains("READY")); + + let terminated = terminate_process_session_at( + root, + &identity, + &poll.process_id, + Some(&poll.next_cursor), + ) + .expect("graceful terminate"); + assert_eq!(terminated.status, "terminated"); + assert!(!terminated.needs_reconciliation); + assert_eq!( + fs::read_to_string(root.join("graceful-marker.txt")) + .expect("target completed delayed SIGTERM cleanup"), + "done" + ); + clear_process_session_registry_for_tests(); + } + + #[cfg(target_os = "linux")] + #[test] + fn process_session_live_registry_blocks_when_durable_record_is_missing() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "live-record-project", "Live Record Project") + .expect("initialize project"); + let spec = resolve_project_command_spec_at( + root, + "bash", + &[ + "-lc".to_string(), + "printf 'READY\\n'; while :; do sleep 1; done".to_string(), + ], + ".", + 30, + ) + .expect("resolve live record command"); + let identity = process_identity("live-record-project"); + let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); + let started = + start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start"); + fs::remove_file(root.join(process_session_record_relative_path(&started.process_id))) + .expect("remove durable process record"); + + let active = active_process_session_records_at( + root, + Some(identity.agent_id.as_str()), + Some(identity.run_id.as_str()), + ) + .expect("live registry remains authoritative blocker"); + assert!(active + .iter() + .any(|record| record.process_id == started.process_id)); + assert!(has_active_process_sessions_at(root).expect("live registry blocks idle")); + + terminate_process_session_at(root, &identity, &started.process_id, None) + .expect("terminate live record fixture"); + clear_process_session_registry_for_tests(); + } + + #[cfg(target_os = "linux")] + #[test] + fn process_session_fast_exit_zero_and_seven_keep_same_process_id() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + for exit_code in [0, 7] { + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + let project_id = format!("fast-exit-{exit_code}-project"); + init_local_game_project_at(root, &project_id, "Fast Exit Project") + .expect("initialize project"); + let spec = resolve_project_command_spec_at( + root, + "bash", + &[ + "-lc".to_string(), + format!("printf 'FAST-{exit_code}\\n'; exit {exit_code}"), + ], + ".", + 30, + ) + .expect("resolve fast exit command"); + let mut identity = process_identity(&project_id); + identity.start_action_id = format!("fast-exit-action-{exit_code}"); + identity.start_action_fingerprint = format!("{}", exit_code + 1).repeat(64); + let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); + let started = start_process_session_at(root, identity.clone(), &spec, fingerprint) + .expect("start"); + let process_id = started.process_id.clone(); + assert!(started.output.is_empty()); + assert_eq!(started.cursor, started.next_cursor); + assert_eq!(started.sandbox_establishment, "established"); + assert_eq!(started.target_exec, "established"); + + let mut poll = started; + let mut output = String::new(); + for _ in 0..30 { + poll = poll_process_session_at( + root, + &identity, + &process_id, + Some(&poll.next_cursor), + Some(8_000), + Some(250), + ) + .expect("poll fast exit"); + assert_eq!(poll.process_id, process_id); + output.push_str(&poll.output); + if poll.status != "running" && !poll.has_more { + break; + } + } + assert_eq!(poll.status, "exited", "{output}"); + assert_eq!(poll.exit_code, Some(exit_code), "{output}"); + assert!(output.contains(&format!("FAST-{exit_code}")), "{output}"); + let record = read_process_session_record(root, &process_id) + .expect("read fast exit record") + .expect("fast exit record exists"); + assert_eq!(record.process_id, process_id); + assert_eq!(record.target_exec, "established"); + assert!(record.exec_established_at.is_some()); + clear_process_session_registry_for_tests(); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn process_session_durable_commit_failure_runs_no_target_and_writes_no_record() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "durable-failure-project", "Durable Failure Project") + .expect("initialize project"); + let spec = resolve_project_command_spec_at( + root, + "bash", + &[ + "-lc".to_string(), + "printf ran > durable-target-ran.txt".to_string(), + ], + ".", + 30, + ) + .expect("resolve durable failure command"); + let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch"); + let identity = process_identity("durable-failure-project"); + let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); + let error = start_prepared_process_session_at( + root, + identity.clone(), + &spec, + &launch, + fingerprint, + || { + assert!(!root.join("durable-target-ran.txt").exists()); + assert!(find_existing_start_action_record(root, &identity) + .expect("record absent inside durable callback") + .is_none()); + Err("forced durable commit failure".to_string()) + }, + ) + .expect_err("durable commit must fail"); + assert_eq!(error.stage(), ProjectCommandErrorStage::DurableCommit); + assert!(!root.join("durable-target-ran.txt").exists()); + assert!(find_existing_start_action_record(root, &identity) + .expect("search process record") + .is_none()); + assert!(!has_active_process_sessions_at(root).expect("no pending launch")); + clear_process_session_registry_for_tests(); + } + + #[cfg(target_os = "linux")] + #[test] + fn process_session_slow_durable_commit_keeps_target_blocked_until_commit() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "slow-commit-project", "Slow Commit Project") + .expect("initialize project"); + let spec = resolve_project_command_spec_at( + root, + "bash", + &[ + "-lc".to_string(), + "printf committed > slow-commit-target.txt".to_string(), + ], + ".", + 30, + ) + .expect("resolve slow commit command"); + let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch"); + let identity = process_identity("slow-commit-project"); + let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); + let started_at = std::time::Instant::now(); + let result = start_prepared_process_session_at( + root, + identity.clone(), + &spec, + &launch, + fingerprint, + || { + thread::sleep(Duration::from_millis(3_200)); + assert!(!root.join("slow-commit-target.txt").exists()); + Ok(()) + }, + ) + .expect("slow durable commit must not time out in child"); + assert!(started_at.elapsed() >= Duration::from_millis(3_200)); + assert_eq!(result.sandbox_establishment, "established"); + assert_eq!(result.target_exec, "established"); + + let mut poll = result; + for _ in 0..20 { + poll = poll_process_session_at( + root, + &identity, + &poll.process_id, + Some(&poll.next_cursor), + Some(8_000), + Some(250), + ) + .expect("poll slow commit target"); + if poll.status != "running" { + break; + } + } + assert_eq!(poll.status, "exited"); + assert_eq!( + fs::read_to_string(root.join("slow-commit-target.txt")) + .expect("read slow commit marker"), + "committed" + ); + clear_process_session_registry_for_tests(); + } + + #[cfg(windows)] + #[test] + fn process_session_windows_replay_runs_durable_commit_only_once() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "windows-replay-project", "Windows Replay Project") + .expect("initialize project"); + fs::write( + root.join("package.json"), + r#"{"scripts":{"dev":"node fixture.js"}}"#, + ) + .expect("write package.json"); + fs::write(root.join("fixture.js"), "setInterval(() => {}, 1000);\n") + .expect("write fixture"); + let spec = resolve_project_command_spec_at( + root, + "npm", + &["run".to_string(), "dev".to_string()], + ".", + 30, + ) + .expect("resolve npm command"); + let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch"); + let identity = process_identity("windows-replay-project"); + let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); + let commit_count = std::sync::atomic::AtomicUsize::new(0); + let first = start_prepared_process_session_at( + root, + identity.clone(), + &spec, + &launch, + fingerprint.clone(), + || { + commit_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(()) + }, + ) + .expect("first start"); + let second = start_prepared_process_session_at( + root, + identity.clone(), + &spec, + &launch, + fingerprint, + || { + commit_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(()) + }, + ) + .expect("idempotent replay"); + assert_eq!(first.process_id, second.process_id); + assert_eq!(commit_count.load(std::sync::atomic::Ordering::SeqCst), 1); + let record = read_process_session_record(root, &first.process_id) + .expect("read Windows replay record") + .expect("Windows replay record exists"); + assert_eq!(record.sandbox_ready_at, Some(record.started_at)); + assert_eq!(record.exec_established_at, Some(record.started_at)); + terminate_process_session_at(root, &identity, &first.process_id, None) + .expect("terminate replay fixture"); + clear_process_session_registry_for_tests(); + } + + #[cfg(target_os = "linux")] + #[test] + fn process_session_target_exec_failure_is_known_terminal_record() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "target-exec-failure-project", "Target Exec Failure") + .expect("initialize project"); + let mut spec = resolve_project_command_spec_at( + root, + "bash", + &["-lc".to_string(), "exit 0".to_string()], + ".", + 30, + ) + .expect("resolve command"); + let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch"); + spec.executable = PathBuf::from("/definitely-missing-genarrative-target"); + let identity = process_identity("target-exec-failure-project"); + let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); + let committed = std::sync::atomic::AtomicBool::new(false); + let result = + start_prepared_process_session_at(root, identity, &spec, &launch, fingerprint, || { + committed.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + }) + .expect("target exec failure is a known result"); + assert!(committed.load(std::sync::atomic::Ordering::SeqCst)); + assert_eq!(result.status, "failed"); + assert_eq!(result.sandbox_establishment, "established"); + assert_eq!(result.target_exec, "failed"); + assert_eq!( + result.launch_failure_kind.as_deref(), + Some("target-exec-failed") + ); + assert!(!result.needs_reconciliation); + assert_eq!(result.cursor, result.next_cursor); + assert!(!has_active_process_sessions_at(root).expect("known target failure is terminal")); + clear_process_session_registry_for_tests(); + } + + #[cfg(target_os = "linux")] + #[test] + fn process_session_start_audit_failure_terminates_and_persists_reconciliation() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "start-audit-project", "Start Audit Project") + .expect("initialize project"); + let spec = resolve_project_command_spec_at( + root, + "bash", + &[ + "-lc".to_string(), + "printf 'READY\\n'; while :; do sleep 1; done".to_string(), + ], + ".", + 30, + ) + .expect("resolve command"); + let identity = process_identity("start-audit-project"); + let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); + let started = start_process_session_at(root, identity, &spec, fingerprint).expect("start"); + mark_process_session_start_audit_failure_at( + root, + &started.process_id, + "forced agent db failure", + ) + .expect("mark start audit reconciliation"); + + let mut record = None; + for _ in 0..30 { + let current = read_process_session_record(root, &started.process_id) + .expect("read audit failure record") + .expect("audit failure record exists"); + if current.status == "needs-reconciliation" + && current.launch_failure_kind.as_deref() == Some("start-audit-failed") + { + record = Some(current); + break; + } + thread::sleep(Duration::from_millis(50)); + } + let record = record.expect("audit failure reconciliation persisted"); + assert!(record.needs_reconciliation); + assert!(!record.stdin_open); + assert_eq!(record.target_exec, "established"); + assert!(active_process_session_records_at(root, None, None) + .expect("audit failure blocks completion") + .iter() + .any(|value| value.process_id == started.process_id)); + clear_process_session_registry_for_tests(); + } + #[cfg(target_os = "linux")] #[test] fn process_session_descendants_inherit_workspace_sandbox() { @@ -2458,7 +4266,12 @@ setInterval(() => {}, 1000); sandbox_mode: "unknown".to_string(), network_access: "unknown".to_string(), sandbox_profile_version: "test-v1".to_string(), - status: "failed".to_string(), + sandbox_establishment: "unknown".to_string(), + target_exec: "unknown".to_string(), + launch_failure_kind: Some("launch-unknown".to_string()), + sandbox_ready_at: None, + exec_established_at: None, + status: "needs-reconciliation".to_string(), exit_code: None, signal: Some("output-read-failed".to_string()), stdin_open: false, @@ -2498,14 +4311,14 @@ setInterval(() => {}, 1000); .expect_err("reconciliation must block a new process session"); assert!(blocked.contains(process_id)); record.needs_reconciliation = false; - write_process_session_record(root, &record).expect("clear reconciliation record"); + write_process_session_record(root, &record).expect("write invalid reconciliation record"); assert!(active_process_session_records_at( root, Some("code-prototype"), Some("run-process-test") ) - .expect("read cleared reconciliation blockers") - .is_empty()); + .expect_err("invalid reconciliation record must fail closed") + .contains("可信 launch 状态组合无效")); clear_process_session_registry_for_tests(); } @@ -2543,7 +4356,7 @@ setInterval(() => {}, 1000); identity.run_id = format!("run-process-capacity-{index}"); identity.start_action_id = format!("action-process-capacity-{index}"); identity.start_action_fingerprint = format!("{}", index + 1).repeat(64); - let record = initial_process_session_record( + let mut record = initial_process_session_record( &identity, process_id, &format!("cmd-process-capacity-{index}"), @@ -2552,6 +4365,10 @@ setInterval(() => {}, 1000); &"f".repeat(64), "running", ); + record.sandbox_establishment = "established".to_string(); + record.target_exec = "established".to_string(); + record.sandbox_ready_at = Some(record.started_at); + record.exec_established_at = Some(record.started_at); write_process_session_record(root, &record).expect("write durable running record"); } @@ -2642,6 +4459,54 @@ process.stdin.resume(); clear_process_session_registry_for_tests(); } + #[test] + fn process_session_stdin_accepts_trusted_terminal_race_after_successful_eof() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "stdin-race-project", "Stdin Race Project") + .expect("initialize project"); + let spec = resolve_project_command_spec_at( + root, + "bash", + &[ + "-lc".to_string(), + "printf 'READY\\n'; while :; do sleep 1; done".to_string(), + ], + ".", + 30, + ) + .expect("resolve stdin race command"); + let identity = process_identity("stdin-race-project"); + let fingerprint = project_command_source_fingerprint(root).expect("fingerprint"); + let started = + start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start"); + let result = write_process_session_stdin_at_with_after_write( + root, + &identity, + &started.process_id, + "", + false, + true, + |live| { + let mut output = live.output.lock().expect("lock terminal race output"); + output.status = "exited".to_string(); + output.exit_code = Some(0); + output.stdin_open = false; + }, + ) + .expect("successful EOF remains successful after trusted terminal wins race"); + assert!(result.eof); + assert!(!result.stdin_open); + let record = read_process_session_record(root, &started.process_id) + .expect("read terminal race record") + .expect("terminal race record exists"); + assert_eq!(record.status, "exited"); + assert!(!record.needs_reconciliation); + clear_process_session_registry_for_tests(); + } + #[test] fn process_session_overlong_unterminated_line_is_stopped() { let _guard = process_session_test_guard(); @@ -2721,6 +4586,11 @@ process.stdin.resume(); sandbox_mode: "workspace-write".to_string(), network_access: "disabled".to_string(), sandbox_profile_version: "workspace-v1".to_string(), + sandbox_establishment: "established".to_string(), + target_exec: "established".to_string(), + launch_failure_kind: None, + sandbox_ready_at: Some(unix_timestamp()), + exec_established_at: Some(unix_timestamp()), status: "running".to_string(), exit_code: None, signal: None, @@ -2746,20 +4616,117 @@ process.stdin.resume(); .expect("record exists"); assert_eq!(record.status, "needs-reconciliation"); assert!(record.needs_reconciliation); + + let launching_process_id = "proc-abcdefabcdefabcdefabcdefabcdefab"; + let spec = resolve_project_command_spec_at( + root, + "npm", + &["run".to_string(), "dev".to_string()], + ".", + 30, + ) + .expect("resolve stale launching command"); + let mut launching = initial_process_session_record( + &identity, + launching_process_id, + "cmd-stale-launching", + &spec, + None, + &"d".repeat(64), + "launching", + ); + launching.owner_boot_id = "old-launching-boot".to_string(); + launching.sandbox_establishment = "established".to_string(); + launching.sandbox_ready_at = Some(launching.started_at); + write_process_session_record(root, &launching).expect("write stale launching record"); + let launching_poll = poll_process_session_at( + root, + &identity, + launching_process_id, + None, + Some(10), + Some(0), + ) + .expect("reconcile stale launching record"); + assert_eq!(launching_poll.status, "needs-reconciliation"); + assert_eq!(launching_poll.target_exec, "unknown"); + assert_eq!( + launching_poll.launch_failure_kind.as_deref(), + Some("launch-unknown") + ); + assert!(launching_poll.needs_reconciliation); + } + + #[cfg(target_os = "linux")] + #[test] + fn process_session_start_replay_reconciles_old_launching_record_once() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path(); + init_local_game_project_at(root, "stale-replay-project", "Stale Replay Project") + .expect("initialize project"); + let identity = process_identity("stale-replay-project"); + let spec = resolve_project_command_spec_at( + root, + "bash", + &["-lc".to_string(), "exit 0".to_string()], + ".", + 30, + ) + .expect("resolve stale replay command"); + let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch"); + let process_id = process_session_id(&identity); + let mut record = initial_process_session_record( + &identity, + &process_id, + "cmd-stale-replay", + &spec, + Some(&launch), + &"e".repeat(64), + "launching", + ); + record.owner_boot_id = "old-replay-boot".to_string(); + record.sandbox_establishment = "established".to_string(); + record.sandbox_ready_at = Some(record.started_at); + write_process_session_record(root, &record).expect("write stale replay record"); + + let callback_called = std::sync::atomic::AtomicBool::new(false); + let error = start_prepared_process_session_at( + root, + identity, + &spec, + &launch, + "e".repeat(64), + || { + callback_called.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + }, + ) + .expect_err("old launching replay must reconcile without relaunch"); + assert_eq!(error.stage(), ProjectCommandErrorStage::LaunchUnknown); + assert!(!callback_called.load(std::sync::atomic::Ordering::SeqCst)); + let reconciled = read_process_session_record(root, &process_id) + .expect("read replay reconciliation") + .expect("replay record exists"); + assert_eq!(reconciled.status, "needs-reconciliation"); + assert_eq!(reconciled.target_exec, "unknown"); + assert!(reconciled.exec_established_at.is_none()); + assert_eq!( + reconciled.launch_failure_kind.as_deref(), + Some("launch-unknown") + ); + assert!(reconciled.needs_reconciliation); + clear_process_session_registry_for_tests(); } #[cfg(target_os = "linux")] #[test] fn process_session_child_wrapper_fixture() { - let Some(argv) = std::env::var_os(PROCESS_SESSION_TEST_CHILD_ARGV_ENV) else { + if std::env::var_os(PROCESS_SESSION_BRIDGE_ENDPOINT_ENV).is_none() { return; - }; - let argv = serde_json::from_str::>(&argv.to_string_lossy()) - .expect("parse process session test child argv"); - assert!(!argv.is_empty(), "process session test child argv"); - let args = std::iter::once(PROCESS_SESSION_CHILD_MODE.to_string()) - .chain(argv) - .collect::>(); + } + let args = vec![PROCESS_SESSION_CHILD_MODE.to_string()]; match run_process_session_child(&args) { Ok(exit_code) => std::process::exit(exit_code), Err(error) => panic!("process session child wrapper failed: {error}"), diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs new file mode 100644 index 000000000..36ac074df --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs @@ -0,0 +1,874 @@ +#[cfg(target_os = "linux")] +mod linux { + use crate::command_sandbox::{ + command_sandbox_platform_metadata, stage_command_sandbox_launch_for_process_session, + CommandSandboxLaunch, + }; + use crate::command_sandbox_trampoline::{TargetExecState, TargetTerminalState}; + use crate::{ProjectCommandLaunchSpec, ProjectCommandSpec}; + use serde::{Deserialize, Serialize}; + use std::ffi::OsString; + use std::fs::File; + use std::io::{self, Read, Write}; + use std::os::fd::AsRawFd; + use std::os::linux::net::SocketAddrExt; + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + use std::os::unix::net::{SocketAddr, UnixListener, UnixStream}; + use std::path::PathBuf; + use std::process::{Command, Stdio}; + use std::thread; + use std::time::{Duration, Instant}; + + pub(crate) const PROCESS_SESSION_BRIDGE_ENDPOINT_ENV: &str = + "GENARRATIVE_PROCESS_SESSION_BRIDGE_ENDPOINT"; + pub(crate) const PROCESS_SESSION_BRIDGE_NONCE_ENV: &str = + "GENARRATIVE_PROCESS_SESSION_BRIDGE_NONCE"; + + const BRIDGE_NONCE_BYTES: usize = 32; + const BRIDGE_FRAME_MAX_BYTES: usize = 1024 * 1024; + const BRIDGE_ENDPOINT_MAX_BYTES: usize = 100; + + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct ProcessSessionLaunchPlan { + sandbox_executable: Vec, + sandbox_arguments: Vec>, + sandbox_cwd: Vec, + sandbox_environment: Vec<(Vec, Vec)>, + target_executable: Vec, + target_arguments: Vec>, + } + + impl ProcessSessionLaunchPlan { + fn from_launch( + launch: &ProjectCommandLaunchSpec, + spec: &ProjectCommandSpec, + ) -> Result { + if !launch.executable.is_absolute() + || !launch.cwd.is_absolute() + || !spec.executable.is_absolute() + { + return Err("process session bridge launch path 必须是绝对路径".to_string()); + } + Ok(Self { + sandbox_executable: launch.executable.as_os_str().as_bytes().to_vec(), + sandbox_arguments: launch + .arguments + .iter() + .map(|argument| argument.as_os_str().as_bytes().to_vec()) + .collect(), + sandbox_cwd: launch.cwd.as_os_str().as_bytes().to_vec(), + sandbox_environment: launch + .environment + .iter() + .map(|(name, value)| { + ( + name.as_os_str().as_bytes().to_vec(), + value.as_os_str().as_bytes().to_vec(), + ) + }) + .collect(), + target_executable: spec.executable.as_os_str().as_bytes().to_vec(), + target_arguments: crate::command_exec::project_command_actual_arguments(spec) + .into_iter() + .map(|argument| OsString::from(argument).into_vec()) + .collect(), + }) + } + + fn into_staged(self) -> Result { + let sandbox_executable = PathBuf::from(OsString::from_vec(self.sandbox_executable)); + let sandbox_cwd = PathBuf::from(OsString::from_vec(self.sandbox_cwd)); + let target_executable = PathBuf::from(OsString::from_vec(self.target_executable)); + if !sandbox_executable.is_absolute() + || !sandbox_cwd.is_absolute() + || !target_executable.is_absolute() + { + return Err("process session bridge launch path 必须是绝对路径".to_string()); + } + let launch = CommandSandboxLaunch { + executable: sandbox_executable, + arguments: self + .sandbox_arguments + .into_iter() + .map(OsString::from_vec) + .collect(), + cwd: sandbox_cwd, + environment: self + .sandbox_environment + .into_iter() + .map(|(name, value)| (OsString::from_vec(name), OsString::from_vec(value))) + .collect(), + metadata: command_sandbox_platform_metadata(), + }; + let target_arguments = self + .target_arguments + .into_iter() + .map(OsString::from_vec) + .collect::>(); + stage_command_sandbox_launch_for_process_session( + launch, + &target_executable, + &target_arguments, + ) + .map_err(|error| error.to_string()) + } + } + + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] + #[serde(tag = "kind", rename_all = "snake_case")] + enum BridgeFrame { + Hello { + nonce: Vec, + }, + Prepare { + nonce: Vec, + plan: ProcessSessionLaunchPlan, + }, + SandboxReady { + nonce: Vec, + }, + PrecommitFailed { + nonce: Vec, + failure_kind: String, + }, + CommitExec { + nonce: Vec, + }, + AbortLaunch { + nonce: Vec, + }, + ExecEstablished { + nonce: Vec, + }, + TargetExecFailed { + nonce: Vec, + errno: i32, + }, + LaunchUnknown { + nonce: Vec, + }, + TerminateTarget { + nonce: Vec, + }, + TargetExited { + nonce: Vec, + code: i32, + }, + TargetSignaled { + nonce: Vec, + signal: i32, + }, + TerminalUnknown { + nonce: Vec, + }, + } + + #[derive(Clone, Debug, Eq, PartialEq)] + pub(crate) enum ProcessSessionSandboxReadyVerdict { + Ready, + Failed { failure_kind: String }, + } + + #[derive(Clone, Debug, Eq, PartialEq)] + pub(crate) enum ProcessSessionExecVerdict { + Established, + TargetExecFailed { errno: i32 }, + LaunchUnknown, + } + + #[derive(Clone, Debug, Eq, PartialEq)] + pub(crate) enum ProcessSessionTerminalVerdict { + Exited { code: i32 }, + Signaled { signal: i32 }, + Unknown, + } + + pub(crate) struct ProcessSessionBridgeServer { + listener: UnixListener, + endpoint: String, + nonce: Vec, + nonce_hex: String, + } + + impl ProcessSessionBridgeServer { + pub(crate) fn bind() -> Result { + let nonce = random_nonce()?; + let nonce_hex = encode_hex(&nonce); + let endpoint = format!("genarrative-ps-{}-{nonce_hex}", unsafe { libc::getpid() }); + if endpoint.len() > BRIDGE_ENDPOINT_MAX_BYTES { + return Err("process session bridge endpoint 过长".to_string()); + } + let address = SocketAddr::from_abstract_name(endpoint.as_bytes()) + .map_err(|error| format!("创建 process session bridge 地址失败:{error}"))?; + let listener = UnixListener::bind_addr(&address) + .map_err(|error| format!("绑定 process session bridge 失败:{error}"))?; + set_close_on_exec(listener.as_raw_fd()) + .map_err(|error| format!("保护 process session bridge listener 失败:{error}"))?; + listener + .set_nonblocking(true) + .map_err(|error| format!("配置 process session bridge 失败:{error}"))?; + Ok(Self { + listener, + endpoint, + nonce, + nonce_hex, + }) + } + + pub(crate) fn endpoint(&self) -> &str { + &self.endpoint + } + + pub(crate) fn nonce_hex(&self) -> &str { + &self.nonce_hex + } + + pub(crate) fn accept( + &self, + expected_peer_pid: u32, + timeout: Duration, + ) -> Result { + let deadline = Instant::now() + timeout; + loop { + match self.listener.accept() { + Ok((mut stream, _)) => { + let (pid, uid) = peer_credentials(&stream)?; + if pid != expected_peer_pid || uid != unsafe { libc::geteuid() } { + continue; + } + set_close_on_exec(stream.as_raw_fd()).map_err(|error| { + format!("保护 process session bridge stream 失败:{error}") + })?; + let frame = read_frame_with_timeout(&mut stream, timeout)?; + if frame + != (BridgeFrame::Hello { + nonce: self.nonce.clone(), + }) + { + continue; + } + return Ok(ProcessSessionBridge { + stream, + nonce: self.nonce.clone(), + }); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => { + return Err(format!("接收 process session bridge 失败:{error}")); + } + } + if Instant::now() >= deadline { + return Err("等待 process session child bridge 超时".to_string()); + } + thread::sleep(Duration::from_millis(5)); + } + } + } + + #[derive(Debug)] + pub(crate) struct ProcessSessionBridge { + stream: UnixStream, + nonce: Vec, + } + + impl ProcessSessionBridge { + pub(crate) fn send_prepare( + &mut self, + launch: &ProjectCommandLaunchSpec, + spec: &ProjectCommandSpec, + ) -> Result<(), String> { + let plan = ProcessSessionLaunchPlan::from_launch(launch, spec)?; + write_frame( + &mut self.stream, + &BridgeFrame::Prepare { + nonce: self.nonce.clone(), + plan, + }, + ) + .map_err(|error| format!("发送 process session launch plan 失败:{error}")) + } + + pub(crate) fn wait_sandbox_ready( + &mut self, + timeout: Duration, + ) -> Result { + match read_frame_with_timeout(&mut self.stream, timeout)? { + BridgeFrame::SandboxReady { nonce } if nonce == self.nonce => { + Ok(ProcessSessionSandboxReadyVerdict::Ready) + } + BridgeFrame::PrecommitFailed { + nonce, + failure_kind, + } if nonce == self.nonce && valid_failure_kind(&failure_kind) => { + Ok(ProcessSessionSandboxReadyVerdict::Failed { failure_kind }) + } + _ => Err("process session sandbox-ready 帧无效".to_string()), + } + } + + pub(crate) fn commit_exec(&mut self) -> Result<(), String> { + write_frame( + &mut self.stream, + &BridgeFrame::CommitExec { + nonce: self.nonce.clone(), + }, + ) + .map_err(|error| format!("发送 process session commit-exec 失败:{error}")) + } + + pub(crate) fn abort_launch(&mut self) -> Result<(), String> { + write_frame( + &mut self.stream, + &BridgeFrame::AbortLaunch { + nonce: self.nonce.clone(), + }, + ) + .map_err(|error| format!("发送 process session abort-launch 失败:{error}")) + } + + pub(crate) fn wait_exec( + &mut self, + timeout: Duration, + ) -> Result { + match read_frame_with_timeout(&mut self.stream, timeout)? { + BridgeFrame::ExecEstablished { nonce } if nonce == self.nonce => { + Ok(ProcessSessionExecVerdict::Established) + } + BridgeFrame::TargetExecFailed { nonce, errno } if nonce == self.nonce => { + Ok(ProcessSessionExecVerdict::TargetExecFailed { errno }) + } + BridgeFrame::LaunchUnknown { nonce } if nonce == self.nonce => { + Ok(ProcessSessionExecVerdict::LaunchUnknown) + } + _ => Err("process session exec verdict 帧无效".to_string()), + } + } + + pub(crate) fn wait_terminal( + &mut self, + timeout: Duration, + ) -> Result { + match read_frame_with_timeout(&mut self.stream, timeout)? { + BridgeFrame::TargetExited { nonce, code } if nonce == self.nonce => { + Ok(ProcessSessionTerminalVerdict::Exited { code }) + } + BridgeFrame::TargetSignaled { nonce, signal } if nonce == self.nonce => { + Ok(ProcessSessionTerminalVerdict::Signaled { signal }) + } + BridgeFrame::TerminalUnknown { nonce } if nonce == self.nonce => { + Ok(ProcessSessionTerminalVerdict::Unknown) + } + _ => Err("process session terminal 帧无效".to_string()), + } + } + + pub(crate) fn terminate_target(&mut self) -> Result<(), String> { + write_frame( + &mut self.stream, + &BridgeFrame::TerminateTarget { + nonce: self.nonce.clone(), + }, + ) + .map_err(|error| format!("发送 process session target terminate 失败:{error}")) + } + } + + pub(crate) fn run_process_session_bridge_child( + expected_parent: libc::pid_t, + ) -> Result { + start_owner_monitor(expected_parent); + let endpoint = std::env::var(PROCESS_SESSION_BRIDGE_ENDPOINT_ENV) + .map_err(|_| "process session child 缺少 bridge endpoint".to_string())?; + let nonce_hex = std::env::var(PROCESS_SESSION_BRIDGE_NONCE_ENV) + .map_err(|_| "process session child 缺少 bridge nonce".to_string())?; + std::env::remove_var(PROCESS_SESSION_BRIDGE_ENDPOINT_ENV); + std::env::remove_var(PROCESS_SESSION_BRIDGE_NONCE_ENV); + let nonce = decode_nonce(&nonce_hex)?; + if endpoint.is_empty() + || endpoint.len() > BRIDGE_ENDPOINT_MAX_BYTES + || !endpoint.bytes().all(|byte| byte.is_ascii_graphic()) + { + return Err("process session bridge endpoint 无效".to_string()); + } + let address = SocketAddr::from_abstract_name(endpoint.as_bytes()) + .map_err(|error| format!("解析 process session bridge 地址失败:{error}"))?; + let mut stream = UnixStream::connect_addr(&address) + .map_err(|error| format!("连接 process session bridge 失败:{error}"))?; + set_close_on_exec(stream.as_raw_fd()) + .map_err(|error| format!("保护 process session child bridge 失败:{error}"))?; + write_frame( + &mut stream, + &BridgeFrame::Hello { + nonce: nonce.clone(), + }, + ) + .map_err(|error| format!("发送 process session bridge hello 失败:{error}"))?; + let plan = match read_frame_with_timeout(&mut stream, Duration::from_secs(3))? { + BridgeFrame::Prepare { + nonce: prepare_nonce, + plan, + } if prepare_nonce == nonce => plan, + _ => return Err("process session bridge prepare 帧无效".to_string()), + }; + let mut staged = match plan.into_staged() { + Ok(staged) => staged, + Err(error) => { + let _ = write_frame( + &mut stream, + &BridgeFrame::PrecommitFailed { + nonce, + failure_kind: "stage-failed".to_string(), + }, + ); + return Err(error); + } + }; + let mut command = Command::new(&staged.launch.executable); + command + .args(&staged.launch.arguments) + .current_dir(&staged.launch.cwd) + .env_clear() + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + for (name, value) in &staged.launch.environment { + command.env(name, value); + } + staged + .gate + .install_on_command(&mut command) + .map_err(|error| { + let _ = write_frame( + &mut stream, + &BridgeFrame::PrecommitFailed { + nonce: nonce.clone(), + failure_kind: "gate-install-failed".to_string(), + }, + ); + error + })?; + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + staged.gate.spawn_failed(); + let _ = write_frame( + &mut stream, + &BridgeFrame::PrecommitFailed { + nonce, + failure_kind: "sandbox-spawn-failed".to_string(), + }, + ); + return Err(format!("启动 process session sandbox 失败:{error}")); + } + }; + let ready = staged + .gate + .child_created() + .and_then(|()| staged.gate.wait_sandbox_ready(Duration::from_secs(3))); + if let Err(error) = ready { + let _ = abort_staged_child(&mut staged.gate, &mut child); + let _ = write_frame( + &mut stream, + &BridgeFrame::PrecommitFailed { + nonce, + failure_kind: "sandbox-ready-failed".to_string(), + }, + ); + return Err(error); + } + if let Err(error) = write_frame( + &mut stream, + &BridgeFrame::SandboxReady { + nonce: nonce.clone(), + }, + ) { + let _ = abort_staged_child(&mut staged.gate, &mut child); + return Err(format!("发送 process session sandbox-ready 失败:{error}")); + } + match read_frame(&mut stream) { + Ok(BridgeFrame::CommitExec { + nonce: commit_nonce, + }) if commit_nonce == nonce => {} + Ok(BridgeFrame::AbortLaunch { nonce: abort_nonce }) if abort_nonce == nonce => { + abort_staged_child(&mut staged.gate, &mut child)?; + return Ok(125); + } + Ok(_) => { + let _ = abort_staged_child(&mut staged.gate, &mut child); + return Err("process session bridge commit 帧无效".to_string()); + } + Err(error) => { + let _ = abort_staged_child(&mut staged.gate, &mut child); + return Err(format!("读取 process session bridge commit 失败:{error}")); + } + } + if staged.gate.commit_exec().is_err() { + let _ = abort_staged_child(&mut staged.gate, &mut child); + let _ = write_frame( + &mut stream, + &BridgeFrame::LaunchUnknown { + nonce: nonce.clone(), + }, + ); + return Ok(125); + } + match staged.gate.wait_target_exec(Duration::from_secs(3)) { + Ok(TargetExecState::Established) => { + write_frame( + &mut stream, + &BridgeFrame::ExecEstablished { + nonce: nonce.clone(), + }, + ) + .map_err(|error| { + let _ = child.kill(); + let _ = child.wait(); + format!("发送 process session exec-established 失败:{error}") + })?; + } + Ok(TargetExecState::Failed { errno }) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = write_frame(&mut stream, &BridgeFrame::TargetExecFailed { nonce, errno }); + return Ok(126); + } + Err(_) => { + let _ = abort_staged_child(&mut staged.gate, &mut child); + let _ = write_frame( + &mut stream, + &BridgeFrame::LaunchUnknown { + nonce: nonce.clone(), + }, + ); + return Ok(125); + } + } + + let status = loop { + if let Some(status) = child + .try_wait() + .map_err(|error| format!("检查 process session sandbox 失败:{error}"))? + { + break status; + } + let Some(frame) = read_frame_if_available(&mut stream, Duration::from_millis(25))? + else { + continue; + }; + match frame { + BridgeFrame::TerminateTarget { + nonce: terminate_nonce, + } if terminate_nonce == nonce => staged.gate.terminate_target()?, + _ => return Err("process session 运行期控制帧无效".to_string()), + } + }; + let terminal = staged.gate.wait_terminal(Duration::from_secs(2)); + match terminal { + Ok(TargetTerminalState::Exited { code }) => { + let _ = write_frame( + &mut stream, + &BridgeFrame::TargetExited { + nonce: nonce.clone(), + code, + }, + ); + } + Ok(TargetTerminalState::Signaled { signal }) => { + let _ = write_frame( + &mut stream, + &BridgeFrame::TargetSignaled { + nonce: nonce.clone(), + signal, + }, + ); + } + Err(_) => { + let _ = write_frame( + &mut stream, + &BridgeFrame::TerminalUnknown { + nonce: nonce.clone(), + }, + ); + } + } + Ok(status.code().unwrap_or(128)) + } + + fn start_owner_monitor(expected_parent: libc::pid_t) { + thread::spawn(move || loop { + if unsafe { libc::getppid() } != expected_parent { + unsafe { + libc::kill(0, libc::SIGKILL); + libc::_exit(125); + } + } + thread::sleep(Duration::from_millis(25)); + }); + } + + fn abort_staged_child( + gate: &mut crate::command_sandbox_trampoline::LaunchGate, + child: &mut std::process::Child, + ) -> Result<(), String> { + match gate.abort(child) { + Ok(()) => Ok(()), + Err(error) => { + let _ = child.kill(); + child + .wait() + .map(|_| ()) + .map_err(|wait_error| format!("{error};回收失败:{wait_error}")) + } + } + } + + fn valid_failure_kind(value: &str) -> bool { + matches!( + value, + "stage-failed" + | "gate-install-failed" + | "sandbox-spawn-failed" + | "sandbox-ready-failed" + ) + } + + fn peer_credentials(stream: &UnixStream) -> Result<(u32, u32), String> { + let mut credentials = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let mut length = std::mem::size_of::() as libc::socklen_t; + let result = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut credentials as *mut _ as *mut libc::c_void, + &mut length, + ) + }; + if result != 0 || length as usize != std::mem::size_of::() { + return Err(format!( + "读取 process session bridge peer credential 失败:{}", + io::Error::last_os_error() + )); + } + Ok(( + u32::try_from(credentials.pid) + .map_err(|_| "process session bridge peer pid 无效".to_string())?, + credentials.uid, + )) + } + + fn set_close_on_exec(fd: libc::c_int) -> io::Result<()> { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags < 0 { + return Err(io::Error::last_os_error()); + } + if unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + + fn random_nonce() -> Result, String> { + let mut nonce = vec![0_u8; BRIDGE_NONCE_BYTES]; + File::open("/dev/urandom") + .and_then(|mut file| file.read_exact(&mut nonce)) + .map_err(|error| format!("读取 process session bridge nonce 失败:{error}"))?; + Ok(nonce) + } + + fn encode_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + encoded + } + + fn decode_nonce(value: &str) -> Result, String> { + if value.len() != BRIDGE_NONCE_BYTES * 2 { + return Err("process session bridge nonce 长度无效".to_string()); + } + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(BRIDGE_NONCE_BYTES); + for index in (0..bytes.len()).step_by(2) { + let high = decode_hex_digit(bytes[index])?; + let low = decode_hex_digit(bytes[index + 1])?; + decoded.push((high << 4) | low); + } + Ok(decoded) + } + + fn decode_hex_digit(value: u8) -> Result { + match value { + b'0'..=b'9' => Ok(value - b'0'), + b'a'..=b'f' => Ok(value - b'a' + 10), + _ => Err("process session bridge nonce 不是小写十六进制".to_string()), + } + } + + fn write_frame(stream: &mut UnixStream, frame: &BridgeFrame) -> io::Result<()> { + let payload = serde_json::to_vec(frame) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if payload.is_empty() || payload.len() > BRIDGE_FRAME_MAX_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "process session bridge 帧大小无效", + )); + } + stream.write_all(&(payload.len() as u32).to_be_bytes())?; + stream.write_all(&payload)?; + stream.flush() + } + + fn read_frame(stream: &mut UnixStream) -> io::Result { + let mut length = [0_u8; 4]; + stream.read_exact(&mut length)?; + let length = u32::from_be_bytes(length) as usize; + if length == 0 || length > BRIDGE_FRAME_MAX_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "process session bridge 帧大小无效", + )); + } + let mut payload = vec![0_u8; length]; + stream.read_exact(&mut payload)?; + serde_json::from_slice(&payload) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) + } + + fn read_frame_if_available( + stream: &mut UnixStream, + timeout: Duration, + ) -> Result, String> { + let timeout_ms = i32::try_from(timeout.as_millis()).unwrap_or(i32::MAX); + let mut descriptor = libc::pollfd { + fd: stream.as_raw_fd(), + events: libc::POLLIN | libc::POLLHUP, + revents: 0, + }; + let result = unsafe { libc::poll(&mut descriptor, 1, timeout_ms) }; + if result < 0 { + return Err(format!( + "等待 process session 运行期控制失败:{}", + io::Error::last_os_error() + )); + } + if result == 0 { + return Ok(None); + } + if descriptor.revents & (libc::POLLERR | libc::POLLNVAL) != 0 { + return Err("process session 运行期控制通道失效".to_string()); + } + read_frame(stream) + .map(Some) + .map_err(|error| format!("读取 process session 运行期控制失败:{error}")) + } + + fn read_frame_with_timeout( + stream: &mut UnixStream, + timeout: Duration, + ) -> Result { + stream + .set_read_timeout(Some(timeout)) + .map_err(|error| format!("配置 process session bridge timeout 失败:{error}"))?; + let result = read_frame(stream) + .map_err(|error| format!("读取 process session bridge 帧失败:{error}")); + let reset = stream.set_read_timeout(None); + match (result, reset) { + (Ok(frame), Ok(())) => Ok(frame), + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => { + Err(format!("重置 process session bridge timeout 失败:{error}")) + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + fn send_test_frame(endpoint: String, frame: BridgeFrame) -> thread::JoinHandle<()> { + thread::spawn(move || { + let address = SocketAddr::from_abstract_name(endpoint.as_bytes()) + .expect("test bridge address"); + let mut stream = UnixStream::connect_addr(&address).expect("connect test bridge"); + write_frame(&mut stream, &frame).expect("write test bridge frame"); + }) + } + + #[test] + fn bridge_nonce_hex_round_trip_is_exact() { + let nonce = (0..BRIDGE_NONCE_BYTES as u8).collect::>(); + let encoded = encode_hex(&nonce); + assert_eq!(decode_nonce(&encoded).expect("decode nonce"), nonce); + assert!(decode_nonce(&encoded.to_uppercase()).is_err()); + } + + #[test] + fn bridge_failure_kinds_are_closed() { + assert!(valid_failure_kind("sandbox-ready-failed")); + assert!(!valid_failure_kind("arbitrary")); + } + + #[test] + fn bridge_accepts_only_exact_peer_and_nonce_hello() { + let server = ProcessSessionBridgeServer::bind().expect("bind bridge"); + let child = send_test_frame( + server.endpoint().to_string(), + BridgeFrame::Hello { + nonce: server.nonce.clone(), + }, + ); + server + .accept(std::process::id(), Duration::from_secs(1)) + .expect("accept exact peer hello"); + child.join().expect("join bridge child"); + } + + #[test] + fn bridge_rejects_wrong_nonce_and_out_of_order_frame() { + for frame in [ + BridgeFrame::Hello { + nonce: vec![0; BRIDGE_NONCE_BYTES], + }, + BridgeFrame::CommitExec { + nonce: vec![0; BRIDGE_NONCE_BYTES], + }, + ] { + let server = ProcessSessionBridgeServer::bind().expect("bind bridge"); + let child = send_test_frame(server.endpoint().to_string(), frame); + let error = server + .accept(std::process::id(), Duration::from_millis(80)) + .expect_err("reject invalid bridge hello"); + assert!(error.contains("超时"), "{error}"); + child.join().expect("join invalid bridge child"); + } + } + + #[test] + fn bridge_rejects_wrong_peer_pid() { + let server = ProcessSessionBridgeServer::bind().expect("bind bridge"); + let child = send_test_frame( + server.endpoint().to_string(), + BridgeFrame::Hello { + nonce: server.nonce.clone(), + }, + ); + let error = server + .accept(u32::MAX, Duration::from_millis(80)) + .expect_err("reject wrong peer pid"); + assert!(error.contains("超时"), "{error}"); + child.join().expect("join wrong peer child"); + } + } +} + +#[cfg(target_os = "linux")] +pub(crate) use linux::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 0ea0686c5..2efd14d97 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -30569,6 +30569,170 @@ async fn execute_approved_process_action_for_test( (pending, observation) } +#[cfg(target_os = "linux")] +async fn process_session_agent_runtime_start_audit_failure_fixture() { + const AGENT_ID: &str = "code-prototype"; + const RUN_ID: &str = "code-process-start-audit-failure-run"; + const TARGET_LOG: &str = "start-audit-target.log"; + clear_process_session_registry_for_tests(); + let root = unique_project_path(); + init_local_game_project_at( + &root, + "process-start-audit-project", + "Agent Runtime 启动审计失败项目", + ) + .expect("project init"); + let _cleanup = ProcessSessionIntegrationCleanup { + root: root.clone(), + agent_id: AGENT_ID, + run_id: RUN_ID, + }; + fs::write( + root.join("package.json"), + r#"{"private":true,"scripts":{"dev":"node process-start-audit-fixture.mjs"}}"#, + ) + .expect("write start audit package"); + fs::write( + root.join("process-start-audit-fixture.mjs"), + format!( + r#"import {{ appendFileSync }} from 'node:fs'; +appendFileSync('{TARGET_LOG}', 'STARTED\n'); +setInterval(() => appendFileSync('{TARGET_LOG}', 'TICK\n'), 20); +"# + ), + ) + .expect("write start audit fixture"); + fs::write( + root.join(PROJECT_PERMISSION_POLICY_PATH), + r#"{"deniedCommands":[],"confirmCommands":[],"agentPolicies":{}}"#, + ) + .expect("write legacy empty policy"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + AGENT_ID, + "验证 command.start 在 Agent DB 审计失败后终止 target", + RUN_ID, + "agent-background-task", + "准备启动审计失败 fixture", + vec!["启动并确认失败关闭".to_string()], + ) + .expect("start runtime"); + state.loop_iteration = 1; + let start_action = AgentRuntimeToolAction { + tool: "command.start".to_string(), + reason: Some("启动持续写入 fixture 并注入 Agent DB 审计失败".to_string()), + input: serde_json::json!({ + "program": "npm", + "args": ["run", "dev"], + "cwd": ".", + "timeoutSeconds": 30 + }), + }; + let start_pending = pending_tool_action_for_test( + &root, + &state, + start_action.clone(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + ); + write_game_creator_agent_runtime_pending_tool_action(&root, &start_pending) + .expect("write approved start action"); + state.status = "running".to_string(); + state.phase = "action".to_string(); + state.pending_tool_action = Some(start_pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append approved start task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write approved start state"); + write_game_creator_agent_runtime_tool_confirmation( + &root, + AGENT_ID, + RUN_ID, + "command.start", + &start_pending.action_fingerprint, + "确认启动 Agent DB 审计失败 fixture", + ) + .expect("write start confirmation"); + + let agent_db = root.join(".agent/agent.db"); + fs::remove_file(&agent_db).expect("remove agent db before start audit failure"); + fs::create_dir(&agent_db).expect("replace agent db with directory"); + + let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action( + &root, + AGENT_ID, + RUN_ID, + &state.current_task, + &start_action, + Some(&start_pending.action_id), + Some(&start_pending), + ) + .await; + + assert_eq!( + observation.status, "needs-reconciliation", + "{observation:?}" + ); + let detail: Value = serde_json::from_str( + observation + .detail + .as_deref() + .expect("start audit failure detail"), + ) + .expect("parse start audit failure detail"); + assert_eq!(detail["status"], "needs-reconciliation"); + assert_eq!(detail["needsReconciliation"], true); + assert_eq!(detail["launchFailureKind"], "start-audit-failed"); + assert_eq!(detail["sandboxEstablishment"], "established"); + assert_eq!(detail["targetExec"], "established"); + let process_id = detail["processId"] + .as_str() + .expect("start audit failure processId"); + + let record = active_process_session_records_at(&root, Some(AGENT_ID), Some(RUN_ID)) + .expect("read start audit failure record") + .into_iter() + .find(|record| record.process_id == process_id) + .expect("start audit failure process record"); + assert_eq!(record.status, "needs-reconciliation"); + assert!(record.needs_reconciliation); + assert!(!record.stdin_open); + assert_eq!( + record.launch_failure_kind.as_deref(), + Some("start-audit-failed") + ); + assert_eq!(record.sandbox_establishment, "established"); + assert_eq!(record.target_exec, "established"); + + let target_log = root.join(TARGET_LOG); + let mut previous_len = usize::MAX; + let mut stable_samples = 0; + for _ in 0..100 { + std::thread::sleep(Duration::from_millis(25)); + let current_len = fs::metadata(&target_log) + .map(|metadata| usize::try_from(metadata.len()).unwrap_or(usize::MAX)) + .unwrap_or(0); + if current_len == previous_len { + stable_samples += 1; + if stable_samples >= 8 { + break; + } + } else { + previous_len = current_len; + stable_samples = 0; + } + } + assert_eq!( + stable_samples, 8, + "business target kept writing after start audit failure" + ); + let stopped_content = fs::read(&target_log).unwrap_or_default(); + std::thread::sleep(Duration::from_millis(250)); + assert_eq!( + fs::read(&target_log).unwrap_or_default(), + stopped_content, + "business target resumed after start audit failure" + ); +} + async fn process_session_agent_runtime_confirmed_lifecycle_fixture() { const READY_SENTINEL: &str = "PROCESS_SESSION_READY_PRIVATE"; const STDIN_SENTINEL: &str = "PROCESS_SESSION_STDIN_PRIVATE"; @@ -30948,3 +31112,28 @@ fn process_session_agent_runtime_confirmed_lifecycle_runs_in_isolated_registry() .expect("spawn isolated process session integration test"); assert!(status.success(), "isolated lifecycle test failed: {status}"); } + +#[cfg(target_os = "linux")] +#[test] +fn process_session_agent_runtime_start_audit_failure_terminates_target_in_isolated_registry() { + const CHILD_MARKER: &str = + "GENARRATIVE_PROCESS_SESSION_AGENT_RUNTIME_START_AUDIT_FAILURE_CHILD"; + if std::env::var_os(CHILD_MARKER).is_some() { + tauri::async_runtime::block_on(process_session_agent_runtime_start_audit_failure_fixture()); + return; + } + let status = std::process::Command::new(std::env::current_exe().expect("current test binary")) + .env(CHILD_MARKER, "1") + .args([ + "--exact", + "tests::process_session_agent_runtime_start_audit_failure_terminates_target_in_isolated_registry", + "--nocapture", + "--test-threads=1", + ]) + .status() + .expect("spawn isolated process session start audit failure test"); + assert!( + status.success(), + "isolated start audit failure test failed: {status}" + ); +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a35040a5d..b0c681675 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4301,3 +4301,17 @@ - 决策:bwrap 使用 fd 4/5 接收 status/block,运行中 App 可执行文件由父进程预打开并通过 fd 6 + `--ro-bind-fd` 挂到固定 trampoline 路径。pre-exec 先把全部源复制到 64 以上临时 FD,再统一映射到固定号,避免并发时源/目标 FD 重叠导致通道被覆盖。 - 决策:bwrap COMMAND 分隔符固定取 launcher 插入的第一个独立 `--`,不能从目标 argv 末尾反查。durable commit 到 exec verdict 之间禁止 async await;commit 后协议/等待未知和执行后 command log、manifest、Agent DB、verification gate 落盘失败统一投影为 `needs-reconciliation`,只有明确 `TARGET_EXEC_FAILED` 可作为已知未 exec 的普通失败收束。 - 边界:本切片只完成 `command.exec / project.verify`。`command.start`、process record v3、PTY 零控制帧泄漏和真实 Provider process-session 仍未完成,不宣称 V1.11.1 已整体交付。 + +## 2026-07-14 AI 游戏创作 Agent Runtime V1.11.1 持久进程可信握手 + +- 决策:`command.start` 的 Runner 与 PTY child wrapper 使用 Linux abstract Unix socket 建立一次性私有 bridge,并同时校验 32 字节随机 nonce、`SO_PEERCRED` peer pid 和 uid。portable-pty argv 只保留内部 child mode,完整 bwrap/target launch plan 只走 bridge;endpoint、nonce、控制帧和宿主 launch plan 不进入 target argv/env、PTY transcript、process record、receipt 或 Agent DB。 +- 决策:portable-pty 会关闭 fd 3 以上描述符,bubblewrap 也不会把未被 option 引用的 fd 3 传给最终 COMMAND,因此 process-session trampoline 仍以 fd 0 接收私有 gate。真实 target 的 stdin 由 trampoline 校验 fd 1 是 PTY 后复制同一 slave 得到;一次性命令继续使用 `/dev/null` stdin,两个模式不能混用。 +- 决策:process record 升级为 v3,新增 `sandboxEstablishment / targetExec / launchFailureKind / sandboxReadyAt / execEstablishedAt`。Runtime 只在 `SANDBOX_READY` 后推进 revision、清除旧验证凭证并最后写入 `launching + established/not-attempted` commit record;只有 `EXEC_ESTABLISHED` 后才写 running、注册 live session、启动业务 timeout 并返回零消费 cursor。明确 `TARGET_EXEC_FAILED` 写同一 processId 的 failed record;commit 后未知写 `launch-unknown + needs-reconciliation`。 +- 决策:v1/v2 active record 无条件迁移为 `unknown / unknown + legacy-active-record + needs-reconciliation`,不按 PID 重连或自动重放;历史 terminal record 保留原终态和 transcript,可用 `unknown / unknown + legacy-record` 惰性读取。target exit 0/7 均沿原 processId 收束。 +- 决策:PTY wrapper spawn 前先登记 pending launch reservation;Runner shutdown 和 idle/final 门禁必须看见 reservation,pid 激活前收到 shutdown 也必须取消,激活后终止整个 wrapper 进程组。start Agent DB 审计失败必须终止 live process 并把 record 标为 `start-audit-failed + needs-reconciliation`。 +- 决策:process-session child 在 sandbox-ready 后只接受父侧显式 `COMMIT_EXEC / ABORT_LAUNCH`,不使用独立 3 秒 commit timeout。durable callback 慢于 launcher setup timeout 时 target 继续保持零执行;父侧失败必须先发送 abort,再 kill/wait/reap containment tree。 +- 决策:Linux process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,原子完成 setpgid + tcsetpgrp 并恢复信号掩码后才 exec,避免 immediate stdin read 以后台组停在 SIGTTIN。graceful terminate 经 Runtime bridge 和 trampoline 私有控制帧只向 target group 发送 SIGTERM;direct leader 先退出时 trampoline 仍检查同组后代,wrapper/bwrap 保持最多 800ms 宽限并继续承载 PTY,宽限后再强杀外层 containment group。Runner 强杀仍依赖 owner monitor 与 bwrap die-with-parent 回收整个 namespace。 +- 决策:process record v3 使用封闭 launch 状态矩阵和逐项时间校验。`launch-unknown / start-audit-failed` 必须对应 `needs-reconciliation=true`,明确 target exec failure 只能是 `established/failed`;旧 boot prepared/launching 降级 target 为 unknown,非法 record 读取失败关闭,不能绕过 final/idle。Windows legacy start 的 durable callback 移到首次 action miss 之后,同 action replay 只返回原 processId,不重复推进 revision。 +- 决策:`command.stdin` 写入和 flush 成功后,若 target 在 writer 释放后先形成可信 terminal,仍按成功返回并持久化 `stdinOpen=false`;只有写入部分失败或结果 record 无法落盘才进入 reconciliation。 +- 决策:active process session 事实由 live registry、durable active/reconciliation record 和 Linux pending reservation 并集构成。capacity、cancel、final 和 runner idle 都必须先合并 live registry;record 被删除或改名不能让 live session 失败开放,损坏 record 仍读取失败关闭。non-Linux live record 的 started/ready/exec 使用同一 launch 时间点,避免跨秒后违反 v3 时间顺序。 +- 边界:确定性 bridge、PTY、迁移、fast-exit、target-exec-failed 和零执行测试通过后,只能宣称本地 Runtime 链路完成;真实 Provider `process-session` 与 Runner kill 套件重新通过前,不新增 V1.11.1 Provider PASS 结论。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 4f76b69f1..4d0d26cb7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2917,3 +2917,27 @@ - 处理:构造器保证 bwrap options 与 COMMAND 之间只有第一个独立 `--` 是 launcher 分隔符;preflight 和 stage 都取第一个位置。不要从用户目标 argv 的末尾推断结构边界。 - 验证:真实 staged bwrap 用例必须让目标 argv 带独立 `--`,同时断言 sandbox-ready/commit 前 marker 为零,commit 后才执行成功。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs`、`command_exec.rs`。 + +## PTY 私有控制 FD 不能假设会被 bubblewrap 透传 + +- 现象:process-session wrapper 在 portable-pty 之后成功创建 fd 3 控制 socket,但 trampoline 收不到 ready/commit 帧;如果直接改用 fd 0,真实 target 又失去交互 stdin。 +- 原因:portable-pty 在 wrapper exec 前关闭全部 fd 3 以上描述符;wrapper 重新创建 fd 3 后,bubblewrap 仍只保留 stdio 和被 `--json-status-fd / --block-fd / --ro-bind-fd` 明确引用的描述符,未引用 fd 3 不是可靠 COMMAND 继承通道。 +- 处理:Runner 与 wrapper 先用 abstract Unix socket bridge 传递私有 launch plan;wrapper 内部 gate 继续通过 bwrap 会保留的 fd 0进入 trampoline。process-session trampoline 不把控制 fd 0传给 target,而是确认 fd 1 是 PTY 后复制同一 slave作为 target stdin;一次性命令模式仍显式使用 `/dev/null`。 +- 验证:真实 PTY 测试必须完成 readiness、stdin echo 和 terminate;trampoline 测试同时断言一次性 stdin 为 null、process-session stdin 可读 PTY,target fd 3/4/5/6 不存在,bridge endpoint/nonce/control frame 不出现在 transcript 和公共持久面。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs`、`process_session.rs`、`command_sandbox.rs`、`command_sandbox_trampoline.rs`。 + +## graceful terminate 不能先杀承载 target 的 wrapper 进程组 + +- 现象:target 注册了 SIGTERM 清理逻辑,但 `command.terminate` 只偶尔出现 stopped marker;耗时 300-500ms 的清理经常被提前截断。 +- 原因:如果先向 wrapper/bwrap/trampoline/target 共用的外层进程组发送 SIGTERM,wrapper 会先退出,bwrap 的 die-with-parent 随即收走 namespace;名义上的 800ms 宽限并没有真正留给 target。 +- 处理:process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 setpgid + PTY slave tcsetpgrp 并恢复信号掩码后才 exec;不能先 spawn 到后台组再由 parent 设前台,否则 target 可能已经因 immediate read 收到 SIGTTIN。Runtime 通过两级私有控制通道请求 trampoline 只向 target group 发 SIGTERM。direct leader 退出后 trampoline 继续检查同组后代,外层 wrapper/bwrap 在最多 800ms 宽限期保持存活,超时才强杀 containment group。 +- 验证:使用直接 bash target 启动同组后台子进程;leader 在输出 READY 后自然退出,仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs`、`process_session_bridge.rs`、`command_sandbox_trampoline.rs`。 + +## 启动记录必须封闭状态组合,child 不能自行猜 durable commit 超时 + +- 现象:慢磁盘让 sandbox-ready 后的 child 在父侧持久化完成前自行退出,父侧随后把零执行误记为 launch-unknown;损坏的 `failed + launch-unknown + needsReconciliation=false` record 又可能被 active/final/idle 扫描漏掉。 +- 原因:child 与父侧各自维护短 timeout,没有统一 commit/abort 决策;record 校验只检查枚举和值存在,没有约束 status、sandbox、target、failure、reconciliation 与时间戳的合法组合。Windows 如果在 action 去重前调用 durable callback,还会在同 action replay 时重复推进 revision。 +- 处理:sandbox-ready 后 child 阻塞等待父侧显式 commit 或 abort,父侧失败时发送 abort 并回收树。v3 读取使用封闭状态矩阵和 `started <= ready <= exec <= terminal <= updated` 的逐项可选时间校验;旧 boot prepared/launching 及同 action start replay转成 target unknown。非 Linux durable callback 只放在 existing action miss 分支,started/ready/exec 使用同一时间点。live registry 必须与 durable record、pending reservation 合并参与 capacity/final/idle,不能因 record 缺失失败开放。 +- 验证:durable callback 延迟超过旧 3 秒时 target marker 在 callback 内必须仍不存在、commit 后才出现;构造 launch-unknown/start-audit/target-exec 的非法组合均拒绝读取,旧 boot launching 和 same-action replay必须变成可再次读取的 reconciliation,同 action Windows 测试只调用一次 callback;删除 live record 后 final/idle 仍被 registry 阻断。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs`、`process_session_bridge.rs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 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 5ccf6e592..b46d106c3 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 @@ -585,7 +585,13 @@ V1.11.1 必须把 `prepared -> child-created -> sandbox-ready -> commit-persiste 2026-07-14 第一实现切片已接入一次性命令:Linux launcher 用 `--json-status-fd` 取得 child-created 后才写入 `--block-fd` 放行,运行中 App 可执行文件通过预打开 FD 和 `--ro-bind-fd` 固定挂入 sandbox;trampoline 的随机 nonce 控制帧只走一次性命令原本不用的 stdin socket,真实目标重新获得 `/dev/null` stdin。`command.exec` 与 `project.verify` 共用 `spawn -> child-created -> sandbox-ready -> durable callback -> commit-exec -> exec-established` launcher;revision / 旧验证凭证只在 sandbox-ready 后提交,业务 timeout 只在 exec-established 后开始,无效 `project.verify` 输入和 commit 前失败保留既有凭证。durable commit 到 exec verdict 之间不再出现 async cancellation point;commit 后协议未知、目标/输出等待异常和命令/Agent DB/gate 审计失败统一进入 `needs-reconciliation`,明确的 target exec failed 则记录 `established / failed / target-exec-failed` 并保留已提交状态。真实 bwrap marker、目标 argv 独立 `--`、EOF、错 nonce、target exec 失败、并发 FD 映射、工作区隔离、命令结果与验证门禁定向测试已通过。 -该切片不代表 V1.11.1 全部完成:`command.start` 仍使用 V1.11 的 PTY launch 与 process record v2。下一切片必须由 process child wrapper 在 PTY 外桥接同一控制协议并升级 record v3;在该链路和真实 Provider `process-session` 通过前,不能把持久进程描述为具备可信 exec-ready 原子保证。 +2026-07-14 第二实现切片已把 `command.start` 接入可信握手。Runner 在 portable-pty spawn 前预留 pending launch,并通过 Linux abstract Unix socket、随机 nonce 与 `SO_PEERCRED` 校验后的 child wrapper交换私有 launch plan和 ready/commit/exec/terminal 帧;wrapper argv 不再携带 bwrap/target plan,bridge FD 显式 CLOEXEC。由于 portable-pty 会关闭 fd 3 以上描述符且 bubblewrap 不可靠透传未引用 fd 3,process-session trampoline 仍以 fd 0承载私有 gate,再从已验证的 PTY fd 1复制 slave作为真实 target stdin;target stdout/stderr继续继承 PTY,一次性命令仍恢复 `/dev/null` stdin。 + +process record 已升级为 v3:只在 `SANDBOX_READY` 后执行 revision / verification durable mutation并最后写入 `launching + established/not-attempted + sandboxReadyAt`,`EXEC_ESTABLISHED` 后才写 running、注册 live session、启动 timeout和返回同一 processId 的零消费 cursor。target 不存在或无权限形成 `established/failed/target-exec-failed` terminal record,fast exit 0/7 继续沿同一 processId收束;commit 后未知、exec 后持久化失败和 start Agent DB 审计失败进入 reconciliation。v1/v2 active record 无条件迁移为 unknown + reconciliation,legacy terminal 和 v1/v2 transcript继续可读;pending reservation参与 shutdown、capacity和 idle/final门禁。v3 读取使用封闭状态矩阵和完整时间顺序校验,`launch-unknown / start-audit-failed` 必须 fail-closed 为 reconciliation;旧 boot 的 prepared / launching 及同 action start replay统一降级 target exec 为 unknown,不能通过伪造 failed record 绕过 final / idle。active/capacity/final/idle 扫描先合并 live registry 与 durable record;record 缺失不能让仍运行的 session 失败开放,损坏 record 仍读取失败关闭。 + +process-session child 在 `SANDBOX_READY` 后阻塞等待父侧显式 `COMMIT_EXEC / ABORT_LAUNCH`,不设置独立短 commit timeout;durable mutation 超过 3 秒仍保持 target 零执行,父侧失败时显式 abort 并回收 wrapper 树。target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 `setpgid + tcsetpgrp` 并恢复信号掩码后才 exec,避免目标以后台组立即读取 PTY 而停在 SIGTTIN。`command.terminate` 经 Runtime bridge 和 trampoline 私有控制帧只向 target group 发 SIGTERM;即使 direct leader 先退出,trampoline 仍在 800ms 宽限内等待同组后代完成清理,wrapper / bwrap 继续承载 PTY 与控制链,超时后才强杀外层 containment group。正常 EOF 写入成功后即使可信 terminal 先于状态落盘,也按成功返回而不是误标 reconciliation;Windows legacy start 的 durable callback 只在同 action首次创建时执行,ready/exec/started 使用同一时间点避免跨秒倒序。 + +本地确定性测试已覆盖真实 PTY stdin/echo/EOF/terminate、direct leader 先退出后同组后代完成 400ms SIGTERM 清理、3.2 秒慢 durable callback、workspace sandbox后代、commit callback失败目标零执行、target exec failure、fast exit 0/7、wrong nonce/peer/乱序、v1/v2 active/terminal迁移、v3 非法组合、旧 boot launching及同 action replay、live record 缺失门禁、start Agent DB 审计失败、start cursor零消费和控制材料不进入 transcript。该实现完成 V1.11.1 本地 Runtime 链路,但真实 Provider `process-session` 与 Runner kill 套件仍须按新协议重跑;在它们通过前,不新增 Provider PASS、统计条数或整体“已验收”结论。 2026-07-14 最新真实 `gpt-5.5` `llm-runtime` 已按新增 metadata 门禁通过:123 条 task、208 条 event、220 条 Agent DB、15 次成功工具执行、2 次 `command.exec`(先失败后成功)、1 次 `project.verify`、3 个隔离实例、双视口浏览器验证、唯一 completed / assistant;Runner 强杀后 run / session 身份稳定恢复,重复、副作用重放、密钥和诱饵泄漏均为 0。保留现场独立核对 2 条 command.exec 和 1 条 project.verify 审计均为 `bubblewrap / workspace-write / disabled / workspace-v1` 后按 sentinel 清理。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 0df6031b6..ef6f5ad35 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -34,6 +34,8 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod 2026-07-14 V1.11.1 第一切片:`command.exec / project.verify` 已共用受信任 trampoline launcher。bwrap 的 `child-pid` 只推进 child-created,`--block-fd` 放行后仍须收到 `SANDBOX_READY`;Runtime 完成 revision / verification durable callback 后才发送 `COMMIT_EXEC`,收到 `EXEC_ESTABLISHED` 后才计算业务 timeout。当前不把这套 stdin 私有控制通道用于 PTY;`command.start` 与 process record v3 仍是下一切片,相关链路完成前 V1.11.1 保持进行中。 +2026-07-14 V1.11.1 第二切片:`command.start` 已通过 PTY 外 abstract Unix socket bridge 接入同一 ready/commit/exec 状态机,process record 升级 v3;真实 target 的 PTY stdin 由 trampoline 复制已验证的 fd 1 slave,不向 target 泄漏 fd 0控制通道。pending launch和 live registry共同参与 capacity/shutdown/idle/final门禁,旧 v1/v2 active record迁入 reconciliation,target exec failure和fast exit保留同一 processId。child 等待父侧显式 commit/abort,不以短 timeout 猜测持久化失败;target 在 pre-exec 内原子进入 PTY 前台进程组,graceful terminate 只给 target group 发信号,direct leader 先退出后仍给同组后代保留清理宽限。v3 状态组合和时间顺序失败关闭,旧 boot launching及同 action replay降级为 launch-unknown,Windows 同 action replay 不重复执行 durable mutation且使用单一 launch 时间点。确定性测试通过后,V1.11.1 仍等待真实 Provider `process-session` 和 Runner kill复验,不提前标记整体已验收。 + 2026-07-12 真实验收:发布 AppData 中的真实 `gpt-5.5` 已通过最终安全收紧后的 `llm-runtime` 套件,覆盖 Runner 强杀恢复且 run/session 身份稳定、仓库上下文、checkpoint/精确修改、失败命令诊断与修复复验、6 套确认生命周期、项目验证、桌面与移动非空画布证据、3 个隔离实例并行和唯一 all-join;95 条 task、161 条 event、137 条 Agent DB、13 条合法工具协议、副作用判重、终态投影、assistant audit、消息、回执和密钥泄露均以结构化落盘事实验收。`full` 套件仍要求 External Editor API 配置,缺失时必须返回 `BLOCKED(editorApi)`,不得记为通过。 2026-07-13 V1.3 真实验收:同一真实 Provider 套件已改为先读取 SHA-256,再用唯一一次 `project.patchset` 同时更新和创建文件,并使用自动 checkpointId 读取 2 项内容 hunks;prepared / completed 审计各 1 条、patchset revision 增量为 1,Runner 强杀恢复、命令和项目验证、双视口浏览器验证、隔离 Agent join、重复副作用与密钥扫描继续全部通过。