为一次性命令增加可信启动握手

新增 Linux trampoline 私有控制协议和 sandbox-ready/exec-established 状态机
让 command.exec 与 project.verify 在 durable commit 后才启动真实目标
补齐 launch unknown reconciliation、target exec failure 和 revision/gate 审计语义
覆盖 bwrap 分隔符、FD 映射、协议失败和真实命令回归测试
同步 Runtime 技术方案、决策记录和已知陷阱
This commit is contained in:
AIGameCreator App
2026-07-14 07:49:04 +08:00
parent 580e4dd87f
commit d5cb2f3719
11 changed files with 1678 additions and 176 deletions
+109 -67
View File
@@ -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)),
}
}
}
}
@@ -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<String>) -> Self {
pub(crate) fn new(stage: ProjectCommandErrorStage, message: impl Into<String>) -> 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<StagedProjectCommandLaunchSpec, ProjectCommandError> {
#[cfg(target_os = "linux")]
{
let target_arguments = project_command_actual_arguments(spec)
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>();
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<F>(
staged: StagedProjectCommandLaunchSpec,
durable_commit: F,
) -> Result<EstablishedProjectCommand, ProjectCommandError>
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<String>,
termination: Result<String, String>,
) -> 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<TargetTerminalState, ProjectCommandError> {
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<String, String> {
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<R>(
mut reader: R,
) -> Result<BoundedCommandOutput, String>
@@ -1465,30 +1727,18 @@ pub(crate) fn project_command_source_fingerprint(root: &Path) -> Result<String,
Ok(format!("{:x}", digest.finalize()))
}
async fn run_project_command_process(
async fn run_project_command_process<F>(
spec: &ProjectCommandSpec,
launch: &ProjectCommandLaunchSpec,
) -> Result<ProjectCommandProcessResult, ProjectCommandError> {
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<ProjectCommandProcessResult, ProjectCommandError>
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<ProjectCommandResult, ProjectCommandError> {
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<F>(
root: &Path,
spec: &ProjectCommandSpec,
launch: &ProjectCommandLaunchSpec,
staged: StagedProjectCommandLaunchSpec,
output_identity: Option<CommandOutputIdentity>,
) -> Result<ProjectCommandResult, ProjectCommandError> {
durable_commit: F,
) -> Result<ProjectCommandResult, ProjectCommandError>
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}-"))
@@ -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<StagedCommandSandboxLaunch, CommandSandboxError> {
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<ReadOnlyMount>,
}
pub(super) fn stage_linux_command_sandbox_launch(
mut launch: CommandSandboxLaunch,
target_executable: &Path,
target_arguments: &[OsString],
) -> Result<StagedCommandSandboxLaunch, CommandSandboxError> {
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::<Vec<_>>();
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"
);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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::<Vec<_>>();
#[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),
@@ -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<String>,
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<String>,
}
#[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<F>(
root: &Path,
spec: &ProjectVerificationSpec,
) -> Result<ProjectVerificationProcessResult, String> {
ensure_project_verification_has_no_project_npmrc(root)?;
durable_commit: F,
) -> Result<ProjectVerificationProcessResult, ProjectCommandError>
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(&sections.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<ProjectVerificationResult, String> {
run_project_verification_with_commit_at(root, script, expected_command, timeout_seconds, || {
Ok(())
})
.await
}
pub(crate) async fn run_project_verification_with_commit_at<F>(
root: &Path,
script: &str,
expected_command: &str,
timeout_seconds: u64,
durable_commit: F,
) -> Result<ProjectVerificationResult, String>
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,
})
@@ -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());
@@ -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 0trampoline 启动真实目标时显式恢复 `/dev/null` stdinstdout/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 awaitcommit 后协议/等待未知和执行后 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 已整体交付。
@@ -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-createdpre-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`
@@ -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` 固定挂入 sandboxtrampoline 的随机 nonce 控制帧只走一次性命令原本不用的 stdin socket,真实目标重新获得 `/dev/null` stdin。`command.exec``project.verify` 共用 `spawn -> child-created -> sandbox-ready -> durable callback -> commit-exec -> exec-established` launcherrevision / 旧验证凭证只在 sandbox-ready 后提交,业务 timeout 只在 exec-established 后开始,无效 `project.verify` 输入和 commit 前失败保留既有凭证。durable commit 到 exec verdict 之间不再出现 async cancellation pointcommit 后协议未知、目标/输出等待异常和命令/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 / assistantRunner 强杀后 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 历史结论,也不把本次失败描述成已验收。
@@ -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 只标记 LinuxWindows 首版继续使用原固定白名单、隔离环境和 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-join95 条 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 项内容 hunksprepared / completed 审计各 1 条、patchset revision 增量为 1Runner 强杀恢复、命令和项目验证、双视口浏览器验证、隔离 Agent join、重复副作用与密钥扫描继续全部通过。