为持久进程增加可信启动握手

新增 PTY 外私有 bridge 与 sandbox ready/commit/exec 协议
升级 process record v3 并收紧恢复、幂等与 final/idle 门禁
完善 target 前台进程组、graceful 后代收束和审计失败处理
补齐跨平台回归测试及 Runtime 文档
This commit is contained in:
AIGameCreator App
2026-07-14 09:41:18 +08:00
parent d5cb2f3719
commit 689082901f
11 changed files with 3805 additions and 211 deletions
@@ -12584,6 +12584,9 @@ fn agent_runtime_process_poll_detail(
"sandboxMode": result.sandbox_mode,
"networkAccess": result.network_access,
"sandboxProfileVersion": result.sandbox_profile_version,
"sandboxEstablishment": result.sandbox_establishment,
"targetExec": result.target_exec,
"launchFailureKind": result.launch_failure_kind,
});
if include_output {
detail
@@ -12629,6 +12632,9 @@ fn append_agent_runtime_process_poll_audit(
"sandboxMode": result.sandbox_mode,
"networkAccess": result.network_access,
"sandboxProfileVersion": result.sandbox_profile_version,
"sandboxEstablishment": result.sandbox_establishment,
"targetExec": result.target_exec,
"launchFailureKind": result.launch_failure_kind,
}),
)
}
@@ -12772,11 +12778,6 @@ fn observe_agent_runtime_command_start(
};
}
};
if let Err(error) =
prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "command.start")
{
return agent_runtime_mutation_gate_failure_observation(root, "command.start", &error);
}
let source_fingerprint = match project_command_source_fingerprint(root) {
Ok(fingerprint) => fingerprint,
Err(error) => {
@@ -12786,7 +12787,7 @@ fn observe_agent_runtime_command_start(
summary: "command.start 无法计算启动前源码指纹,进程未启动".to_string(),
detail: Some(
serde_json::json!({
"revisionAdvanced": true,
"revisionAdvanced": false,
"error": redact_agent_runtime_project_paths(root, &error, 500),
})
.to_string(),
@@ -12794,40 +12795,85 @@ fn observe_agent_runtime_command_start(
};
}
};
let result = match start_prepared_process_session_at(
let revision_before = match read_game_creator_agent_runtime_project_revision(root) {
Ok(revision) => revision.revision,
Err(error) => {
return agent_runtime_mutation_gate_failure_observation(root, "command.start", &error);
}
};
let mut result = match start_prepared_process_session_at(
root,
identity,
&command_spec,
&command_launch,
source_fingerprint,
|| {
prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "command.start")
.map(|_| ())
},
) {
Ok(result) => result,
Err(error) => {
let revision_advanced = read_game_creator_agent_runtime_project_revision(root)
.map(|revision| (revision.revision > revision_before).to_string())
.unwrap_or_else(|_| "unknown".to_string());
let needs_reconciliation = error.needs_reconciliation();
return AgentRuntimeToolObservation {
tool: "command.start".to_string(),
status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(),
summary: "command.start 启动结果无法完整确认".to_string(),
status: if needs_reconciliation {
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
} else {
"failed"
}
.to_string(),
summary: if needs_reconciliation {
"command.start 启动结果无法完整确认".to_string()
} else {
"command.start 未进入目标执行".to_string()
},
detail: Some(
serde_json::json!({
"revisionAdvanced": true,
"error": redact_agent_runtime_project_paths(root, &error, 500),
"revisionAdvanced": revision_advanced,
"launchFailureKind": error.stage().as_str(),
"error": redact_agent_runtime_project_paths(root, error.message(), 500),
})
.to_string(),
),
};
}
};
let revision_advanced = read_game_creator_agent_runtime_project_revision(root)
.map(|revision| revision.revision > revision_before)
.unwrap_or(true);
let audit = append_agent_runtime_process_poll_audit(root, "command.start", pending, &result);
if let Err(error) = &audit {
let _ = mark_process_session_start_audit_failure_at(root, &result.process_id, error);
result.status = "needs-reconciliation".to_string();
result.stdin_open = false;
result.needs_reconciliation = true;
result.launch_failure_kind = Some("start-audit-failed".to_string());
}
let needs_reconciliation = result.needs_reconciliation || audit.is_err();
AgentRuntimeToolObservation {
tool: "command.start".to_string(),
status: process_session_observation_status(needs_reconciliation),
status: if needs_reconciliation {
AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION
} else if result.status == "failed" {
"failed"
} else {
"ok"
}
.to_string(),
summary: if audit.is_err() {
"command.start 已返回,但安全审计无法完整落盘".to_string()
} else {
format!("进程会话 {} 状态为 {}", result.process_id, result.status)
},
detail: Some(agent_runtime_process_poll_detail(&result, false, true)),
detail: Some(agent_runtime_process_poll_detail(
&result,
false,
revision_advanced,
)),
}
}
@@ -135,17 +135,42 @@ pub(crate) fn stage_command_sandbox_launch(
target_executable: &Path,
target_arguments: &[OsString],
) -> Result<StagedCommandSandboxLaunch, CommandSandboxError> {
linux::stage_linux_command_sandbox_launch(launch, target_executable, target_arguments)
let metadata = launch.metadata.clone();
let gate = LaunchGate::new_for_sandbox_stdin(target_executable, target_arguments)
.map_err(|error| CommandSandboxError::new(error, metadata))?;
linux::stage_linux_command_sandbox_launch(
launch,
gate,
linux::StagedSandboxTrampoline::SandboxStdin,
)
}
#[cfg(target_os = "linux")]
pub(crate) fn stage_command_sandbox_launch_for_process_session(
launch: CommandSandboxLaunch,
target_executable: &Path,
target_arguments: &[OsString],
) -> Result<StagedCommandSandboxLaunch, CommandSandboxError> {
let metadata = launch.metadata.clone();
let gate = LaunchGate::new_for_sandbox_stdin(target_executable, target_arguments)
.map_err(|error| CommandSandboxError::new(error, metadata))?;
linux::stage_linux_command_sandbox_launch(
launch,
gate,
linux::StagedSandboxTrampoline::ProcessSession,
)
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
#[cfg(test)]
use crate::command_sandbox_trampoline::sandbox_trampoline_test_environment;
use crate::command_sandbox_trampoline::{
sandbox_trampoline_arguments, LaunchGate, BWRAP_BLOCK_FD, BWRAP_STATUS_FD, TRAMPOLINE_PATH,
TRAMPOLINE_SOURCE_FD,
process_session_trampoline_arguments, sandbox_trampoline_arguments, LaunchGate,
BWRAP_BLOCK_FD, BWRAP_STATUS_FD, TRAMPOLINE_PATH, TRAMPOLINE_SOURCE_FD,
};
#[cfg(test)]
use crate::command_sandbox_trampoline::{
process_session_trampoline_test_environment, sandbox_trampoline_test_environment,
};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
@@ -192,10 +217,16 @@ mod linux {
fixed_system_read_only: Vec<ReadOnlyMount>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum StagedSandboxTrampoline {
SandboxStdin,
ProcessSession,
}
pub(super) fn stage_linux_command_sandbox_launch(
mut launch: CommandSandboxLaunch,
target_executable: &Path,
target_arguments: &[OsString],
gate: LaunchGate,
trampoline: StagedSandboxTrampoline,
) -> Result<StagedCommandSandboxLaunch, CommandSandboxError> {
let metadata = launch.metadata.clone();
let separator = launch
@@ -242,7 +273,12 @@ mod linux {
);
#[cfg(test)]
{
let (name, value) = sandbox_trampoline_test_environment();
let (name, value) = match trampoline {
StagedSandboxTrampoline::SandboxStdin => sandbox_trampoline_test_environment(),
StagedSandboxTrampoline::ProcessSession => {
process_session_trampoline_test_environment()
}
};
push_option(
&mut launch.arguments,
"--setenv",
@@ -251,10 +287,11 @@ mod linux {
}
launch.arguments.push(OsString::from("--"));
launch.arguments.push(OsString::from(TRAMPOLINE_PATH));
launch.arguments.extend(sandbox_trampoline_arguments());
launch.arguments.extend(match trampoline {
StagedSandboxTrampoline::SandboxStdin => sandbox_trampoline_arguments(),
StagedSandboxTrampoline::ProcessSession => process_session_trampoline_arguments(),
});
let gate = LaunchGate::new_for_sandbox_stdin(target_executable, target_arguments)
.map_err(|error| CommandSandboxError::new(error, metadata))?;
Ok(StagedCommandSandboxLaunch { launch, gate })
}
@@ -1254,10 +1291,13 @@ print("SANDBOX_OK")
)
.expect("prepare real Linux sandbox");
let target_arguments = arguments.iter().map(OsString::from).collect::<Vec<_>>();
let gate =
LaunchGate::new_for_sandbox_stdin(Path::new("/usr/bin/python3"), &target_arguments)
.expect("create real Linux sandbox gate");
let staged = stage_linux_command_sandbox_launch(
launch,
Path::new("/usr/bin/python3"),
&target_arguments,
gate,
StagedSandboxTrampoline::SandboxStdin,
)
.expect("stage real Linux sandbox");
assert!(!staged
@@ -1315,6 +1355,65 @@ print("SANDBOX_OK")
"COMMITTED"
);
}
#[test]
fn staged_gate_variants_share_bwrap_control_mounts() {
let launch = CommandSandboxLaunch {
executable: PathBuf::from("/usr/bin/bwrap"),
arguments: vec![
OsString::from("--unshare-all"),
OsString::from("--"),
OsString::from("/usr/bin/true"),
],
cwd: PathBuf::from("/workspace/project"),
environment: Vec::new(),
metadata: CommandSandboxMetadata::enforced_linux(),
};
let target_arguments = [OsString::from("--version")];
let stdin = stage_command_sandbox_launch(
launch.clone(),
Path::new("/usr/bin/true"),
&target_arguments,
)
.expect("stage stdin sandbox launch");
let process_session = stage_command_sandbox_launch_for_process_session(
launch,
Path::new("/usr/bin/true"),
&target_arguments,
)
.expect("stage process-session sandbox launch");
for staged in [&stdin, &process_session] {
assert_eq!(staged.launch.executable, Path::new("/usr/bin/bwrap"));
assert_eq!(staged.launch.cwd, Path::new("/workspace/project"));
assert_eq!(staged.launch.metadata, stdin.launch.metadata);
assert!(has_sequence(
&staged.launch.arguments,
&["--json-status-fd", "4", "--block-fd", "5"]
));
assert!(has_sequence(
&staged.launch.arguments,
&["--ro-bind-fd", "6", TRAMPOLINE_PATH]
));
}
assert!(has_sequence(
&stdin.launch.arguments,
&[
"--setenv",
"GENARRATIVE_COMMAND_SANDBOX_TRAMPOLINE_FIXTURE",
"stdin"
]
));
assert!(has_sequence(
&process_session.launch.arguments,
&[
"--setenv",
"GENARRATIVE_COMMAND_SANDBOX_TRAMPOLINE_FIXTURE",
"process-session"
]
));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -58,6 +58,7 @@ mod isolated_agent;
mod patchset;
mod preview;
mod process_session;
mod process_session_bridge;
mod project;
mod repository_context;
mod runner;
@@ -1263,13 +1264,17 @@ fn main() {
}
}
#[cfg(target_os = "linux")]
if command_sandbox_trampoline::is_process_session_trampoline_mode(&args) {
match command_sandbox_trampoline::run_process_session_trampoline() {
Ok(exit_code) => std::process::exit(exit_code),
Err(_) => std::process::exit(125),
}
}
#[cfg(target_os = "linux")]
if is_process_session_child_mode(&args) {
match run_process_session_child(&args) {
Ok(exit_code) => std::process::exit(exit_code),
Err(error) => {
eprintln!("process.session.child.failed: {error}");
std::process::exit(1);
}
Err(_) => std::process::exit(125),
}
}
let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -30569,6 +30569,170 @@ async fn execute_approved_process_action_for_test(
(pending, observation)
}
#[cfg(target_os = "linux")]
async fn process_session_agent_runtime_start_audit_failure_fixture() {
const AGENT_ID: &str = "code-prototype";
const RUN_ID: &str = "code-process-start-audit-failure-run";
const TARGET_LOG: &str = "start-audit-target.log";
clear_process_session_registry_for_tests();
let root = unique_project_path();
init_local_game_project_at(
&root,
"process-start-audit-project",
"Agent Runtime 启动审计失败项目",
)
.expect("project init");
let _cleanup = ProcessSessionIntegrationCleanup {
root: root.clone(),
agent_id: AGENT_ID,
run_id: RUN_ID,
};
fs::write(
root.join("package.json"),
r#"{"private":true,"scripts":{"dev":"node process-start-audit-fixture.mjs"}}"#,
)
.expect("write start audit package");
fs::write(
root.join("process-start-audit-fixture.mjs"),
format!(
r#"import {{ appendFileSync }} from 'node:fs';
appendFileSync('{TARGET_LOG}', 'STARTED\n');
setInterval(() => appendFileSync('{TARGET_LOG}', 'TICK\n'), 20);
"#
),
)
.expect("write start audit fixture");
fs::write(
root.join(PROJECT_PERMISSION_POLICY_PATH),
r#"{"deniedCommands":[],"confirmCommands":[],"agentPolicies":{}}"#,
)
.expect("write legacy empty policy");
let mut state = start_game_creator_agent_runtime_task_at(
&root,
AGENT_ID,
"验证 command.start 在 Agent DB 审计失败后终止 target",
RUN_ID,
"agent-background-task",
"准备启动审计失败 fixture",
vec!["启动并确认失败关闭".to_string()],
)
.expect("start runtime");
state.loop_iteration = 1;
let start_action = AgentRuntimeToolAction {
tool: "command.start".to_string(),
reason: Some("启动持续写入 fixture 并注入 Agent DB 审计失败".to_string()),
input: serde_json::json!({
"program": "npm",
"args": ["run", "dev"],
"cwd": ".",
"timeoutSeconds": 30
}),
};
let start_pending = pending_tool_action_for_test(
&root,
&state,
start_action.clone(),
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
None,
);
write_game_creator_agent_runtime_pending_tool_action(&root, &start_pending)
.expect("write approved start action");
state.status = "running".to_string();
state.phase = "action".to_string();
state.pending_tool_action = Some(start_pending.summary());
append_game_creator_agent_runtime_task(&root, &state).expect("append approved start task");
write_game_creator_agent_runtime_state(&root, &state).expect("write approved start state");
write_game_creator_agent_runtime_tool_confirmation(
&root,
AGENT_ID,
RUN_ID,
"command.start",
&start_pending.action_fingerprint,
"确认启动 Agent DB 审计失败 fixture",
)
.expect("write start confirmation");
let agent_db = root.join(".agent/agent.db");
fs::remove_file(&agent_db).expect("remove agent db before start audit failure");
fs::create_dir(&agent_db).expect("replace agent db with directory");
let observation = execute_game_creator_agent_runtime_tool_action_with_pending_action(
&root,
AGENT_ID,
RUN_ID,
&state.current_task,
&start_action,
Some(&start_pending.action_id),
Some(&start_pending),
)
.await;
assert_eq!(
observation.status, "needs-reconciliation",
"{observation:?}"
);
let detail: Value = serde_json::from_str(
observation
.detail
.as_deref()
.expect("start audit failure detail"),
)
.expect("parse start audit failure detail");
assert_eq!(detail["status"], "needs-reconciliation");
assert_eq!(detail["needsReconciliation"], true);
assert_eq!(detail["launchFailureKind"], "start-audit-failed");
assert_eq!(detail["sandboxEstablishment"], "established");
assert_eq!(detail["targetExec"], "established");
let process_id = detail["processId"]
.as_str()
.expect("start audit failure processId");
let record = active_process_session_records_at(&root, Some(AGENT_ID), Some(RUN_ID))
.expect("read start audit failure record")
.into_iter()
.find(|record| record.process_id == process_id)
.expect("start audit failure process record");
assert_eq!(record.status, "needs-reconciliation");
assert!(record.needs_reconciliation);
assert!(!record.stdin_open);
assert_eq!(
record.launch_failure_kind.as_deref(),
Some("start-audit-failed")
);
assert_eq!(record.sandbox_establishment, "established");
assert_eq!(record.target_exec, "established");
let target_log = root.join(TARGET_LOG);
let mut previous_len = usize::MAX;
let mut stable_samples = 0;
for _ in 0..100 {
std::thread::sleep(Duration::from_millis(25));
let current_len = fs::metadata(&target_log)
.map(|metadata| usize::try_from(metadata.len()).unwrap_or(usize::MAX))
.unwrap_or(0);
if current_len == previous_len {
stable_samples += 1;
if stable_samples >= 8 {
break;
}
} else {
previous_len = current_len;
stable_samples = 0;
}
}
assert_eq!(
stable_samples, 8,
"business target kept writing after start audit failure"
);
let stopped_content = fs::read(&target_log).unwrap_or_default();
std::thread::sleep(Duration::from_millis(250));
assert_eq!(
fs::read(&target_log).unwrap_or_default(),
stopped_content,
"business target resumed after start audit failure"
);
}
async fn process_session_agent_runtime_confirmed_lifecycle_fixture() {
const READY_SENTINEL: &str = "PROCESS_SESSION_READY_PRIVATE";
const STDIN_SENTINEL: &str = "PROCESS_SESSION_STDIN_PRIVATE";
@@ -30948,3 +31112,28 @@ fn process_session_agent_runtime_confirmed_lifecycle_runs_in_isolated_registry()
.expect("spawn isolated process session integration test");
assert!(status.success(), "isolated lifecycle test failed: {status}");
}
#[cfg(target_os = "linux")]
#[test]
fn process_session_agent_runtime_start_audit_failure_terminates_target_in_isolated_registry() {
const CHILD_MARKER: &str =
"GENARRATIVE_PROCESS_SESSION_AGENT_RUNTIME_START_AUDIT_FAILURE_CHILD";
if std::env::var_os(CHILD_MARKER).is_some() {
tauri::async_runtime::block_on(process_session_agent_runtime_start_audit_failure_fixture());
return;
}
let status = std::process::Command::new(std::env::current_exe().expect("current test binary"))
.env(CHILD_MARKER, "1")
.args([
"--exact",
"tests::process_session_agent_runtime_start_audit_failure_terminates_target_in_isolated_registry",
"--nocapture",
"--test-threads=1",
])
.status()
.expect("spawn isolated process session start audit failure test");
assert!(
status.success(),
"isolated start audit failure test failed: {status}"
);
}
@@ -4301,3 +4301,17 @@
- 决策:bwrap 使用 fd 4/5 接收 status/block,运行中 App 可执行文件由父进程预打开并通过 fd 6 + `--ro-bind-fd` 挂到固定 trampoline 路径。pre-exec 先把全部源复制到 64 以上临时 FD,再统一映射到固定号,避免并发时源/目标 FD 重叠导致通道被覆盖。
- 决策:bwrap COMMAND 分隔符固定取 launcher 插入的第一个独立 `--`,不能从目标 argv 末尾反查。durable commit 到 exec verdict 之间禁止 async 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 已整体交付。
## 2026-07-14 AI 游戏创作 Agent Runtime V1.11.1 持久进程可信握手
- 决策:`command.start` 的 Runner 与 PTY child wrapper 使用 Linux abstract Unix socket 建立一次性私有 bridge,并同时校验 32 字节随机 nonce、`SO_PEERCRED` peer pid 和 uid。portable-pty argv 只保留内部 child mode,完整 bwrap/target launch plan 只走 bridgeendpoint、nonce、控制帧和宿主 launch plan 不进入 target argv/env、PTY transcript、process record、receipt 或 Agent DB。
- 决策:portable-pty 会关闭 fd 3 以上描述符,bubblewrap 也不会把未被 option 引用的 fd 3 传给最终 COMMAND,因此 process-session trampoline 仍以 fd 0 接收私有 gate。真实 target 的 stdin 由 trampoline 校验 fd 1 是 PTY 后复制同一 slave 得到;一次性命令继续使用 `/dev/null` stdin,两个模式不能混用。
- 决策:process record 升级为 v3,新增 `sandboxEstablishment / targetExec / launchFailureKind / sandboxReadyAt / execEstablishedAt`。Runtime 只在 `SANDBOX_READY` 后推进 revision、清除旧验证凭证并最后写入 `launching + established/not-attempted` commit record;只有 `EXEC_ESTABLISHED` 后才写 running、注册 live session、启动业务 timeout 并返回零消费 cursor。明确 `TARGET_EXEC_FAILED` 写同一 processId 的 failed recordcommit 后未知写 `launch-unknown + needs-reconciliation`
- 决策:v1/v2 active record 无条件迁移为 `unknown / unknown + legacy-active-record + needs-reconciliation`,不按 PID 重连或自动重放;历史 terminal record 保留原终态和 transcript,可用 `unknown / unknown + legacy-record` 惰性读取。target exit 0/7 均沿原 processId 收束。
- 决策:PTY wrapper spawn 前先登记 pending launch reservationRunner shutdown 和 idle/final 门禁必须看见 reservationpid 激活前收到 shutdown 也必须取消,激活后终止整个 wrapper 进程组。start Agent DB 审计失败必须终止 live process 并把 record 标为 `start-audit-failed + needs-reconciliation`
- 决策:process-session child 在 sandbox-ready 后只接受父侧显式 `COMMIT_EXEC / ABORT_LAUNCH`,不使用独立 3 秒 commit timeout。durable callback 慢于 launcher setup timeout 时 target 继续保持零执行;父侧失败必须先发送 abort,再 kill/wait/reap containment tree。
- 决策:Linux process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,原子完成 setpgid + tcsetpgrp 并恢复信号掩码后才 exec,避免 immediate stdin read 以后台组停在 SIGTTIN。graceful terminate 经 Runtime bridge 和 trampoline 私有控制帧只向 target group 发送 SIGTERMdirect leader 先退出时 trampoline 仍检查同组后代,wrapper/bwrap 保持最多 800ms 宽限并继续承载 PTY,宽限后再强杀外层 containment group。Runner 强杀仍依赖 owner monitor 与 bwrap die-with-parent 回收整个 namespace。
- 决策:process record v3 使用封闭 launch 状态矩阵和逐项时间校验。`launch-unknown / start-audit-failed` 必须对应 `needs-reconciliation=true`,明确 target exec failure 只能是 `established/failed`;旧 boot prepared/launching 降级 target 为 unknown,非法 record 读取失败关闭,不能绕过 final/idle。Windows legacy start 的 durable callback 移到首次 action miss 之后,同 action replay 只返回原 processId,不重复推进 revision。
- 决策:`command.stdin` 写入和 flush 成功后,若 target 在 writer 释放后先形成可信 terminal,仍按成功返回并持久化 `stdinOpen=false`;只有写入部分失败或结果 record 无法落盘才进入 reconciliation。
- 决策:active process session 事实由 live registry、durable active/reconciliation record 和 Linux pending reservation 并集构成。capacity、cancel、final 和 runner idle 都必须先合并 live registryrecord 被删除或改名不能让 live session 失败开放,损坏 record 仍读取失败关闭。non-Linux live record 的 started/ready/exec 使用同一 launch 时间点,避免跨秒后违反 v3 时间顺序。
- 边界:确定性 bridge、PTY、迁移、fast-exit、target-exec-failed 和零执行测试通过后,只能宣称本地 Runtime 链路完成;真实 Provider `process-session` 与 Runner kill 套件重新通过前,不新增 V1.11.1 Provider PASS 结论。
@@ -2917,3 +2917,27 @@
- 处理:构造器保证 bwrap options 与 COMMAND 之间只有第一个独立 `--` 是 launcher 分隔符;preflight 和 stage 都取第一个位置。不要从用户目标 argv 的末尾推断结构边界。
- 验证:真实 staged bwrap 用例必须让目标 argv 带独立 `--`,同时断言 sandbox-ready/commit 前 marker 为零,commit 后才执行成功。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs``command_exec.rs`
## PTY 私有控制 FD 不能假设会被 bubblewrap 透传
- 现象:process-session wrapper 在 portable-pty 之后成功创建 fd 3 控制 socket,但 trampoline 收不到 ready/commit 帧;如果直接改用 fd 0,真实 target 又失去交互 stdin。
- 原因:portable-pty 在 wrapper exec 前关闭全部 fd 3 以上描述符;wrapper 重新创建 fd 3 后,bubblewrap 仍只保留 stdio 和被 `--json-status-fd / --block-fd / --ro-bind-fd` 明确引用的描述符,未引用 fd 3 不是可靠 COMMAND 继承通道。
- 处理:Runner 与 wrapper 先用 abstract Unix socket bridge 传递私有 launch planwrapper 内部 gate 继续通过 bwrap 会保留的 fd 0进入 trampoline。process-session trampoline 不把控制 fd 0传给 target,而是确认 fd 1 是 PTY 后复制同一 slave作为 target stdin;一次性命令模式仍显式使用 `/dev/null`
- 验证:真实 PTY 测试必须完成 readiness、stdin echo 和 terminatetrampoline 测试同时断言一次性 stdin 为 null、process-session stdin 可读 PTYtarget fd 3/4/5/6 不存在,bridge endpoint/nonce/control frame 不出现在 transcript 和公共持久面。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs``process_session.rs``command_sandbox.rs``command_sandbox_trampoline.rs`
## graceful terminate 不能先杀承载 target 的 wrapper 进程组
- 现象:target 注册了 SIGTERM 清理逻辑,但 `command.terminate` 只偶尔出现 stopped marker;耗时 300-500ms 的清理经常被提前截断。
- 原因:如果先向 wrapper/bwrap/trampoline/target 共用的外层进程组发送 SIGTERMwrapper 会先退出,bwrap 的 die-with-parent 随即收走 namespace;名义上的 800ms 宽限并没有真正留给 target。
- 处理:process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 setpgid + PTY slave tcsetpgrp 并恢复信号掩码后才 exec;不能先 spawn 到后台组再由 parent 设前台,否则 target 可能已经因 immediate read 收到 SIGTTIN。Runtime 通过两级私有控制通道请求 trampoline 只向 target group 发 SIGTERM。direct leader 退出后 trampoline 继续检查同组后代,外层 wrapper/bwrap 在最多 800ms 宽限期保持存活,超时才强杀 containment group。
- 验证:使用直接 bash target 启动同组后台子进程;leader 在输出 READY 后自然退出,仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs``process_session_bridge.rs``command_sandbox_trampoline.rs`
## 启动记录必须封闭状态组合,child 不能自行猜 durable commit 超时
- 现象:慢磁盘让 sandbox-ready 后的 child 在父侧持久化完成前自行退出,父侧随后把零执行误记为 launch-unknown;损坏的 `failed + launch-unknown + needsReconciliation=false` record 又可能被 active/final/idle 扫描漏掉。
- 原因:child 与父侧各自维护短 timeout,没有统一 commit/abort 决策;record 校验只检查枚举和值存在,没有约束 status、sandbox、target、failure、reconciliation 与时间戳的合法组合。Windows 如果在 action 去重前调用 durable callback,还会在同 action replay 时重复推进 revision。
- 处理:sandbox-ready 后 child 阻塞等待父侧显式 commit 或 abort,父侧失败时发送 abort 并回收树。v3 读取使用封闭状态矩阵和 `started <= ready <= exec <= terminal <= updated` 的逐项可选时间校验;旧 boot prepared/launching 及同 action start replay转成 target unknown。非 Linux durable callback 只放在 existing action miss 分支,started/ready/exec 使用同一时间点。live registry 必须与 durable record、pending reservation 合并参与 capacity/final/idle,不能因 record 缺失失败开放。
- 验证:durable callback 延迟超过旧 3 秒时 target marker 在 callback 内必须仍不存在、commit 后才出现;构造 launch-unknown/start-audit/target-exec 的非法组合均拒绝读取,旧 boot launching 和 same-action replay必须变成可再次读取的 reconciliation,同 action Windows 测试只调用一次 callback;删除 live record 后 final/idle 仍被 registry 阻断。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs``process_session_bridge.rs``docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`
@@ -585,7 +585,13 @@ V1.11.1 必须把 `prepared -> child-created -> sandbox-ready -> commit-persiste
2026-07-14 第一实现切片已接入一次性命令:Linux launcher 用 `--json-status-fd` 取得 child-created 后才写入 `--block-fd` 放行,运行中 App 可执行文件通过预打开 FD 和 `--ro-bind-fd` 固定挂入 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 第二实现切片已把 `command.start` 接入可信握手。Runner 在 portable-pty spawn 前预留 pending launch,并通过 Linux abstract Unix socket、随机 nonce 与 `SO_PEERCRED` 校验后的 child wrapper交换私有 launch plan和 ready/commit/exec/terminal 帧;wrapper argv 不再携带 bwrap/target planbridge FD 显式 CLOEXEC。由于 portable-pty 会关闭 fd 3 以上描述符且 bubblewrap 不可靠透传未引用 fd 3process-session trampoline 仍以 fd 0承载私有 gate,再从已验证的 PTY fd 1复制 slave作为真实 target stdintarget stdout/stderr继续继承 PTY,一次性命令仍恢复 `/dev/null` stdin
process record 已升级为 v3:只在 `SANDBOX_READY` 后执行 revision / verification durable mutation并最后写入 `launching + established/not-attempted + sandboxReadyAt``EXEC_ESTABLISHED` 后才写 running、注册 live session、启动 timeout和返回同一 processId 的零消费 cursor。target 不存在或无权限形成 `established/failed/target-exec-failed` terminal recordfast exit 0/7 继续沿同一 processId收束;commit 后未知、exec 后持久化失败和 start Agent DB 审计失败进入 reconciliation。v1/v2 active record 无条件迁移为 unknown + reconciliationlegacy terminal 和 v1/v2 transcript继续可读;pending reservation参与 shutdown、capacity和 idle/final门禁。v3 读取使用封闭状态矩阵和完整时间顺序校验,`launch-unknown / start-audit-failed` 必须 fail-closed 为 reconciliation;旧 boot 的 prepared / launching 及同 action start replay统一降级 target exec 为 unknown,不能通过伪造 failed record 绕过 final / idle。active/capacity/final/idle 扫描先合并 live registry 与 durable recordrecord 缺失不能让仍运行的 session 失败开放,损坏 record 仍读取失败关闭。
process-session child 在 `SANDBOX_READY` 后阻塞等待父侧显式 `COMMIT_EXEC / ABORT_LAUNCH`,不设置独立短 commit timeoutdurable mutation 超过 3 秒仍保持 target 零执行,父侧失败时显式 abort 并回收 wrapper 树。target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 `setpgid + tcsetpgrp` 并恢复信号掩码后才 exec,避免目标以后台组立即读取 PTY 而停在 SIGTTIN。`command.terminate` 经 Runtime bridge 和 trampoline 私有控制帧只向 target group 发 SIGTERM;即使 direct leader 先退出,trampoline 仍在 800ms 宽限内等待同组后代完成清理,wrapper / bwrap 继续承载 PTY 与控制链,超时后才强杀外层 containment group。正常 EOF 写入成功后即使可信 terminal 先于状态落盘,也按成功返回而不是误标 reconciliationWindows legacy start 的 durable callback 只在同 action首次创建时执行,ready/exec/started 使用同一时间点避免跨秒倒序。
本地确定性测试已覆盖真实 PTY stdin/echo/EOF/terminate、direct leader 先退出后同组后代完成 400ms SIGTERM 清理、3.2 秒慢 durable callback、workspace sandbox后代、commit callback失败目标零执行、target exec failure、fast exit 0/7、wrong nonce/peer/乱序、v1/v2 active/terminal迁移、v3 非法组合、旧 boot launching及同 action replay、live record 缺失门禁、start Agent DB 审计失败、start cursor零消费和控制材料不进入 transcript。该实现完成 V1.11.1 本地 Runtime 链路,但真实 Provider `process-session` 与 Runner kill 套件仍须按新协议重跑;在它们通过前,不新增 Provider PASS、统计条数或整体“已验收”结论。
2026-07-14 最新真实 `gpt-5.5` `llm-runtime` 已按新增 metadata 门禁通过:123 条 task、208 条 event、220 条 Agent DB、15 次成功工具执行、2 次 `command.exec`(先失败后成功)、1 次 `project.verify`、3 个隔离实例、双视口浏览器验证、唯一 completed / assistantRunner 强杀后 run / session 身份稳定恢复,重复、副作用重放、密钥和诱饵泄漏均为 0。保留现场独立核对 2 条 command.exec 和 1 条 project.verify 审计均为 `bubblewrap / workspace-write / disabled / workspace-v1` 后按 sentinel 清理。
@@ -34,6 +34,8 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod
2026-07-14 V1.11.1 第一切片:`command.exec / project.verify` 已共用受信任 trampoline launcher。bwrap 的 `child-pid` 只推进 child-created`--block-fd` 放行后仍须收到 `SANDBOX_READY`Runtime 完成 revision / verification durable callback 后才发送 `COMMIT_EXEC`,收到 `EXEC_ESTABLISHED` 后才计算业务 timeout。当前不把这套 stdin 私有控制通道用于 PTY;`command.start` 与 process record v3 仍是下一切片,相关链路完成前 V1.11.1 保持进行中。
2026-07-14 V1.11.1 第二切片:`command.start` 已通过 PTY 外 abstract Unix socket bridge 接入同一 ready/commit/exec 状态机,process record 升级 v3;真实 target 的 PTY stdin 由 trampoline 复制已验证的 fd 1 slave,不向 target 泄漏 fd 0控制通道。pending launch和 live registry共同参与 capacity/shutdown/idle/final门禁,旧 v1/v2 active record迁入 reconciliationtarget exec failure和fast exit保留同一 processId。child 等待父侧显式 commit/abort,不以短 timeout 猜测持久化失败;target 在 pre-exec 内原子进入 PTY 前台进程组,graceful terminate 只给 target group 发信号,direct leader 先退出后仍给同组后代保留清理宽限。v3 状态组合和时间顺序失败关闭,旧 boot launching及同 action replay降级为 launch-unknownWindows 同 action replay 不重复执行 durable mutation且使用单一 launch 时间点。确定性测试通过后,V1.11.1 仍等待真实 Provider `process-session` 和 Runner kill复验,不提前标记整体已验收。
2026-07-12 真实验收:发布 AppData 中的真实 `gpt-5.5` 已通过最终安全收紧后的 `llm-runtime` 套件,覆盖 Runner 强杀恢复且 run/session 身份稳定、仓库上下文、checkpoint/精确修改、失败命令诊断与修复复验、6 套确认生命周期、项目验证、桌面与移动非空画布证据、3 个隔离实例并行和唯一 all-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、重复副作用与密钥扫描继续全部通过。