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 be8f83e05..8bad5fa35 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -12236,30 +12236,23 @@ async fn observe_agent_runtime_command_exec( }; } }; - if let Err(error) = - prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "command.exec") - { - return agent_runtime_mutation_gate_failure_observation(root, "command.exec", &error); - } - let (revision, gate) = match begin_agent_runtime_project_verification_locked( - root, - agent_id, - run_id, - "command.exec", - ) { - Ok(state) => state, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "command.exec".to_string(), - status: "verification-failed".to_string(), - summary: "command.exec 无法清除旧验证凭证,命令未执行".to_string(), - detail: Some(format!( - "revisionAdvanced=true · {}", - redact_agent_runtime_project_paths(root, &error, 500) - )), - }; - } - }; + let command_launch_metadata = command_launch.clone(); + let staged_command_launch = + match stage_project_command_launch_spec(&command_spec, command_launch) { + Ok(staged) => staged, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: "failed".to_string(), + summary: "command.exec 沙箱启动闸门准备失败,命令未执行".to_string(), + detail: Some(redact_agent_runtime_project_paths( + root, + error.message(), + 500, + )), + }; + } + }; let output_identity = action_id @@ -12272,13 +12265,33 @@ async fn observe_agent_runtime_command_exec( action_id: action_id.to_string(), action_fingerprint: action_fingerprint.to_string(), }); + 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.exec", &error); + } + }; + let mut verification_state = None; let result = run_prepared_project_command_with_output_at( root, &command_spec, - &command_launch, + staged_command_launch, output_identity, + || { + prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "command.exec")?; + verification_state = Some(begin_agent_runtime_project_verification_locked( + root, + agent_id, + run_id, + "command.exec", + )?); + Ok(()) + }, ) .await; + 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 args_json = serde_json::to_vec(&input.args).unwrap_or_default(); let args_sha256 = format!("{:x}", Sha256::digest(&args_json)); let audit_result = match &result { @@ -12327,10 +12340,10 @@ async fn observe_agent_runtime_command_exec( "argsCount": input.args.len(), "cwd": input.cwd, "verificationEligible": verification_eligible, - "sandboxBackend": command_launch.sandbox_backend, - "sandboxMode": command_launch.sandbox_mode, - "networkAccess": command_launch.network_access, - "sandboxProfileVersion": command_launch.sandbox_profile_version, + "sandboxBackend": command_launch_metadata.sandbox_backend, + "sandboxMode": command_launch_metadata.sandbox_mode, + "networkAccess": command_launch_metadata.network_access, + "sandboxProfileVersion": command_launch_metadata.sandbox_profile_version, "status": if error.execution_started() { "execution-unknown" } else { @@ -12352,8 +12365,12 @@ async fn observe_agent_runtime_command_exec( .as_ref() .map(|_| true) .unwrap_or_else(|error| error.execution_started()); - let gate_result = - finish_agent_runtime_project_verification_locked(root, &revision, gate, passed); + let gate_result = match verification_state { + Some((revision, gate)) => { + finish_agent_runtime_project_verification_locked(root, &revision, gate, passed) + } + None => Ok(()), + }; if let Err(error) = audit_result { let gate_error = gate_result.err().map(|gate_error| { @@ -12376,7 +12393,7 @@ async fn observe_agent_runtime_command_exec( "command.exec 未启动,失败诊断也无法写入 Agent DB".to_string() }, detail: Some(format!( - "revisionAdvanced=true · verificationEligible={verification_eligible} · {}{}", + "revisionAdvanced={revision_advanced} · verificationEligible={verification_eligible} · {}{}", redact_agent_runtime_project_paths(root, &error, 500), gate_error.unwrap_or_default(), )), @@ -12425,7 +12442,7 @@ async fn observe_agent_runtime_command_exec( "{} 执行期间修改了受保护源码,验证结果无效", command.command_id ), - detail: Some(format!("revisionAdvanced=true · {detail}")), + detail: Some(format!("revisionAdvanced={revision_advanced} · {detail}")), } } else if command.status == "completed" { AgentRuntimeToolObservation { @@ -12470,7 +12487,7 @@ async fn observe_agent_runtime_command_exec( "command.exec 未启动".to_string() }, detail: Some(format!( - "revisionAdvanced=true · verificationEligible={verification_eligible} · {}", + "revisionAdvanced={revision_advanced} · verificationEligible={verification_eligible} · {}", redact_agent_runtime_project_paths(root, error.message(), 500) )), } @@ -13232,7 +13249,7 @@ pub(crate) fn observe_agent_runtime_limited_command( } } -async fn observe_agent_runtime_project_verify( +pub(crate) async fn observe_agent_runtime_project_verify( root: &Path, agent_id: &str, run_id: &str, @@ -13271,38 +13288,30 @@ async fn observe_agent_runtime_project_verify( }; } }; - let (revision, gate) = match begin_agent_runtime_project_verification_locked( - root, - agent_id, - run_id, - "project.verify", - ) { - Ok(state) => state, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "project.verify".to_string(), - status: "failed".to_string(), - summary: "project.verify 无法清除旧验证凭证".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }; - } - }; if let Some(error) = validation_error { - let gate_error = - finish_agent_runtime_project_verification_locked(root, &revision, gate, false).err(); return AgentRuntimeToolObservation { tool: "project.verify".to_string(), status: "failed".to_string(), summary: error, - detail: gate_error.map(|error| redact_agent_runtime_project_paths(root, &error, 500)), + detail: None, }; } let timeout_seconds = timeout_seconds.expect("validated timeoutSeconds"); - let result = run_project_verification_at( + let mut verification_state = None; + let result = run_project_verification_with_commit_at( root, script.as_str(), expected_command.as_str(), timeout_seconds, + || { + verification_state = Some(begin_agent_runtime_project_verification_locked( + root, + agent_id, + run_id, + "project.verify", + )?); + Ok(()) + }, ) .await .and_then(|verification| { @@ -13333,6 +13342,9 @@ async fn observe_agent_runtime_project_verify( "sandboxMode": verification.sandbox_mode, "networkAccess": verification.network_access, "sandboxProfileVersion": verification.sandbox_profile_version, + "sandboxEstablishment": verification.sandbox_establishment, + "targetExec": verification.target_exec, + "launchFailureKind": verification.launch_failure_kind, "logPath": verification.log_path, "output": audit_output, }), @@ -13342,13 +13354,27 @@ async fn observe_agent_runtime_project_verify( let passed = result .as_ref() .is_ok_and(|verification| verification.status == "completed"); - if let Err(error) = - finish_agent_runtime_project_verification_locked(root, &revision, gate, passed) - { + let verification_started = verification_state.is_some(); + let gate_result = match verification_state { + Some((revision, gate)) => { + finish_agent_runtime_project_verification_locked(root, &revision, gate, passed) + } + None => Ok(()), + }; + if let Err(error) = gate_result { return AgentRuntimeToolObservation { tool: "project.verify".to_string(), - status: "failed".to_string(), - summary: "project.verify 结果无法形成有效验证凭证".to_string(), + status: if verification_started { + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + } else { + "failed" + } + .to_string(), + summary: if verification_started { + "project.verify 已执行,但验证凭证无法完整落盘".to_string() + } else { + "project.verify 结果无法形成有效验证凭证".to_string() + }, detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), }; } @@ -13360,11 +13386,14 @@ async fn observe_agent_runtime_project_verify( AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS, ); let detail = format!( - "sandboxBackend={} · sandboxMode={} · networkAccess={} · sandboxProfileVersion={} · {output_tail}", + "sandboxBackend={} · sandboxMode={} · networkAccess={} · sandboxProfileVersion={} · sandboxEstablishment={} · targetExec={} · launchFailureKind={} · {output_tail}", verification.sandbox_backend, verification.sandbox_mode, verification.network_access, verification.sandbox_profile_version, + verification.sandbox_establishment, + verification.target_exec, + verification.launch_failure_kind.as_deref().unwrap_or("none"), ); if verification.status == "completed" { AgentRuntimeToolObservation { @@ -13389,12 +13418,25 @@ async fn observe_agent_runtime_project_verify( } } } - Err(error) => AgentRuntimeToolObservation { - tool: "project.verify".to_string(), - status: "failed".to_string(), - summary: redact_agent_runtime_project_paths(root, &error, 240), - detail: None, - }, + Err(error) => { + let needs_reconciliation = verification_started; + AgentRuntimeToolObservation { + tool: "project.verify".to_string(), + status: if needs_reconciliation { + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + } else { + "failed" + } + .to_string(), + summary: if needs_reconciliation { + "project.verify 执行或审计结果不完整,需要人工核对".to_string() + } else { + redact_agent_runtime_project_paths(root, &error, 240) + }, + detail: needs_reconciliation + .then(|| redact_agent_runtime_project_paths(root, &error, 500)), + } + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index 733750d49..a34566216 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -5,6 +5,9 @@ use std::ffi::{OsStr, OsString}; use std::process::Stdio; use tokio::io::AsyncReadExt; +#[cfg(target_os = "linux")] +use crate::command_sandbox_trampoline::{LaunchGate, TargetExecState, TargetTerminalState}; + const PROJECT_COMMAND_MAX_ARGUMENTS: usize = 64; const PROJECT_COMMAND_MAX_ARGUMENT_CHARS: usize = 512; const PROJECT_COMMAND_MAX_ARGUMENT_BYTES: usize = 8 * 1024; @@ -111,6 +114,20 @@ pub(crate) struct ProjectCommandLaunchSpec { pub(crate) sandbox_profile_version: String, } +#[derive(Debug)] +pub(crate) struct StagedProjectCommandLaunchSpec { + pub(crate) launch: ProjectCommandLaunchSpec, + #[cfg(target_os = "linux")] + pub(crate) gate: LaunchGate, +} + +#[derive(Debug)] +pub(crate) struct EstablishedProjectCommand { + pub(crate) child: tokio::process::Child, + #[cfg(target_os = "linux")] + pub(crate) gate: LaunchGate, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ProjectCommandResult { pub(crate) command_id: String, @@ -143,6 +160,9 @@ pub(crate) enum ProjectCommandErrorStage { Validation, Preflight, Spawn, + DurableCommit, + TargetExec, + LaunchUnknown, Execution, PostExecutionFingerprint, OutputSidecar, @@ -157,7 +177,7 @@ pub(crate) struct ProjectCommandError { } impl ProjectCommandError { - fn new(stage: ProjectCommandErrorStage, message: impl Into) -> Self { + pub(crate) fn new(stage: ProjectCommandErrorStage, message: impl Into) -> Self { Self { stage, message: sanitize_project_verification_output(&message.into()), @@ -175,7 +195,8 @@ impl ProjectCommandError { pub(crate) fn execution_started(&self) -> bool { matches!( self.stage, - ProjectCommandErrorStage::Execution + ProjectCommandErrorStage::LaunchUnknown + | ProjectCommandErrorStage::Execution | ProjectCommandErrorStage::PostExecutionFingerprint | ProjectCommandErrorStage::OutputSidecar | ProjectCommandErrorStage::AuditLog @@ -194,6 +215,9 @@ impl ProjectCommandErrorStage { Self::Validation => "validation", Self::Preflight => "preflight", Self::Spawn => "spawn", + Self::DurableCommit => "durable-commit", + Self::TargetExec => "target-exec", + Self::LaunchUnknown => "launch-unknown", Self::Execution => "execution", Self::PostExecutionFingerprint => "post-execution-fingerprint", Self::OutputSidecar => "output-sidecar", @@ -1235,6 +1259,54 @@ pub(crate) fn prepare_project_command_launch_spec( } } +pub(crate) fn stage_project_command_launch_spec( + spec: &ProjectCommandSpec, + launch: ProjectCommandLaunchSpec, +) -> Result { + #[cfg(target_os = "linux")] + { + let target_arguments = project_command_actual_arguments(spec) + .into_iter() + .map(OsString::from) + .collect::>(); + let staged = stage_command_sandbox_launch( + CommandSandboxLaunch { + executable: launch.executable, + arguments: launch.arguments, + cwd: launch.cwd, + environment: launch.environment, + metadata: command_sandbox_platform_metadata(), + }, + &spec.executable, + &target_arguments, + ) + .map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + format!("command.exec sandbox staged launch 失败:{error}"), + ) + })?; + Ok(StagedProjectCommandLaunchSpec { + launch: ProjectCommandLaunchSpec { + executable: staged.launch.executable, + arguments: staged.launch.arguments, + cwd: staged.launch.cwd, + environment: staged.launch.environment, + sandbox_backend: staged.launch.metadata.backend.to_string(), + sandbox_mode: staged.launch.metadata.mode.to_string(), + network_access: staged.launch.metadata.network.to_string(), + sandbox_profile_version: staged.launch.metadata.profile_version.to_string(), + }, + gate: staged.gate, + }) + } + #[cfg(not(target_os = "linux"))] + { + let _ = spec; + Ok(StagedProjectCommandLaunchSpec { launch }) + } +} + fn configure_project_command_process_group(command: &mut tokio::process::Command) { #[cfg(unix)] { @@ -1251,20 +1323,173 @@ fn configure_project_command_process_group(command: &mut tokio::process::Command } } +pub(crate) async fn spawn_staged_project_command( + staged: StagedProjectCommandLaunchSpec, + durable_commit: F, +) -> Result +where + F: FnOnce() -> Result<(), String>, +{ + #[cfg(not(target_os = "linux"))] + durable_commit().map_err(|error| { + ProjectCommandError::new(ProjectCommandErrorStage::DurableCommit, error) + })?; + + #[cfg(target_os = "linux")] + let mut staged = staged; + + let mut command = tokio::process::Command::new(&staged.launch.executable); + command + .args(&staged.launch.arguments) + .current_dir(&staged.launch.cwd) + .env_clear() + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + for (name, value) in &staged.launch.environment { + command.env(name, value); + } + configure_project_command_process_group(&mut command); + #[cfg(target_os = "linux")] + staged + .gate + .install_on_command(command.as_std_mut()) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; + + let child = match command.spawn() { + Ok(child) => child, + Err(error) => { + #[cfg(target_os = "linux")] + staged.gate.spawn_failed(); + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Spawn, + format!("启动 staged project command 失败:{error}"), + )); + } + }; + + #[cfg(target_os = "linux")] + { + let mut child = child; + let ready_task = tokio::task::spawn_blocking(move || { + let mut gate = staged.gate; + let ready = gate + .child_created() + .and_then(|()| gate.wait_sandbox_ready(Duration::from_secs(3))); + (gate, ready) + }) + .await; + let (mut gate, ready) = match ready_task { + Ok(result) => result, + Err(error) => { + let termination = terminate_project_command_process_group(&mut child).await; + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + project_command_launch_error_with_termination( + format!("等待 sandbox-ready 任务失败:{error}"), + termination, + ), + )); + } + }; + if let Err(error) = ready { + let termination = terminate_project_command_process_group(&mut child).await; + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + project_command_launch_error_with_termination(error, termination), + )); + } + if let Err(error) = durable_commit() { + let termination = terminate_project_command_process_group(&mut child).await; + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::DurableCommit, + project_command_launch_error_with_termination(error, termination), + )); + } + if let Err(error) = gate.commit_exec() { + let termination = terminate_project_command_process_group_after_commit(&mut child); + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::LaunchUnknown, + project_command_launch_error_with_termination(error, termination), + )); + } + // No await is allowed between durable commit and the exec verdict. A + // cancelled future must not erase the launch-unknown decision window. + let exec = gate.wait_target_exec(Duration::from_secs(3)); + match exec { + Ok(TargetExecState::Established) => Ok(EstablishedProjectCommand { child, gate }), + Ok(TargetExecState::Failed { errno }) => { + let termination = terminate_project_command_process_group_after_commit(&mut child); + Err(ProjectCommandError::new( + ProjectCommandErrorStage::TargetExec, + project_command_launch_error_with_termination( + format!("command target exec 失败:errno={errno}"), + termination, + ), + )) + } + Err(error) => { + let termination = terminate_project_command_process_group_after_commit(&mut child); + Err(ProjectCommandError::new( + ProjectCommandErrorStage::LaunchUnknown, + project_command_launch_error_with_termination(error, termination), + )) + } + } + } + #[cfg(not(target_os = "linux"))] + { + Ok(EstablishedProjectCommand { child }) + } +} + +fn project_command_launch_error_with_termination( + message: impl Into, + termination: Result, +) -> String { + match termination { + Ok(summary) => format!("{};{summary}", message.into()), + Err(error) => format!("{};进程终止与回收未确认:{error}", message.into()), + } +} + +#[cfg(target_os = "linux")] +pub(crate) async fn wait_established_project_command_terminal( + mut gate: LaunchGate, +) -> Result { + tokio::task::spawn_blocking(move || gate.wait_terminal(Duration::from_secs(2))) + .await + .map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + format!("等待 command target terminal 任务失败:{error}"), + ) + })? + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error)) +} + +#[cfg(unix)] +fn request_unix_project_command_process_group_termination( + process_id: u32, +) -> Result<&'static str, String> { + let result = unsafe { libc::kill(-(process_id as i32), libc::SIGKILL) }; + if result == 0 { + return Ok("已请求终止受控进程组"); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + return Ok("受控进程组已不存在"); + } + Err(format!("请求终止受控进程组失败:{error}")) +} + async fn request_project_command_process_group_termination( process_id: u32, ) -> Result<&'static str, String> { #[cfg(unix)] { - let result = unsafe { libc::kill(-(process_id as i32), libc::SIGKILL) }; - if result == 0 { - return Ok("已请求终止受控进程组"); - } - let error = std::io::Error::last_os_error(); - if error.raw_os_error() == Some(libc::ESRCH) { - return Ok("受控进程组已不存在"); - } - return Err(format!("请求终止受控进程组失败:{error}")); + return request_unix_project_command_process_group_termination(process_id); } #[cfg(windows)] { @@ -1329,6 +1554,43 @@ async fn terminate_project_command_process_group( )) } +#[cfg(target_os = "linux")] +fn terminate_project_command_process_group_after_commit( + child: &mut tokio::process::Child, +) -> Result { + let process_id = child + .id() + .ok_or_else(|| "请求终止受控进程组失败:子进程缺少 pid".to_string())?; + let group_result = request_unix_project_command_process_group_termination(process_id); + let child_kill_error = child.start_kill().err(); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + let wait_result = loop { + match child.try_wait() { + Ok(Some(status)) => break Ok(status), + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(5)); + } + Ok(None) => break Err("同步等待主进程退出超时".to_string()), + Err(error) => break Err(format!("同步等待主进程退出失败:{error}")), + } + }; + if let Err(error) = &group_result { + let fallback = match (&child_kill_error, &wait_result) { + (_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(), + (Some(kill_error), Err(wait_error)) => { + format!("主进程兜底终止失败:{kill_error};{wait_error}") + } + (None, Err(wait_error)) => wait_error.clone(), + }; + return Err(format!("{error};{fallback}")); + } + wait_result?; + Ok(format!( + "{}并完成主进程回收", + group_result.expect("group termination result checked") + )) +} + async fn read_bounded_project_command_output( mut reader: R, ) -> Result @@ -1465,30 +1727,18 @@ pub(crate) fn project_command_source_fingerprint(root: &Path) -> Result( spec: &ProjectCommandSpec, - launch: &ProjectCommandLaunchSpec, -) -> Result { - let mut command = tokio::process::Command::new(&launch.executable); - command - .args(&launch.arguments) - .current_dir(&launch.cwd) - .env_clear() - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - for (name, value) in &launch.environment { - command.env(name, value); - } - configure_project_command_process_group(&mut command); - - let mut child = command.spawn().map_err(|error| { - ProjectCommandError::new( - ProjectCommandErrorStage::Spawn, - format!("启动 command.exec {} 失败:{error}", spec.program), - ) - })?; + staged: StagedProjectCommandLaunchSpec, + durable_commit: F, +) -> Result +where + F: FnOnce() -> Result<(), String>, +{ + let established = spawn_staged_project_command(staged, durable_commit).await?; + let mut child = established.child; + #[cfg(target_os = "linux")] + let gate = established.gate; #[cfg(unix)] let process_id = child.id(); let stdout = match child.stdout.take() { @@ -1520,6 +1770,8 @@ async fn run_project_command_process( let wait = tokio::time::timeout(Duration::from_secs(spec.timeout_seconds), child.wait()).await; let (exit_code, timed_out, termination_summary) = match wait { Ok(Ok(status)) => { + #[cfg(target_os = "linux")] + let _terminal = wait_established_project_command_terminal(gate).await?; #[cfg(unix)] if let Some(process_id) = process_id { if let Err(error) = @@ -1645,19 +1897,26 @@ pub(crate) async fn run_project_command_with_output_at( ) -> Result { let spec = resolve_project_command_spec_at(root, program, arguments, cwd, timeout_seconds)?; let launch = prepare_project_command_launch_spec(root, &spec)?; - run_prepared_project_command_with_output_at(root, &spec, &launch, output_identity).await + let staged = stage_project_command_launch_spec(&spec, launch)?; + run_prepared_project_command_with_output_at(root, &spec, staged, output_identity, || Ok(())) + .await } -pub(crate) async fn run_prepared_project_command_with_output_at( +pub(crate) async fn run_prepared_project_command_with_output_at( root: &Path, spec: &ProjectCommandSpec, - launch: &ProjectCommandLaunchSpec, + staged: StagedProjectCommandLaunchSpec, output_identity: Option, -) -> Result { + durable_commit: F, +) -> Result +where + F: FnOnce() -> Result<(), String>, +{ + let launch_metadata = staged.launch.clone(); let source_fingerprint_before = project_command_source_fingerprint(root) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; let started_at = std::time::Instant::now(); - let process = run_project_command_process(spec, launch).await?; + let process = run_project_command_process(spec, staged, durable_commit).await?; let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); let source_fingerprint_after = project_command_source_fingerprint(root).map_err(|error| { ProjectCommandError::new( @@ -1732,10 +1991,10 @@ pub(crate) async fn run_prepared_project_command_with_output_at( duration_ms, source_changed, spec.verification_eligible, - launch.sandbox_backend, - launch.sandbox_mode, - launch.network_access, - launch.sandbox_profile_version, + launch_metadata.sandbox_backend, + launch_metadata.sandbox_mode, + launch_metadata.network_access, + launch_metadata.sandbox_profile_version, process.output, ); fs::OpenOptions::new() @@ -1788,10 +2047,10 @@ pub(crate) async fn run_prepared_project_command_with_output_at( source_fingerprint_after, source_changed, verification_eligible: spec.verification_eligible, - 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_backend: launch_metadata.sandbox_backend, + sandbox_mode: launch_metadata.sandbox_mode, + network_access: launch_metadata.network_access, + sandbox_profile_version: launch_metadata.sandbox_profile_version, log_path: log_path.to_string_lossy().into_owned(), updated_at, }) @@ -1801,6 +2060,40 @@ pub(crate) async fn run_prepared_project_command_with_output_at( mod tests { use super::*; + #[cfg(target_os = "linux")] + #[tokio::test] + async fn staged_launcher_never_commits_before_sandbox_ready() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + let committed = Arc::new(AtomicBool::new(false)); + let committed_for_callback = Arc::clone(&committed); + let staged = StagedProjectCommandLaunchSpec { + launch: ProjectCommandLaunchSpec { + executable: PathBuf::from("/usr/bin/false"), + arguments: Vec::new(), + cwd: PathBuf::from("/"), + environment: Vec::new(), + sandbox_backend: "bubblewrap".to_string(), + sandbox_mode: "workspace-write".to_string(), + network_access: "disabled".to_string(), + sandbox_profile_version: "workspace-v1".to_string(), + }, + gate: LaunchGate::new_for_sandbox_stdin(Path::new("/usr/bin/true"), &[]) + .expect("create staged launch gate"), + }; + + let error = spawn_staged_project_command(staged, move || { + committed_for_callback.store(true, Ordering::SeqCst); + Ok(()) + }) + .await + .expect_err("launcher without bwrap status must fail before ready"); + + assert_eq!(error.stage(), ProjectCommandErrorStage::Preflight); + assert!(!committed.load(Ordering::SeqCst)); + } + fn command_project(name: &str) -> tempfile::TempDir { let dir = tempfile::Builder::new() .prefix(&format!("game-creator-command-{name}-")) 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 a6152d850..e923f1f73 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 @@ -4,6 +4,9 @@ use std::ffi::OsString; use std::fmt; use std::path::{Path, PathBuf}; +#[cfg(target_os = "linux")] +use crate::command_sandbox_trampoline::LaunchGate; + #[cfg(target_os = "linux")] const COMMAND_SANDBOX_PROFILE_VERSION: &str = "workspace-v1"; #[cfg(target_os = "linux")] @@ -56,6 +59,13 @@ pub(crate) struct CommandSandboxLaunch { pub(crate) metadata: CommandSandboxMetadata, } +#[cfg(target_os = "linux")] +#[derive(Debug)] +pub(crate) struct StagedCommandSandboxLaunch { + pub(crate) launch: CommandSandboxLaunch, + pub(crate) gate: LaunchGate, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CommandSandboxError { message: String, @@ -119,9 +129,24 @@ pub(crate) fn prepare_command_sandbox_launch( } } +#[cfg(target_os = "linux")] +pub(crate) fn stage_command_sandbox_launch( + launch: CommandSandboxLaunch, + target_executable: &Path, + target_arguments: &[OsString], +) -> Result { + linux::stage_linux_command_sandbox_launch(launch, target_executable, target_arguments) +} + #[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, + }; use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::os::unix::ffi::OsStrExt; @@ -167,6 +192,72 @@ mod linux { fixed_system_read_only: Vec, } + pub(super) fn stage_linux_command_sandbox_launch( + mut launch: CommandSandboxLaunch, + target_executable: &Path, + target_arguments: &[OsString], + ) -> Result { + let metadata = launch.metadata.clone(); + let separator = launch + .arguments + .iter() + .position(|argument| argument == OsStr::new("--")) + .ok_or_else(|| { + CommandSandboxError::new( + "command sandbox staged launcher 缺少 argv 分隔符", + metadata.clone(), + ) + })?; + launch.arguments.truncate(separator); + push_option( + &mut launch.arguments, + "--dir", + [OsStr::new("/run/genarrative-launch")], + ); + push_chmod( + &mut launch.arguments, + "0700", + Path::new("/run/genarrative-launch"), + ); + let bwrap_status_fd = OsString::from(BWRAP_STATUS_FD.to_string()); + push_option( + &mut launch.arguments, + "--json-status-fd", + [bwrap_status_fd.as_os_str()], + ); + let bwrap_block_fd = OsString::from(BWRAP_BLOCK_FD.to_string()); + push_option( + &mut launch.arguments, + "--block-fd", + [bwrap_block_fd.as_os_str()], + ); + let trampoline_source_fd = OsString::from(TRAMPOLINE_SOURCE_FD.to_string()); + push_option( + &mut launch.arguments, + "--ro-bind-fd", + [ + trampoline_source_fd.as_os_str(), + OsStr::new(TRAMPOLINE_PATH), + ], + ); + #[cfg(test)] + { + let (name, value) = sandbox_trampoline_test_environment(); + push_option( + &mut launch.arguments, + "--setenv", + [OsStr::new(name), OsStr::new(value)], + ); + } + launch.arguments.push(OsString::from("--")); + launch.arguments.push(OsString::from(TRAMPOLINE_PATH)); + launch.arguments.extend(sandbox_trampoline_arguments()); + + let gate = LaunchGate::new_for_sandbox_stdin(target_executable, target_arguments) + .map_err(|error| CommandSandboxError::new(error, metadata))?; + Ok(StagedCommandSandboxLaunch { launch, gate }) + } + pub(super) fn prepare_linux_command_sandbox_launch( root: &Path, executable: &Path, @@ -756,7 +847,7 @@ mod linux { let separator = launch .arguments .iter() - .rposition(|argument| argument == OsStr::new("--")) + .position(|argument| argument == OsStr::new("--")) .ok_or_else(|| "sandbox launcher 缺少 argv 分隔符".to_string())?; let mut arguments = launch.arguments[..=separator].to_vec(); arguments.push(OsString::from("/usr/bin/true")); @@ -798,6 +889,7 @@ mod linux { #[cfg(test)] mod tests { use super::*; + use crate::command_sandbox_trampoline::{TargetExecState, TargetTerminalState}; use std::io::Write; use std::time::{SystemTime, UNIX_EPOCH}; @@ -1134,6 +1226,95 @@ print("SANDBOX_OK") assert!(!root.join(name).join("blocked-write").exists()); } } + + #[test] + fn command_sandbox_staged_gate_real_linux_opt_in_blocks_until_commit() { + if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() { + return; + } + let tree = unique_temp_tree(); + let root = tree.0.join("workspace-staged-gate"); + std::fs::create_dir_all(&root).expect("create workspace"); + for name in [".agent", ".git", ".agents", ".codex", ".hermes"] { + std::fs::create_dir_all(root.join(name)).expect("create control directory"); + } + let marker = root.join("committed-target-ran"); + let script = format!( + "from pathlib import Path; import os; assert os.readlink('/proc/self/fd/0') == '/dev/null'; assert all(not Path(f'/proc/self/fd/{{fd}}').exists() for fd in (3, 4, 5, 6)); Path({:?}).write_text('COMMITTED')", + marker.to_string_lossy() + ); + let arguments = vec!["-c".to_string(), script, "--".to_string()]; + let environment = vec![(OsString::from("PATH"), OsString::from("/usr/bin"))]; + let launch = prepare_linux_command_sandbox_launch( + &root, + Path::new("/usr/bin/python3"), + &arguments, + &root, + &environment, + ) + .expect("prepare real Linux sandbox"); + let target_arguments = arguments.iter().map(OsString::from).collect::>(); + let staged = stage_linux_command_sandbox_launch( + launch, + Path::new("/usr/bin/python3"), + &target_arguments, + ) + .expect("stage real Linux sandbox"); + assert!(!staged + .launch + .arguments + .iter() + .any(|argument| argument == OsStr::new(&arguments[1]))); + + let mut gate = staged.gate; + let mut command = Command::new(&staged.launch.executable); + command + .args(&staged.launch.arguments) + .current_dir(&staged.launch.cwd) + .env_clear() + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + gate.install_on_command(&mut command) + .expect("install private control channel"); + let child = command.spawn().expect("spawn staged sandbox"); + gate.child_created().expect("mark child-created"); + if let Err(error) = gate.wait_sandbox_ready(Duration::from_secs(3)) { + let output = child + .wait_with_output() + .expect("collect failed staged sandbox output"); + panic!( + "sandbox ready failed: {error}; stdout={}; stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + std::thread::sleep(Duration::from_millis(100)); + assert!(!marker.exists(), "target ran before durable commit"); + + gate.commit_exec().expect("commit target exec"); + assert_eq!( + gate.wait_target_exec(Duration::from_secs(3)) + .expect("target exec-established"), + TargetExecState::Established + ); + assert_eq!( + gate.wait_terminal(Duration::from_secs(3)) + .expect("target terminal"), + TargetTerminalState::Exited { code: 0 } + ); + let output = child.wait_with_output().expect("collect sandbox output"); + if !output.status.success() { + let mut diagnostics = std::io::stderr().lock(); + let _ = diagnostics.write_all(&output.stdout); + let _ = diagnostics.write_all(&output.stderr); + } + assert!(output.status.success()); + assert_eq!( + std::fs::read_to_string(marker).expect("committed marker"), + "COMMITTED" + ); + } } } 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 new file mode 100644 index 000000000..ac9e14bbd --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox_trampoline.rs @@ -0,0 +1,818 @@ +#[cfg(target_os = "linux")] +mod linux { + use serde::{Deserialize, Serialize}; + use std::ffi::OsString; + use std::fs::File; + use std::io::{self, Read, Write}; + use std::net::Shutdown; + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + use std::os::unix::net::UnixStream; + use std::os::unix::process::{CommandExt, ExitStatusExt}; + use std::path::{Path, PathBuf}; + use std::process::{Child, Command, Stdio}; + use std::time::Duration; + + pub(crate) const TRAMPOLINE_MODE_ARG: &str = "--command-sandbox-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; + pub(crate) const BWRAP_BLOCK_FD: RawFd = 5; + pub(crate) const TRAMPOLINE_SOURCE_FD: RawFd = 6; + const SANDBOX_CONTROL_FD: RawFd = libc::STDIN_FILENO; + const CONTROL_FRAME_LIMIT: usize = 64 * 1024; + const NONCE_BYTES: usize = 32; + #[cfg(test)] + const TEST_FIXTURE_ENV: &str = "GENARRATIVE_COMMAND_SANDBOX_TRAMPOLINE_FIXTURE"; + + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] + #[serde(tag = "kind", rename_all = "snake_case")] + enum ControlFrame { + Prepare { + nonce: Vec, + }, + SandboxReady { + nonce: Vec, + }, + CommitExec { + nonce: Vec, + executable: Vec, + arguments: Vec>, + }, + ExecEstablished { + nonce: Vec, + }, + TargetExecFailed { + nonce: Vec, + errno: i32, + }, + TargetExited { + nonce: Vec, + code: i32, + }, + TargetSignaled { + nonce: Vec, + signal: i32, + }, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum GatePhase { + Prepared, + ChildConfigured, + LauncherSpawned, + ChildCreated, + SandboxReady, + CommitPersisted, + ExecEstablished, + TargetExecFailed, + Terminal, + Aborted, + Failed, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum GateEvent { + ChildConfigured, + LauncherSpawned, + ChildCreated, + SandboxReady, + CommitPersisted, + ExecEstablished, + TargetExecFailed, + Terminal, + Abort, + Fail, + } + + impl GatePhase { + fn apply(&mut self, event: GateEvent) -> Result<(), String> { + let next = match (*self, event) { + (Self::Prepared, GateEvent::ChildConfigured) => Self::ChildConfigured, + (Self::ChildConfigured, GateEvent::LauncherSpawned) => Self::LauncherSpawned, + (Self::LauncherSpawned, GateEvent::ChildCreated) => Self::ChildCreated, + (Self::ChildCreated, GateEvent::SandboxReady) => Self::SandboxReady, + (Self::SandboxReady, GateEvent::CommitPersisted) => Self::CommitPersisted, + (Self::CommitPersisted, GateEvent::ExecEstablished) => Self::ExecEstablished, + (Self::CommitPersisted, GateEvent::TargetExecFailed) => Self::TargetExecFailed, + (Self::ExecEstablished, GateEvent::Terminal) => Self::Terminal, + ( + Self::Prepared + | Self::ChildConfigured + | Self::LauncherSpawned + | Self::ChildCreated + | Self::SandboxReady, + GateEvent::Abort, + ) => Self::Aborted, + ( + Self::Prepared + | Self::ChildConfigured + | Self::LauncherSpawned + | Self::ChildCreated + | Self::SandboxReady + | Self::CommitPersisted, + GateEvent::Fail, + ) => Self::Failed, + (Self::Failed, GateEvent::Abort) => Self::Aborted, + _ => return Err("command sandbox launch gate 状态迁移非法".to_string()), + }; + *self = next; + Ok(()) + } + } + + #[derive(Clone, Debug, Eq, PartialEq)] + pub(crate) enum TargetExecState { + Established, + Failed { errno: i32 }, + } + + #[derive(Clone, Debug, Eq, PartialEq)] + pub(crate) enum TargetTerminalState { + Exited { code: i32 }, + Signaled { signal: i32 }, + } + + #[derive(Debug)] + pub(crate) struct LaunchGate { + control: UnixStream, + child_control: Option, + bwrap_status: Option, + bwrap_status_child: Option, + bwrap_block: Option, + bwrap_block_child: Option, + trampoline_source: Option, + inherited_fds: Vec, + nonce: Vec, + executable: Vec, + arguments: Vec>, + child_control_fd: RawFd, + phase: GatePhase, + } + + impl LaunchGate { + pub(crate) fn new(executable: &Path, arguments: &[OsString]) -> Result { + Self::new_with_control_fd(executable, arguments, CONTROL_FD) + } + + pub(crate) fn new_for_sandbox_stdin( + executable: &Path, + arguments: &[OsString], + ) -> Result { + let trampoline_source = File::open("/proc/self/exe") + .map_err(|error| format!("打开 command sandbox trampoline source 失败:{error}"))?; + let (bwrap_status, bwrap_status_child) = UnixStream::pair() + .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)?; + gate.trampoline_source = Some(trampoline_source); + gate.bwrap_status = Some(bwrap_status); + gate.bwrap_status_child = Some(bwrap_status_child); + gate.bwrap_block = Some(bwrap_block); + gate.bwrap_block_child = Some(bwrap_block_child); + Ok(gate) + } + + fn new_with_control_fd( + executable: &Path, + arguments: &[OsString], + child_control_fd: RawFd, + ) -> Result { + if !executable.is_absolute() { + return Err("command sandbox target executable 必须是绝对路径".to_string()); + } + let (control, child_control) = UnixStream::pair() + .map_err(|error| format!("创建 command sandbox 私有控制通道失败:{error}"))?; + Ok(Self { + control, + child_control: Some(child_control), + bwrap_status: None, + bwrap_status_child: None, + bwrap_block: None, + bwrap_block_child: None, + trampoline_source: None, + inherited_fds: Vec::new(), + nonce: random_nonce()?, + executable: executable.as_os_str().as_bytes().to_vec(), + arguments: arguments + .iter() + .map(|argument| argument.as_os_str().as_bytes().to_vec()) + .collect(), + child_control_fd, + phase: GatePhase::Prepared, + }) + } + + /// Installs the private launch descriptors. The caller must invoke + /// `child_created` immediately after a successful outer spawn. + pub(crate) fn install_on_command(&mut self, command: &mut Command) -> Result<(), String> { + if self.phase != GatePhase::Prepared { + return Err("command sandbox launch gate 已配置".to_string()); + } + let child_fd = self + .child_control + .as_ref() + .ok_or_else(|| "command sandbox child 控制通道缺失".to_string())? + .as_raw_fd(); + let target_fd = self.child_control_fd; + let bwrap_status_fd = self.bwrap_status_child.as_ref().map(AsRawFd::as_raw_fd); + let bwrap_block_fd = self.bwrap_block_child.as_ref().map(AsRawFd::as_raw_fd); + let trampoline_source_fd = self.trampoline_source.as_ref().map(AsRawFd::as_raw_fd); + let sources = [ + (Some(child_fd), target_fd), + (bwrap_status_fd, BWRAP_STATUS_FD), + (bwrap_block_fd, BWRAP_BLOCK_FD), + (trampoline_source_fd, TRAMPOLINE_SOURCE_FD), + ]; + let mut mappings = [(None, 0); 4]; + let mut inherited_fds = Vec::new(); + for (index, (source, target)) in sources.into_iter().enumerate() { + let Some(source) = source else { + mappings[index] = (None, target); + continue; + }; + let inherited = duplicate_fd_high(source) + .map_err(|error| format!("准备 command sandbox inherited fd 失败:{error}"))?; + mappings[index] = (Some(inherited.as_raw_fd()), target); + inherited_fds.push(inherited); + } + self.inherited_fds = inherited_fds; + unsafe { + command.pre_exec(move || install_fixed_fds(mappings)); + } + self.phase.apply(GateEvent::ChildConfigured) + } + + pub(crate) fn child_created(&mut self) -> Result<(), String> { + self.phase.apply(GateEvent::LauncherSpawned)?; + self.child_control.take(); + self.bwrap_status_child.take(); + self.bwrap_block_child.take(); + self.trampoline_source.take(); + self.inherited_fds.clear(); + if let Some(status) = self.bwrap_status.as_mut() { + read_bwrap_child_created(status, Duration::from_secs(3)).map_err(|error| { + self.fail(format!("等待 bwrap child-created 失败:{error}")) + })?; + } + self.phase.apply(GateEvent::ChildCreated)?; + if let Some(mut block) = self.bwrap_block.take() { + block + .write_all(&[1]) + .and_then(|()| block.flush()) + .map_err(|error| self.fail(format!("放行 bwrap block-fd 失败:{error}")))?; + } + write_frame( + &mut self.control, + &ControlFrame::Prepare { + nonce: self.nonce.clone(), + }, + ) + .map_err(|error| self.fail(format!("发送 command sandbox prepare 失败:{error}"))) + } + + pub(crate) fn spawn_failed(&mut self) { + self.child_control.take(); + self.bwrap_status.take(); + self.bwrap_status_child.take(); + self.bwrap_block.take(); + self.bwrap_block_child.take(); + self.trampoline_source.take(); + self.inherited_fds.clear(); + let _ = self.phase.apply(GateEvent::Fail); + } + + pub(crate) fn wait_sandbox_ready(&mut self, timeout: Duration) -> Result<(), String> { + if self.phase != GatePhase::ChildCreated { + return Err("command sandbox 尚未进入 child-created".to_string()); + } + let frame = read_frame_with_timeout(&mut self.control, timeout) + .map_err(|error| self.fail(format!("等待 command sandbox ready 失败:{error}")))?; + match frame { + ControlFrame::SandboxReady { nonce } if nonce == self.nonce => { + self.phase.apply(GateEvent::SandboxReady) + } + _ => Err(self.fail("command sandbox ready 帧无效".to_string())), + } + } + + /// Must only be called after the Runtime durable commit succeeds. + pub(crate) fn commit_exec(&mut self) -> Result<(), String> { + self.phase.apply(GateEvent::CommitPersisted)?; + write_frame( + &mut self.control, + &ControlFrame::CommitExec { + nonce: self.nonce.clone(), + executable: self.executable.clone(), + arguments: self.arguments.clone(), + }, + ) + .map_err(|error| self.fail(format!("发送 command sandbox commit 失败:{error}"))) + } + + pub(crate) fn wait_target_exec( + &mut self, + timeout: Duration, + ) -> Result { + if self.phase != GatePhase::CommitPersisted { + return Err("command sandbox 尚未完成 durable commit".to_string()); + } + let frame = read_frame_with_timeout(&mut self.control, timeout) + .map_err(|error| self.fail(format!("等待 target exec 失败:{error}")))?; + match frame { + ControlFrame::ExecEstablished { nonce } if nonce == self.nonce => { + self.phase.apply(GateEvent::ExecEstablished)?; + Ok(TargetExecState::Established) + } + ControlFrame::TargetExecFailed { nonce, errno } if nonce == self.nonce => { + self.phase.apply(GateEvent::TargetExecFailed)?; + Ok(TargetExecState::Failed { errno }) + } + _ => Err(self.fail("command sandbox target exec 帧无效".to_string())), + } + } + + pub(crate) fn wait_terminal( + &mut self, + timeout: Duration, + ) -> Result { + if self.phase != GatePhase::ExecEstablished { + return Err("command sandbox target 尚未 exec-established".to_string()); + } + let frame = read_frame_with_timeout(&mut self.control, timeout) + .map_err(|error| self.fail(format!("等待 target terminal 失败:{error}")))?; + let terminal = match frame { + ControlFrame::TargetExited { nonce, code } if nonce == self.nonce => { + TargetTerminalState::Exited { code } + } + ControlFrame::TargetSignaled { nonce, signal } if nonce == self.nonce => { + TargetTerminalState::Signaled { signal } + } + _ => return Err(self.fail("command sandbox target terminal 帧无效".to_string())), + }; + self.phase.apply(GateEvent::Terminal)?; + Ok(terminal) + } + + pub(crate) fn abort(&mut self, child: &mut Child) -> Result<(), String> { + child + .kill() + .map_err(|error| format!("终止 command sandbox child 失败:{error}"))?; + child + .wait() + .map_err(|error| format!("回收 command sandbox child 失败:{error}"))?; + let _ = self.control.shutdown(Shutdown::Both); + self.phase.apply(GateEvent::Abort) + } + + fn fail(&mut self, message: String) -> String { + let _ = self.phase.apply(GateEvent::Fail); + message + } + } + + pub(crate) fn is_trampoline_mode(arguments: &[String]) -> bool { + arguments == [TRAMPOLINE_MODE_ARG] + } + + pub(crate) fn sandbox_trampoline_arguments() -> Vec { + #[cfg(not(test))] + { + vec![OsString::from(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) + } + + fn run_trampoline_from_fd(control_fd: RawFd) -> 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}"))?; + + let nonce = match read_frame(&mut control) + .map_err(|error| format!("读取 command sandbox prepare 失败:{error}"))? + { + ControlFrame::Prepare { nonce } if nonce.len() == NONCE_BYTES => nonce, + _ => return Err("command sandbox prepare 帧无效".to_string()), + }; + write_frame( + &mut control, + &ControlFrame::SandboxReady { + nonce: nonce.clone(), + }, + ) + .map_err(|error| format!("发送 command sandbox ready 失败:{error}"))?; + + let (executable, arguments) = match read_frame(&mut control) + .map_err(|error| format!("读取 command sandbox commit 失败:{error}"))? + { + ControlFrame::CommitExec { + nonce: commit_nonce, + executable, + arguments, + } if commit_nonce == nonce => (executable, arguments), + _ => return Err("command sandbox commit 帧无效".to_string()), + }; + let executable = PathBuf::from(OsString::from_vec(executable)); + if !executable.is_absolute() { + return Err("command sandbox target executable 非绝对路径".to_string()); + } + let arguments = arguments + .into_iter() + .map(OsString::from_vec) + .collect::>(); + + let mut target = match Command::new(&executable) + .args(&arguments) + .stdin(Stdio::null()) + .spawn() + { + Ok(target) => target, + Err(error) => { + write_frame( + &mut control, + &ControlFrame::TargetExecFailed { + nonce, + errno: error.raw_os_error().unwrap_or(0), + }, + ) + .map_err(|write_error| { + format!("发送 command sandbox target exec 失败帧失败:{write_error}") + })?; + return Ok(126); + } + }; + write_frame( + &mut control, + &ControlFrame::ExecEstablished { + nonce: nonce.clone(), + }, + ) + .map_err(|error| format!("发送 command sandbox exec-established 失败:{error}"))?; + + let status = 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}"))?; + Ok(code) + } else { + let signal = status.signal().unwrap_or(0); + write_frame( + &mut control, + &ControlFrame::TargetSignaled { nonce, signal }, + ) + .map_err(|error| format!("发送 command sandbox target signal 失败:{error}"))?; + Ok(128 + signal) + } + } + + fn duplicate_fd_high(source: RawFd) -> io::Result { + let duplicated = unsafe { libc::fcntl(source, libc::F_DUPFD_CLOEXEC, 64) }; + if duplicated < 0 { + return Err(io::Error::last_os_error()); + } + Ok(unsafe { OwnedFd::from_raw_fd(duplicated) }) + } + + fn install_fixed_fds(mappings: [(Option, RawFd); 4]) -> io::Result<()> { + for (source, target) in mappings { + let Some(source) = source else { + continue; + }; + if unsafe { libc::dup2(source, target) } < 0 { + return Err(io::Error::last_os_error()); + } + } + Ok(()) + } + + fn set_close_on_exec(fd: RawFd) -> 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; NONCE_BYTES]; + File::open("/dev/urandom") + .and_then(|mut file| file.read_exact(&mut nonce)) + .map_err(|error| format!("读取 command sandbox nonce 失败:{error}"))?; + Ok(nonce) + } + + fn read_bwrap_child_created(stream: &mut UnixStream, timeout: Duration) -> io::Result<()> { + stream.set_read_timeout(Some(timeout))?; + let result = (|| { + let mut line = Vec::new(); + let mut byte = [0_u8; 1]; + loop { + let read = stream.read(&mut byte)?; + if read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "bwrap status 在 child-created 前关闭", + )); + } + if byte[0] == b'\n' { + let status = serde_json::from_slice::(&line) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if status + .get("child-pid") + .and_then(serde_json::Value::as_u64) + .is_some_and(|pid| pid > 0) + { + return Ok(()); + } + line.clear(); + continue; + } + line.push(byte[0]); + if line.len() > CONTROL_FRAME_LIMIT { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "bwrap status 帧超过大小上限", + )); + } + } + })(); + let reset = stream.set_read_timeout(None); + match (result, reset) { + (Ok(()), Ok(())) => Ok(()), + (Err(error), _) => Err(error), + (Ok(()), Err(error)) => Err(error), + } + } + + fn write_frame(stream: &mut UnixStream, frame: &ControlFrame) -> 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() > CONTROL_FRAME_LIMIT { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "command sandbox 控制帧大小无效", + )); + } + 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 > CONTROL_FRAME_LIMIT { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "command sandbox 控制帧大小无效", + )); + } + 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_with_timeout( + stream: &mut UnixStream, + timeout: Duration, + ) -> io::Result { + stream.set_read_timeout(Some(timeout))?; + let result = read_frame(stream); + let reset = stream.set_read_timeout(None); + match (result, reset) { + (Ok(frame), Ok(())) => Ok(frame), + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + } + } + + #[cfg(test)] + mod tests { + use super::*; + use std::thread; + use std::time::{Duration, Instant}; + + const FIXTURE_TEST: &str = + "command_sandbox_trampoline::linux::tests::trampoline_child_fixture"; + + #[test] + #[ignore] + fn trampoline_child_fixture() { + let Some(mode) = std::env::var_os(TEST_FIXTURE_ENV) else { + return; + }; + let control_fd = if mode == "stdin" { + SANDBOX_CONTROL_FD + } else { + CONTROL_FD + }; + match run_trampoline_from_fd(control_fd) { + Ok(exit_code) => std::process::exit(exit_code), + Err(_) => std::process::exit(125), + } + } + + fn spawn_trampoline(gate: &mut LaunchGate) -> Child { + let mut command = Command::new(std::env::current_exe().expect("current test binary")); + command + .arg("--exact") + .arg(FIXTURE_TEST) + .arg("--ignored") + .arg("--nocapture") + .env(TEST_FIXTURE_ENV, "fd3"); + gate.install_on_command(&mut command).expect("install gate"); + match command.spawn() { + Ok(child) => { + gate.child_created().expect("mark child-created"); + child + } + Err(error) => { + gate.spawn_failed(); + panic!("spawn trampoline fixture: {error}"); + } + } + } + + fn wait_child(child: &mut Child, timeout: Duration) -> std::process::ExitStatus { + let deadline = Instant::now() + timeout; + loop { + match child.try_wait().expect("poll child") { + Some(status) => return status, + None if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)), + None => { + let _ = child.kill(); + let _ = child.wait(); + panic!("trampoline fixture timed out"); + } + } + } + } + + #[test] + fn target_is_blocked_until_commit_and_control_fd_is_not_inherited() { + let directory = tempfile::tempdir().expect("temp directory"); + let marker = directory.path().join("target-ran"); + let script = format!( + "if [ -e /proc/self/fd/{CONTROL_FD} ]; then exit 90; fi; printf committed > '{}'", + marker.display() + ); + let mut gate = LaunchGate::new( + Path::new("/bin/sh"), + &[OsString::from("-c"), OsString::from(script)], + ) + .expect("create gate"); + let mut child = spawn_trampoline(&mut gate); + + gate.wait_sandbox_ready(Duration::from_secs(2)) + .expect("sandbox ready"); + thread::sleep(Duration::from_millis(120)); + assert!(!marker.exists(), "target ran before durable commit"); + + gate.commit_exec().expect("commit target exec"); + assert_eq!( + gate.wait_target_exec(Duration::from_secs(2)) + .expect("target exec result"), + TargetExecState::Established + ); + assert_eq!( + gate.wait_terminal(Duration::from_secs(2)) + .expect("target terminal"), + TargetTerminalState::Exited { code: 0 } + ); + assert!(wait_child(&mut child, Duration::from_secs(2)).success()); + assert_eq!( + std::fs::read_to_string(marker).expect("target marker"), + "committed" + ); + } + + #[test] + fn abort_before_commit_reaps_child_without_running_target() { + let directory = tempfile::tempdir().expect("temp directory"); + let marker = directory.path().join("target-ran"); + let script = format!("printf forbidden > '{}'", marker.display()); + let mut gate = LaunchGate::new( + Path::new("/bin/sh"), + &[OsString::from("-c"), OsString::from(script)], + ) + .expect("create gate"); + let mut child = spawn_trampoline(&mut gate); + + gate.wait_sandbox_ready(Duration::from_secs(2)) + .expect("sandbox ready"); + gate.abort(&mut child).expect("abort and reap child"); + assert!(!marker.exists(), "target ran before commit"); + assert!(child.try_wait().expect("reaped child").is_some()); + } + + #[test] + fn target_exec_failure_is_distinct_from_sandbox_ready() { + let mut gate = + LaunchGate::new(Path::new("/definitely/missing/genarrative-target"), &[]) + .expect("create gate"); + let mut child = spawn_trampoline(&mut gate); + + gate.wait_sandbox_ready(Duration::from_secs(2)) + .expect("sandbox ready"); + gate.commit_exec().expect("commit target exec"); + assert!(matches!( + gate.wait_target_exec(Duration::from_secs(2)) + .expect("target exec result"), + TargetExecState::Failed { errno } if errno != 0 + )); + assert_eq!( + wait_child(&mut child, Duration::from_secs(2)).code(), + Some(126) + ); + } + + #[test] + fn control_eof_before_commit_never_runs_target() { + let directory = tempfile::tempdir().expect("temp directory"); + let marker = directory.path().join("target-ran"); + let script = format!("printf forbidden > '{}'", marker.display()); + let mut gate = LaunchGate::new( + Path::new("/bin/sh"), + &[OsString::from("-c"), OsString::from(script)], + ) + .expect("create gate"); + let mut child = spawn_trampoline(&mut gate); + + gate.wait_sandbox_ready(Duration::from_secs(2)) + .expect("sandbox ready"); + drop(gate); + assert!(!wait_child(&mut child, Duration::from_secs(2)).success()); + assert!(!marker.exists(), "target ran after pre-commit EOF"); + } + + #[test] + fn wrong_commit_nonce_never_runs_target() { + let directory = tempfile::tempdir().expect("temp directory"); + let marker = directory.path().join("target-ran"); + let script = format!("printf forbidden > '{}'", marker.display()); + let mut gate = LaunchGate::new( + Path::new("/bin/sh"), + &[OsString::from("-c"), OsString::from(script)], + ) + .expect("create gate"); + let mut child = spawn_trampoline(&mut gate); + + gate.wait_sandbox_ready(Duration::from_secs(2)) + .expect("sandbox ready"); + gate.nonce[0] ^= 0xff; + gate.commit_exec().expect("send mismatched commit nonce"); + assert!(gate.wait_target_exec(Duration::from_secs(2)).is_err()); + assert!(!wait_child(&mut child, Duration::from_secs(2)).success()); + assert!(!marker.exists(), "target ran after wrong commit nonce"); + } + + #[test] + fn gate_state_rejects_duplicate_and_out_of_order_events() { + let mut phase = GatePhase::Prepared; + assert!(phase.apply(GateEvent::SandboxReady).is_err()); + phase.apply(GateEvent::ChildConfigured).expect("configured"); + assert!(phase.apply(GateEvent::ChildConfigured).is_err()); + phase + .apply(GateEvent::LauncherSpawned) + .expect("launcher spawned"); + phase.apply(GateEvent::ChildCreated).expect("created"); + phase.apply(GateEvent::SandboxReady).expect("ready"); + assert!(phase.apply(GateEvent::ExecEstablished).is_err()); + } + } +} + +#[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, +}; 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 a239c9440..888aa4352 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -47,6 +47,7 @@ mod cli; mod command_exec; mod command_output; mod command_sandbox; +mod command_sandbox_trampoline; mod commands; mod config; #[cfg(all(debug_assertions, not(test)))] @@ -1255,6 +1256,13 @@ struct GameCreatorAgentLoopResult { fn main() { let mut args = std::env::args().skip(1).collect::>(); #[cfg(target_os = "linux")] + if command_sandbox_trampoline::is_trampoline_mode(&args) { + match command_sandbox_trampoline::run_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), diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index c7c0b917e..4b385728d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -3264,6 +3264,9 @@ pub(crate) struct ProjectVerificationResult { 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, pub(crate) log_path: String, pub(crate) updated_at: u64, } @@ -3277,6 +3280,9 @@ struct ProjectVerificationProcessResult { sandbox_mode: String, network_access: String, sandbox_profile_version: String, + sandbox_establishment: String, + target_exec: String, + launch_failure_kind: Option, } #[derive(Debug)] @@ -3551,22 +3557,6 @@ where Ok(output.finish()) } -fn configure_project_verification_process_group(command: &mut tokio::process::Command) { - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - command.as_std_mut().process_group(0); - } - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; - command - .as_std_mut() - .creation_flags(CREATE_NEW_PROCESS_GROUP); - } -} - #[cfg(unix)] fn terminate_project_verification_process_group(process_id: u32) { // npm may exit while a script leaves non-detached descendants behind. @@ -3611,44 +3601,56 @@ async fn collect_project_verification_output_task( } } -async fn run_project_verification_process( +async fn run_project_verification_process( root: &Path, spec: &ProjectVerificationSpec, -) -> Result { - ensure_project_verification_has_no_project_npmrc(root)?; + durable_commit: F, +) -> Result +where + F: FnOnce() -> Result<(), String>, +{ + ensure_project_verification_has_no_project_npmrc(root) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; let command_spec = - resolve_project_command_spec_at(root, "npm", &spec.arguments, ".", spec.timeout_seconds) - .map_err(|error| format!("project.verify 命令解析失败:{error}"))?; - let launch = prepare_project_command_launch_spec(root, &command_spec) - .map_err(|error| format!("project.verify sandbox preflight 失败:{error}"))?; - - let mut command = tokio::process::Command::new(&launch.executable); - command - .args(&launch.arguments) - .current_dir(&launch.cwd) - .env_clear() - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - for (name, value) in &launch.environment { - command.env(name, value); - } - configure_project_verification_process_group(&mut command); - - let mut child = command - .spawn() - .map_err(|error| format!("启动 {} 失败:{error}", spec.program))?; + resolve_project_command_spec_at(root, "npm", &spec.arguments, ".", spec.timeout_seconds)?; + let launch = prepare_project_command_launch_spec(root, &command_spec)?; + let launch_metadata = launch.clone(); + let staged = stage_project_command_launch_spec(&command_spec, launch)?; + let established = match spawn_staged_project_command(staged, durable_commit).await { + Ok(established) => established, + Err(error) if error.stage() == ProjectCommandErrorStage::TargetExec => { + return Ok(ProjectVerificationProcessResult { + exit_code: None, + timed_out: false, + output: sanitize_project_verification_output(error.message()), + sandbox_backend: launch_metadata.sandbox_backend, + sandbox_mode: launch_metadata.sandbox_mode, + network_access: launch_metadata.network_access, + sandbox_profile_version: launch_metadata.sandbox_profile_version, + sandbox_establishment: "established".to_string(), + target_exec: "failed".to_string(), + launch_failure_kind: Some("target-exec-failed".to_string()), + }); + } + Err(error) => return Err(error), + }; + let mut child = established.child; + #[cfg(target_os = "linux")] + let gate = established.gate; #[cfg(unix)] let process_id = child.id(); - let stdout = child - .stdout - .take() - .ok_or_else(|| "读取 project.verify stdout 失败".to_string())?; - let stderr = child - .stderr - .take() - .ok_or_else(|| "读取 project.verify stderr 失败".to_string())?; + let stdout = child.stdout.take().ok_or_else(|| { + ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + "读取 project.verify stdout 失败", + ) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + "读取 project.verify stderr 失败", + ) + })?; let stream_limit = PROJECT_VERIFICATION_OUTPUT_MAX_BYTES; let stdout_task = tokio::spawn(read_bounded_project_process_output(stdout, stream_limit)); let stderr_task = tokio::spawn(read_bounded_project_process_output(stderr, stream_limit)); @@ -3656,6 +3658,8 @@ async fn run_project_verification_process( let wait = tokio::time::timeout(timeout, child.wait()).await; let (exit_code, timed_out) = match wait { Ok(Ok(status)) => { + #[cfg(target_os = "linux")] + let _terminal = wait_established_project_command_terminal(gate).await?; #[cfg(unix)] if let Some(process_id) = process_id { terminate_project_verification_process_group(process_id); @@ -3666,7 +3670,10 @@ async fn run_project_verification_process( terminate_project_verification_process_tree(&mut child).await; stdout_task.abort(); stderr_task.abort(); - return Err(format!("等待 project.verify 子进程失败:{error}")); + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Execution, + format!("等待 project.verify 子进程失败:{error}"), + )); } Err(_) => { terminate_project_verification_process_tree(&mut child).await; @@ -3677,8 +3684,10 @@ async fn run_project_verification_process( collect_project_verification_output_task(stdout_task, "stdout"), collect_project_verification_output_task(stderr_task, "stderr"), ); - let stdout = stdout?; - let stderr = stderr?; + let stdout = stdout + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; + let stderr = stderr + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?; let mut sections = Vec::new(); if !stdout.trim().is_empty() { sections.push(format!("stdout:\n{}", stdout.trim())); @@ -3701,10 +3710,13 @@ async fn run_project_verification_process( exit_code, timed_out, output: sanitize_project_verification_output(§ions.join("\n\n")), - sandbox_backend: launch.sandbox_backend, - sandbox_mode: launch.sandbox_mode, - network_access: launch.network_access, - sandbox_profile_version: launch.sandbox_profile_version, + sandbox_backend: launch_metadata.sandbox_backend, + sandbox_mode: launch_metadata.sandbox_mode, + network_access: launch_metadata.network_access, + sandbox_profile_version: launch_metadata.sandbox_profile_version, + sandbox_establishment: "established".to_string(), + target_exec: "established".to_string(), + launch_failure_kind: None, }) } @@ -3714,11 +3726,30 @@ pub(crate) async fn run_project_verification_at( expected_command: &str, timeout_seconds: u64, ) -> Result { + run_project_verification_with_commit_at(root, script, expected_command, timeout_seconds, || { + Ok(()) + }) + .await +} + +pub(crate) async fn run_project_verification_with_commit_at( + root: &Path, + script: &str, + expected_command: &str, + timeout_seconds: u64, + durable_commit: F, +) -> Result +where + F: FnOnce() -> Result<(), String>, +{ let spec = resolve_project_verification_spec_at(root, script, expected_command, timeout_seconds)?; let started_at = std::time::Instant::now(); - let process = match run_project_verification_process(root, &spec).await { + let process = match run_project_verification_process(root, &spec, durable_commit).await { Ok(process) => process, + Err(error) if error.needs_reconciliation() => { + return Err(format!("project.verify 执行状态需要人工核对:{error}")); + } Err(error) => ProjectVerificationProcessResult { exit_code: None, timed_out: false, @@ -3727,6 +3758,9 @@ pub(crate) async fn run_project_verification_at( sandbox_mode: "not-established".to_string(), network_access: "not-established".to_string(), sandbox_profile_version: "none".to_string(), + sandbox_establishment: "not-established".to_string(), + target_exec: "not-attempted".to_string(), + launch_failure_kind: None, }, }; let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); @@ -3740,7 +3774,7 @@ pub(crate) async fn run_project_verification_at( .map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?; } let log_entry = format!( - "{updated_at} project.verify {} {status} manager={} exitCode={} timedOut={} durationMs={} sandboxBackend={} sandboxMode={} networkAccess={} sandboxProfileVersion={}\n{}\n", + "{updated_at} project.verify {} {status} manager={} exitCode={} timedOut={} durationMs={} sandboxBackend={} sandboxMode={} networkAccess={} sandboxProfileVersion={} sandboxEstablishment={} targetExec={} launchFailureKind={}\n{}\n", spec.script, spec.package_manager, process @@ -3753,6 +3787,9 @@ pub(crate) async fn run_project_verification_at( process.sandbox_mode, process.network_access, process.sandbox_profile_version, + process.sandbox_establishment, + process.target_exec, + process.launch_failure_kind.as_deref().unwrap_or("none"), process.output ); fs::OpenOptions::new() @@ -3790,6 +3827,9 @@ pub(crate) async fn run_project_verification_at( sandbox_mode: process.sandbox_mode, network_access: process.network_access, sandbox_profile_version: process.sandbox_profile_version, + sandbox_establishment: process.sandbox_establishment, + target_exec: process.target_exec, + launch_failure_kind: process.launch_failure_kind, log_path: log_path.to_string_lossy().into_owned(), updated_at, }) 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 9cf7ef63b..0ea0686c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -12309,6 +12309,96 @@ fn failed_project_verification_clears_previous_run_credential() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn invalid_project_verification_preserves_previous_run_credential() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "无效验证保留凭证项目").expect("project init"); + advance_project_revision_for_test(&root, "agent-a", "run-a", "file.patch"); + persist_project_verification_for_test(&root, "agent-a", "run-a", "project.verify", true); + + let observation = observe_agent_runtime_project_verify( + &root, + "agent-a", + "run-a", + Some("action-invalid-verify"), + "fingerprint-invalid-verify", + &serde_json::json!({ "script": "check" }), + ) + .await; + + assert_eq!(observation.status, "failed"); + let gate = read_game_creator_agent_runtime_verification_gate(&root, "agent-a", "run-a") + .expect("read preserved gate"); + assert_eq!(gate.verified_revision, Some(1)); + assert_eq!(gate.last_verification_status.as_deref(), Some("passed")); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision") + .revision, + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +#[cfg(unix)] +#[tokio::test] +async fn executed_project_verification_audit_failure_requires_reconciliation() { + use std::os::unix::fs::symlink; + + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "验证审计失败项目").expect("project init"); + let check_command = r#"node -e "require('fs').writeFileSync('verify-ran.txt','once')""#; + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "verify-audit-failure", + "private": true, + "scripts": { "check": check_command } + })) + .expect("serialize package json"), + ) + .expect("write package json"); + fs::remove_file(root.join(".agent/agent.db")).expect("remove initial agent db"); + let outside = root.parent().expect("project parent").join(format!( + "verify-audit-outside-{}", + TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::write(&outside, "outside").expect("write outside agent db target"); + symlink(&outside, root.join(".agent/agent.db")).expect("symlink agent db"); + + let observation = observe_agent_runtime_project_verify( + &root, + "agent-a", + "run-a", + Some("action-verify-audit-failure"), + "fingerprint-verify-audit-failure", + &serde_json::json!({ + "script": "check", + "expectedCommand": check_command, + "timeoutSeconds": 15 + }), + ) + .await; + + assert_eq!( + observation.status, + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + ); + assert_eq!( + fs::read_to_string(root.join("verify-ran.txt")).expect("verification marker"), + "once" + ); + let gate = read_game_creator_agent_runtime_verification_gate(&root, "agent-a", "run-a") + .expect("read failed gate"); + assert_eq!(gate.verified_revision, None); + assert_eq!(gate.last_verification_status.as_deref(), Some("failed")); + + fs::remove_file(root.join(".agent/agent.db")).ok(); + fs::remove_file(outside).ok(); + fs::remove_dir_all(root).ok(); +} + #[test] fn project_verification_gate_accepts_only_exact_game_static_smoke() { let root = unique_project_path(); @@ -28039,7 +28129,7 @@ fn limited_local_command_runs_static_game_smoke_and_writes_log() { let result = run_limited_local_command_at(&root, "game.static_smoke").expect("static smoke"); assert_eq!(result.command_id, "game.static_smoke"); - assert_eq!(result.status, "completed"); + assert_eq!(result.status, "completed", "{}", result.output); assert!(result.output.contains("game/index.html")); let log = fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); assert!(log.contains("command.run_limited game.static_smoke")); @@ -28285,7 +28375,7 @@ async fn project_verification_runs_without_prepost_and_records_failure_and_timeo let completed = run_project_verification_at(&root, "check", check_command, 15) .await .expect("run check script"); - assert_eq!(completed.status, "completed"); + assert_eq!(completed.status, "completed", "{}", completed.output); assert_eq!(completed.exit_code, Some(0)); assert!(!completed.timed_out); assert!(completed.output.contains("VERIFY_PROCESS_OK")); @@ -28301,7 +28391,7 @@ async fn project_verification_runs_without_prepost_and_records_failure_and_timeo let named = run_project_verification_at(&root, "test:unit", named_test_command, 15) .await .expect("run named unit test script"); - assert_eq!(named.status, "completed"); + assert_eq!(named.status, "completed", "{}", named.output); assert!(named.output.contains("VERIFY_NAMED_TEST_OK")); assert!(!root.join("prenamed-test-ran.txt").exists()); assert!(!root.join("postnamed-test-ran.txt").exists()); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8786bfc8e..a35040a5d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4293,3 +4293,11 @@ - 已知残余:项目 mount preflight 与真实 bwrap launch 是两次独立进程启动。第二次 setup 失败不会让目标程序脱离沙箱执行,但当前缺少 exec-ready 握手,revision 可能已推进且审计无法证明目标是否进入 exec;后续必须在 launcher 层补可信握手,当前文档和验收不得宣称该阶段具备原子保证。 - V1.11.1 决策:bwrap `child-pid` 只作为 child-created,不作为 sandbox-ready。Linux launcher 必须以受信任 trampoline 和独立私有控制通道完成 `SANDBOX_READY -> durable commit -> COMMIT_EXEC -> EXEC_ESTABLISHED`;commit 前失败显式 kill/reap 且目标零执行,commit 后无 exec-ready 进入 launch-unknown reconciliation。PTY 控制帧不得混入 transcript。 - 验收修正:V1.10 process fixture 写 `.agent`、启动 TCP 并跨 namespace 使用 PID/端口,与 V1.11 安全边界冲突。V1.11 真实复验改为纯 PTY readiness/challenge/echo/stopped 协议,以唯一 durable start、cursor 链、stdin hash 和宿主项目 cwd 进程清零证明;Provider 502 的零工具计划失败单独记为外部瞬态错误。 + +## 2026-07-14 AI 游戏创作 Agent Runtime V1.11.1 一次性命令可信握手 + +- 决策:Linux `command.exec / project.verify` 统一进入 `bwrap child-created -> block release -> SANDBOX_READY -> durable callback -> COMMIT_EXEC -> EXEC_ESTABLISHED`。revision、旧验证凭证和 verification running 状态只在 ready 后持久化;target exec 失败保留已提交 revision,commit 前失败不执行目标。 +- 决策:当前 bubblewrap 0.11.1 没有可用的 `--preserve-fds`。一次性命令原本不接收 stdin,因此控制 socket 仅占 bwrap/trampoline 的 fd 0,trampoline 启动真实目标时显式恢复 `/dev/null` stdin;stdout/stderr 保持业务专用。PTY 不复用该方式,后续由 process child wrapper 在 PTY 外桥接同一帧协议。 +- 决策: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 已整体交付。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 1a44c4d19..4f76b69f1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2901,3 +2901,19 @@ - 处理:交互 fixture 只使用 PTY stdin/stdout/signal,不写 `.agent`、不监听 TCP、不持久化 PID/端口。唯一启动由 process record、start action/fingerprint、start audit 和唯一 readiness marker共同证明;Runner 强杀后的清理由 owner boot、reconciliation record 和宿主 `/proc/*/cwd` 项目进程归零证明。 - 验证:独立真实 Node smoke 必须完成 readiness、challenge 单行原样输入、精确 echo、SIGTERM stopped,并确认项目未创建 `.agent`;E2E 分别报告 readiness / stdin hash / echo / stopped 缺失,严格检查 readiness poll -> stdin -> echo poll -> terminate -> terminal poll。Provider 在零工具计划阶段的 502/TLS 只记外部失败,不得归因到 fixture 或 Runtime。 - 关联:`apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs`、`agent-runtime-real-e2e.mjs`、`apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 + +## pre-exec 固定 FD 映射不能逐项覆盖源描述符 + +- 现象:可信 launcher 的单项测试都通过,但并发 `project.verify` 偶发在第二次 launch 被记为 failed;失败项单独重跑又恢复正常。 +- 原因:父进程创建 socket/file 后得到的源 FD 数值不固定。若逐项 `dup2(source, 0/4/5/6)`,前一次目标 FD 可能正是后一条映射尚未读取的源 FD,导致 status、block 或 trampoline source 被静默替换。测试并发度改变打开 FD 分布,因此表现为偶发。 +- 处理:在父进程进入 spawn 前先用 `F_DUPFD_CLOEXEC` 把所有源复制到 64 以上互不重叠的 owned FD,并保持到 child-created;pre-exec 只把这些稳定高位 FD `dup2` 到固定 0/4/5/6。不能等到 pre-exec 才复制原始源,因为 Command 的 stdin/stdout/stderr 安装可能已覆盖原本占用 0/1/2 的源 FD。 +- 验证:并发执行全部 project verification 测试;同时真实运行 bwrap staged marker 用例,确认 child-created、block、ready、commit、exec 和目标退出链均稳定,目标 argv/env/FD 不含 nonce 或控制 socket。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox_trampoline.rs`、`command_sandbox.rs`、`command_exec.rs`、`project.rs`。 + +## bwrap 的命令分隔符不能从目标 argv 末尾反查 + +- 现象:普通命令握手正常,但 `cargo test -- --nocapture`、npm forwarded args 等包含独立 `--` 的合法目标参数可能在 sandbox preflight 或 staged launch 期间提前执行原目标。 +- 原因:launcher 用 `rposition("--")` 查找 bwrap 自己插入的命令分隔符,误命中目标 argv 里的最后一个 `--`;截断后原 target executable 仍位于 bwrap COMMAND 位置,trampoline 被追加成目标参数而不是替代目标。 +- 处理:构造器保证 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`。 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 cd55ca913..5ccf6e592 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 @@ -583,6 +583,10 @@ V1.11.1 必须把 `prepared -> child-created -> sandbox-ready -> commit-persiste - portable-pty 会关闭额外 FD,不能把控制协议混入 PTY 输出。Linux process child wrapper 需要唯一专用控制通道,只经该通道接收私有 launch plan 和交换 ready/commit/exec 帧;目标只继承 PTY stdin/stdout/stderr,控制 FD、nonce、child-pid、宿主路径和完整 bwrap argv不得进入目标 argv/env、transcript、command log、record、receipt 或 Agent DB。 - 门禁必须覆盖乱序/重复/错 nonce/EOF、block 未放行目标 marker 为零、sandbox-ready 后持久化失败、目标不存在或无权限、目标立即 exit 0/7、PTY 快速退出和 Runner 强杀窗口,并扫描 `/proc/self/fd`、argv、env 与全部公共持久面确认控制材料泄漏为零。Windows 继续按 CreateProcess + Job 语义单独建模,不能复用或宣称 Linux 握手。 +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 最新真实 `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 清理。 同日追加的 `process-session` Provider 复验未计为通过:前三轮模型以不同 actionId / fingerprint 主动重复 start;收紧策略后的旧 E2E 又暴露 V1.10 fixture 与 V1.11 sandbox 契约冲突,fixture 在 readiness 前写 `.agent`、启动 namespace 内 loopback 并把 namespace PID/端口当宿主事实,而 V1.11 正确隐藏 `.agent` 且隔离 pid/network namespace,因此 `process-transcript-interaction-evidence-missing` 不能直接归因于模型抄错 challenge。修复方向是纯 PTY fixture:不写 `.agent`、不启动 TCP、不跨 namespace 读取 PID/端口,以唯一 process record/start/readiness、连续 cursor、stdin hash、精确 echo、stopped 和宿主项目 cwd 进程清零作为事实。最新一次重跑在零工具计划阶段连续收到 Provider 502,只记外部瞬态失败,不用于判断 Runtime。新的纯 PTY Provider 套件 PASS 前,不更新 V1.10 历史结论,也不把本次失败描述成已验收。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 35008425a..0df6031b6 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -32,6 +32,8 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod 2026-07-14 起,同一文档的“V1.11 OS 强制工作区沙箱与通用项目命令”替代 V1.2 / V1.10 在 Linux 上的固定 program / 严格 argv 白名单边界。`command.exec / command.start` 继续接受结构化 `program + args + cwd`、沿用 confirm policy、durable action、revision、verification、输出和进程会话协议;`project.verify` 也必须复用同一 launcher,不能保留平行的宿主 npm spawn。Linux 只在受信任系统 bubblewrap 创建的 workspace-write sandbox 内启动真实命令:项目根可写,`.git / .agents / .codex / .hermes` 只读,`.agent` 不可见且不可写,项目外普通用户文件不挂载,网络 namespace 默认隔离,所有后代继承相同边界。program 只接受无路径分隔符的裸可执行名并从受信任 PATH 解析,argv 只保留数量、长度和控制字符硬限制;允许 `bash -lc`、Git、构建器、测试器和项目脚本在沙箱内自行工作。外部工具链环境根必须 canonicalize 后校验为窄工具链目录,禁止把整个 HOME 或其符号链接目标挂入沙箱。bubblewrap 缺失、不可执行或 setup 失败必须在项目命令执行前失败关闭,不允许退回宿主全权限。process record v2 与命令审计持久化真实 launch metadata,失败不能按平台静态冒充已建立沙箱。共享 `os-workspace-sandbox` capability 只标记 Linux;Windows 首版继续使用原固定白名单、隔离环境和 Job Object,不能宣称已达到同等 OS sandbox。deb / rpm 声明 bubblewrap 依赖,AppImage 依赖宿主预装且缺失时功能失败关闭;approval 与 sandbox 仍是两层独立门禁。 +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-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、重复副作用与密钥扫描继续全部通过。