Merge branch 'master' into fix/codex/agc-error-convergence-regression
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 13:31:50 +08:00
9 changed files with 1292 additions and 60 deletions
@@ -103,6 +103,16 @@ pub(crate) struct ProjectCommandSpec {
pub(crate) cwd: PathBuf,
pub(crate) timeout_seconds: u64,
pub(crate) verification_eligible: bool,
/// Linux 上 npm 的真实启动锚点:`(node, npm-cli.js)`。保留 `executable` 为 npm shim 以
/// 维持既有 program / verification 口径,实际进程改由 node 直接运行 npm-cli.js,避免
/// `#!/usr/bin/env node` 在沙箱里落到系统 node。
pub(crate) node_launcher: Option<(PathBuf, PathBuf)>,
}
struct ResolvedProjectCommandExecutable {
executable: PathBuf,
safe_path: OsString,
node_launcher: Option<(PathBuf, PathBuf)>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -538,6 +548,7 @@ where
cwd: root.to_path_buf(),
timeout_seconds: 15,
verification_eligible: false,
node_launcher: None,
};
let launch = prepare_project_command_launch_spec(root, &spec)?;
let mut staged = stage_project_command_launch_spec(&spec, launch)?;
@@ -645,17 +656,18 @@ pub(crate) fn resolve_project_bootstrap_spec_at(
));
}
let program = "npm".to_string();
let (executable, safe_path) = resolve_project_command_executable(root, &program)
let resolved = resolve_project_command_executable(root, &program)
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?;
Ok(ProjectCommandSpec {
program,
executable,
safe_path,
executable: resolved.executable,
safe_path: resolved.safe_path,
arguments: vec!["install".to_string()],
cwd_relative,
cwd,
timeout_seconds,
verification_eligible: false,
node_launcher: resolved.node_launcher,
})
}
@@ -689,17 +701,18 @@ fn resolve_project_command_spec_inner(
if program == "node" {
validate_project_command_node_test_files(&cwd, arguments)?;
}
let (executable, safe_path) = resolve_project_command_executable(root, &program)?;
let resolved = resolve_project_command_executable(root, &program)?;
let verification_eligible = project_command_verification_eligible(&program, arguments);
Ok(ProjectCommandSpec {
program,
executable,
safe_path,
executable: resolved.executable,
safe_path: resolved.safe_path,
arguments: arguments.to_vec(),
cwd_relative,
cwd,
timeout_seconds,
verification_eligible,
node_launcher: resolved.node_launcher,
})
}
@@ -887,36 +900,81 @@ fn project_command_npm_verification_script_suffix_allowed(suffix: &str) -> bool
fn resolve_project_command_executable(
root: &Path,
program: &str,
) -> Result<(PathBuf, OsString), String> {
) -> Result<ResolvedProjectCommandExecutable, String> {
if matches!(program, "node" | "npm") {
let runtime = crate::environment_check::resolve_node_runtime(root)?;
let executable = if program == "node" {
runtime.node.clone()
} else {
runtime
.node
.parent()
.ok_or("node-runtime-invalid")?
.join(if cfg!(windows) { "npm.cmd" } else { "npm" })
};
if program == "node" {
return Ok(ResolvedProjectCommandExecutable {
executable: runtime.node,
safe_path: runtime.safe_path,
node_launcher: None,
});
}
let executable = runtime
.node
.parent()
.ok_or("node-runtime-invalid")?
.join(if cfg!(windows) { "npm.cmd" } else { "npm" });
if !executable.is_file() {
return Err("npm-runtime-missing-launcher".into());
}
return Ok((executable, runtime.safe_path));
#[cfg(target_os = "linux")]
let node_launcher = Some((runtime.node.clone(), runtime.npm_cli.clone()));
#[cfg(not(target_os = "linux"))]
let node_launcher = None;
return Ok(ResolvedProjectCommandExecutable {
executable,
safe_path: runtime.safe_path,
node_launcher,
});
}
#[cfg(target_os = "linux")]
let path = std::env::join_paths([
PathBuf::from("/usr/local/sbin"),
PathBuf::from("/usr/local/bin"),
PathBuf::from("/usr/sbin"),
PathBuf::from("/usr/bin"),
PathBuf::from("/sbin"),
PathBuf::from("/bin"),
])
.map_err(|error| format!("构造 command.exec 受信任系统 PATH 失败:{error}"))?;
let path = {
// 先信任客户端解析出的 Node 工具链 bin(fnm / nvm / 系统),再落到系统目录;
// npx / corepack / pnpm / yarn 等随之在沙箱里与 node 同版本。
let mut directories = Vec::new();
if let Ok(runtime) = crate::environment_check::resolve_node_runtime(root) {
if let Some(bin) = runtime.node.parent() {
directories.push(bin.to_path_buf());
}
}
directories.extend([
PathBuf::from("/usr/local/sbin"),
PathBuf::from("/usr/local/bin"),
PathBuf::from("/usr/sbin"),
PathBuf::from("/usr/bin"),
PathBuf::from("/sbin"),
PathBuf::from("/bin"),
]);
std::env::join_paths(directories)
.map_err(|error| format!("构造 command.exec 受信任系统 PATH 失败:{error}"))?
};
#[cfg(not(target_os = "linux"))]
let path = std::env::var_os("PATH").ok_or_else(|| "command.exec 缺少 PATH".to_string())?;
resolve_project_command_executable_from_path(root, program, &path)
let (executable, safe_path) =
resolve_project_command_executable_from_path(root, program, &path)?;
Ok(ResolvedProjectCommandExecutable {
executable,
safe_path,
node_launcher: None,
})
}
/// Linux 上 npm 以 `node <npm-cli.js> <args>` 启动;其余情况保持 `executable + args`。
pub(crate) fn project_command_actual_target(spec: &ProjectCommandSpec) -> (PathBuf, Vec<String>) {
#[cfg(target_os = "linux")]
{
if let Some((node, npm_cli)) = spec.node_launcher.as_ref() {
let mut arguments = Vec::with_capacity(spec.arguments.len() + 1);
arguments.push(npm_cli.to_string_lossy().into_owned());
arguments.extend(spec.arguments.iter().cloned());
return (node.clone(), arguments);
}
}
(
spec.executable.clone(),
project_command_actual_arguments(spec),
)
}
fn resolve_project_command_executable_from_path(
@@ -1604,13 +1662,16 @@ pub(crate) fn prepare_project_command_launch_spec(
}
}
#[cfg(target_os = "linux")]
let (target_executable, target_arguments) = project_command_actual_target(spec);
#[cfg(not(target_os = "linux"))]
let arguments = project_command_actual_arguments(spec);
#[cfg(target_os = "linux")]
{
let sandbox = prepare_command_sandbox_launch(
root,
&spec.executable,
&arguments,
&target_executable,
&target_arguments,
&spec.cwd,
&environment,
)
@@ -1713,7 +1774,8 @@ pub(crate) fn stage_project_command_launch_spec(
) -> Result<StagedProjectCommandLaunchSpec, ProjectCommandError> {
#[cfg(target_os = "linux")]
{
let target_arguments = project_command_actual_arguments(spec)
let (target_executable, target_arguments) = project_command_actual_target(spec);
let target_arguments = target_arguments
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>();
@@ -1725,7 +1787,7 @@ pub(crate) fn stage_project_command_launch_spec(
environment: launch.environment,
metadata: command_sandbox_platform_metadata(),
},
&spec.executable,
&target_executable,
&target_arguments,
)
.map_err(|error| {
@@ -2593,6 +2655,34 @@ where
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn npm_command_targets_node_plus_npm_cli_on_linux() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("game")).unwrap();
let spec = resolve_project_command_spec_at(
root.path(),
"npm",
&["run".to_string(), "build".to_string()],
".",
30,
)
.unwrap();
let (executable, arguments) = project_command_actual_target(&spec);
assert_eq!(
executable.file_name().and_then(|name| name.to_str()),
Some("node"),
"{executable:?}"
);
assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}");
assert_eq!(&arguments[1..], ["run".to_string(), "build".to_string()]);
let bootstrap = resolve_project_bootstrap_spec_at(root.path(), 30).unwrap();
let (_, arguments) = project_command_actual_target(&bootstrap);
assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}");
assert_eq!(arguments[1], "install");
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn exited_group_accepts_orphan_zombies_but_rejects_live_members() {
@@ -2792,6 +2882,7 @@ mod tests {
cwd: root.to_path_buf(),
timeout_seconds: 20,
verification_eligible: false,
node_launcher: None,
};
let staged = StagedProjectCommandLaunchSpec {
launch: ProjectCommandLaunchSpec {
@@ -295,16 +295,7 @@ mod linux {
environment: &[(OsString, OsString)],
) -> Result<CommandSandboxLaunch, CommandSandboxError> {
let mut metadata = CommandSandboxMetadata::enforced_linux();
let network_enabled = executable
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| {
name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd")
})
&& arguments
.first()
.is_some_and(|argument| argument == "install");
if network_enabled {
if command_sandbox_requests_npm_install(executable, arguments) {
metadata.network = "enabled";
}
let bwrap = find_trusted_bwrap().map_err(|error| {
@@ -365,6 +356,14 @@ mod linux {
)
},
)?;
let node_read_only = collect_node_toolchain_mounts(&root, &executable, &target_environment)
.map_err(|error| {
CommandSandboxError::new(
format!("command sandbox node toolchain mount 无效:{error}"),
metadata.clone(),
)
})?;
let external_read_only = merge_read_only_mounts(external_read_only, node_read_only);
let fixed_system_read_only = collect_fixed_system_mounts();
let mut launch = build_linux_bwrap_launch(LinuxSandboxPlan {
@@ -465,6 +464,11 @@ mod linux {
let mut values = BTreeMap::<OsString, OsString>::new();
for (name, value) in environment {
validate_environment_name(name)?;
if name == OsStr::new("FNM_MULTISHELL_PATH") {
// fnm 的 multishell 路径位于 /run 下,而 sandbox 用 tmpfs 覆盖 /run;
// 保留会让子进程按一个不存在的目录查找 node。
continue;
}
let replacement = match name.to_str().unwrap_or_default() {
"HOME" | "USERPROFILE" => Some(COMMAND_SANDBOX_PRIVATE_HOME),
"TMPDIR" | "TEMP" | "TMP" => Some("/tmp"),
@@ -542,6 +546,108 @@ mod linux {
.collect())
}
/// `npm install` 是唯一允许联网的受控入口。Linux 以 `node <npm-cli.js> install` 启动时
/// executable 是 node,网络判定不能只看 executable 文件名。
fn command_sandbox_requests_npm_install(executable: &Path, arguments: &[String]) -> bool {
let npm_name = executable
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| {
name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd")
});
if npm_name {
return arguments
.first()
.is_some_and(|argument| argument == "install");
}
arguments
.first()
.map(Path::new)
.and_then(Path::file_name)
.and_then(OsStr::to_str)
.is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js"))
&& arguments
.get(1)
.is_some_and(|argument| argument == "install")
}
/// fnm / nvm / 系统 / 随包 Node 的安装前缀必须整棵只读挂进沙箱:只挂单个 `node` 或 `npm`
/// shim 会让 `#!/usr/bin/env node` 落到系统 node,并让 npm 的 `../lib/node_modules/npm`
/// 相对 require 失效。前缀本身仍走与主机发现共用的窄叶校验。
fn collect_node_toolchain_mounts(
root: &Path,
executable: &Path,
environment: &[(OsString, OsString)],
) -> Result<Vec<ReadOnlyMount>, String> {
let mut mounts = BTreeMap::<PathBuf, PathBuf>::new();
for candidate in node_installation_candidates(executable, environment) {
if candidate.starts_with(root) {
continue;
}
let Ok(prefix) =
crate::environment_check::validate_node_installation_prefix(&candidate)
else {
continue;
};
add_external_mount(root, &prefix, &prefix, &mut mounts)?;
}
Ok(mounts
.into_iter()
.map(|(destination, source)| ReadOnlyMount {
source,
destination,
})
.collect())
}
fn node_installation_candidates(
executable: &Path,
environment: &[(OsString, OsString)],
) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Ok(canonical) = fs::canonicalize(executable) {
push_node_prefix_candidates(&canonical, &mut candidates);
}
if let Some(path) = environment
.iter()
.find(|(name, _)| name == OsStr::new("PATH"))
.map(|(_, value)| value)
{
for directory in std::env::split_paths(path) {
if !directory.is_absolute() {
continue;
}
let Ok(directory) = fs::canonicalize(&directory) else {
continue;
};
if directory.is_dir() {
push_node_prefix_candidates(&directory.join("node"), &mut candidates);
}
}
}
candidates
}
fn push_node_prefix_candidates(path: &Path, candidates: &mut Vec<PathBuf>) {
let Some(parent) = path.parent() else {
return;
};
for ancestor in parent.ancestors().take(5) {
candidates.push(ancestor.to_path_buf());
}
}
fn merge_read_only_mounts(
first: Vec<ReadOnlyMount>,
second: Vec<ReadOnlyMount>,
) -> Vec<ReadOnlyMount> {
let mut mounts = first;
mounts.extend(second);
mounts.sort_by(|left, right| left.destination.cmp(&right.destination));
mounts.dedup_by(|left, right| left.destination == right.destination);
mounts
}
fn validate_external_toolchain_root(name: &str, root: &Path) -> Result<(), String> {
if root.parent() == Some(Path::new("/home")) {
return Err(format!(
@@ -609,16 +715,7 @@ mod linux {
let mut args = Vec::<OsString>::new();
push_namespace_arguments(
&mut args,
plan.executable
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| {
name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd")
})
&& plan
.arguments
.first()
.is_some_and(|argument| argument == "install"),
command_sandbox_requests_npm_install(&plan.executable, &plan.arguments),
);
push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr"));
for (target, destination) in &plan.merged_usr_links {
@@ -1150,6 +1247,113 @@ mod linux {
assert_eq!(mounts[0].source, rustup_home);
}
fn install_node_prefix_fixture(prefix: &Path) {
use std::os::unix::fs::PermissionsExt;
let bin = prefix.join("bin");
let npm = prefix.join("lib/node_modules/npm/bin");
std::fs::create_dir_all(&bin).expect("create node bin");
std::fs::create_dir_all(&npm).expect("create npm bin");
let node = bin.join("node");
std::fs::write(&node, b"node").expect("write node");
std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o755))
.expect("chmod node");
std::fs::write(npm.join("npm-cli.js"), b"npm").expect("write npm-cli");
}
#[test]
fn node_installation_prefix_is_mounted_read_only_from_executable_and_path() {
let tree = unique_temp_tree();
let root = tree.0.join("workspace-node-prefix");
let installation = tree
.0
.join("fnm")
.join("node-versions")
.join("v22.23.3")
.join("installation");
std::fs::create_dir_all(&root).expect("create workspace");
install_node_prefix_fixture(&installation);
let node = installation.join("bin/node");
let mounts = collect_node_toolchain_mounts(
&root,
&node,
&[(
OsString::from("PATH"),
installation.join("bin").into_os_string(),
)],
)
.expect("node installation prefix should mount");
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].source, installation.canonicalize().unwrap());
assert_eq!(mounts[0].destination, installation.canonicalize().unwrap());
}
#[test]
fn node_toolchain_mount_skips_wide_and_incomplete_candidates() {
let tree = unique_temp_tree();
let root = tree.0.join("workspace-node-skip");
std::fs::create_dir_all(&root).expect("create workspace");
let incomplete = tree.0.join("incomplete-node");
std::fs::create_dir_all(incomplete.join("bin")).expect("create bin");
std::fs::write(incomplete.join("bin/node"), b"node").expect("write node");
let mounts = collect_node_toolchain_mounts(
&root,
&incomplete.join("bin/node"),
&[(
OsString::from("PATH"),
OsString::from("/usr/bin:/root:/tmp:/"),
)],
)
.expect("wide or incomplete candidates must be skipped, not fatal");
assert!(mounts.is_empty(), "unexpected mounts: {mounts:?}");
}
#[test]
fn normalize_target_environment_drops_fnm_multishell_path() {
let values = normalize_target_environment(&[
(
OsString::from("FNM_MULTISHELL_PATH"),
OsString::from("/run/user/0/fnm_multishells/1_2"),
),
(OsString::from("PATH"), OsString::from("/usr/bin")),
])
.expect("normalize environment");
assert!(!values
.iter()
.any(|(name, _)| name == OsStr::new("FNM_MULTISHELL_PATH")));
assert!(
values
.iter()
.any(|(name, value)| name == OsStr::new("PATH")
&& value == OsStr::new("/usr/bin"))
);
}
#[test]
fn npm_install_network_detection_supports_node_launcher() {
assert!(command_sandbox_requests_npm_install(
Path::new("/opt/node/bin/node"),
&[
"/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(),
"install".to_string(),
],
));
assert!(!command_sandbox_requests_npm_install(
Path::new("/opt/node/bin/node"),
&[
"/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(),
"run".to_string(),
"build".to_string(),
],
));
assert!(command_sandbox_requests_npm_install(
Path::new("/usr/bin/npm"),
&["install".to_string()],
));
}
struct TempTree(PathBuf);
impl Drop for TempTree {
@@ -1283,6 +1487,130 @@ print("SANDBOX_OK")
}
}
#[test]
fn command_sandbox_real_linux_opt_in_runs_host_node_and_npm_cli() {
if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() {
return;
}
let tree = unique_temp_tree();
let root = tree.0.join("workspace-node-runtime");
std::fs::create_dir_all(root.join(".agent")).expect("create runtime control");
let Ok(runtime) = crate::environment_check::resolve_node_runtime(&root) else {
return;
};
let environment = vec![
(OsString::from("PATH"), runtime.safe_path.clone()),
(OsString::from("HOME"), OsString::from("/host/home")),
];
let run = |executable: &Path, arguments: &[String]| {
let launch = prepare_command_sandbox_launch(
&root,
executable,
arguments,
&root,
&environment,
)
.expect("prepare node sandbox");
let output = Command::new(&launch.executable)
.args(&launch.arguments)
.current_dir(&launch.cwd)
.env_clear()
.envs(launch.environment.iter().cloned())
.output()
.expect("run node sandbox");
assert!(
output.status.success(),
"stderr={}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
};
let version = run(
&runtime.node,
&[
"-e".to_string(),
"process.stdout.write(process.version)".to_string(),
],
);
assert!(
version.starts_with('v'),
"unexpected node version: {version}"
);
let npm_cli = runtime.npm_cli.to_string_lossy().into_owned();
let npm_version = run(&runtime.node, &[npm_cli, "--version".to_string()]);
assert!(!npm_version.is_empty(), "npm --version returned nothing");
}
#[test]
fn command_sandbox_real_linux_opt_in_runs_nvm_installation_prefix() {
if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() {
return;
}
let Some(nvm_dir) = std::env::var_os("NVM_DIR").map(PathBuf::from) else {
return;
};
let versions_dir = nvm_dir.join("versions").join("node");
let Ok(entries) = std::fs::read_dir(&versions_dir) else {
return;
};
let mut versions = entries
.flatten()
.filter_map(|entry| entry.file_name().to_str().map(str::to_string))
.collect::<Vec<_>>();
versions.sort();
let Some(version) = versions.pop() else {
return;
};
let prefix = crate::environment_check::validate_node_installation_prefix(
&versions_dir.join(&version),
)
.expect("nvm 安装前缀应通过窄叶校验");
let node = prefix.join("bin").join("node");
let npm_cli = prefix.join("lib/node_modules/npm/bin/npm-cli.js");
let tree = unique_temp_tree();
let root = tree.0.join("workspace-nvm-runtime");
std::fs::create_dir_all(root.join(".agent")).expect("create runtime control");
let environment = vec![
(OsString::from("PATH"), prefix.join("bin").into_os_string()),
(OsString::from("HOME"), OsString::from("/host/home")),
];
let run = |arguments: &[String]| {
let launch =
prepare_command_sandbox_launch(&root, &node, arguments, &root, &environment)
.expect("prepare nvm sandbox");
let output = Command::new(&launch.executable)
.args(&launch.arguments)
.current_dir(&launch.cwd)
.env_clear()
.envs(launch.environment.iter().cloned())
.output()
.expect("run nvm sandbox");
assert!(
output.status.success(),
"stderr={}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
};
let version = run(&[
"-e".to_string(),
"process.stdout.write(process.version)".to_string(),
]);
assert!(
version.starts_with('v'),
"unexpected node version: {version}"
);
let npm_version = run(&[
npm_cli.to_string_lossy().into_owned(),
"--version".to_string(),
]);
assert!(!npm_version.is_empty(), "npm --version returned nothing");
}
#[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() {
File diff suppressed because it is too large Load Diff
@@ -44,9 +44,11 @@ mod linux {
launch: &ProjectCommandLaunchSpec,
spec: &ProjectCommandSpec,
) -> Result<Self, String> {
let (target_executable, target_arguments) =
crate::command_exec::project_command_actual_target(spec);
if !launch.executable.is_absolute()
|| !launch.cwd.is_absolute()
|| !spec.executable.is_absolute()
|| !target_executable.is_absolute()
{
return Err("process session bridge launch path 必须是绝对路径".to_string());
}
@@ -68,8 +70,8 @@ mod linux {
)
})
.collect(),
target_executable: spec.executable.as_os_str().as_bytes().to_vec(),
target_arguments: crate::command_exec::project_command_actual_arguments(spec)
target_executable: target_executable.as_os_str().as_bytes().to_vec(),
target_arguments: target_arguments
.into_iter()
.map(|argument| OsString::from(argument).into_vec())
.collect(),
@@ -0,0 +1,54 @@
# 【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07
| 字段 | 值 |
| ----------- | ------------------------------------------------------------------- |
| Version | 1.0 |
| Status | implemented-awaiting-runtime-acceptance |
| Date | 2026-10-07 |
| Parent Spec | `docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` |
## 目标
开发构建的 Linux 命令沙箱能原生解析并复用宿主 fnm / nvm 托管的 Node 安装:宿主发现与沙箱只读挂载使用同一套窄叶校验;`.nvmrc` / `.node-version` pin 权威、`engines.node` 仅为偏好;npm 以受信任 `node <npm-cli.js>` 形态启动且不丢失 `npm install` 的联网判定。不再要求用户改系统 Node、把宿主 shim 指向 `/usr/bin`,或把托管目录软链进系统路径。
## 范围
- 移除把 `node` / `npm` / `npx` 指向 `/usr/bin/*` 的宿主 shim 依赖,开发构建默认解析托管安装。
- 枚举 fnm `node-versions/<version>/installation`(含 `aliases/default`)与 nvm `versions/node/<version>`,按窄叶规则校验完整安装前缀。
- 版本选择:`.nvmrc` / `.node-version` pin > PATH 可解析的可用 Node > 版本管理器回退链(`engines.node` 最高匹配 > 活动版本 > 默认别名 > 已安装最高版本)。
- Linux 命令沙箱把通过校验的完整安装前缀只读挂载,并以 `node <npm-cli.js>` 启动 npm;`npm install` 保持联网判定。
- 沙箱内联环境不继承 fnm multishell 等临时版本管理器变量。
## 不在范围内
- 不把 `fnm` / `nvm` CLI 本身做成沙箱内可用工具。
- 不改变发布构建的 bundle / 系统 Node 解析策略。
- 不改变 Windows 的 npm.cmd 处理与 V1.10 固定 program 口径。
- 不引入 fnm / nvm 之外的版本管理器。
- 不为了兼容挂载整个用户 HOME、`FNM_DIR` / `NVM_DIR` 根或 `aliases` 目录。
## 依赖与前置条件
- 主机已安装 fnm 或 nvm,且至少有一个 Node 22 安装;项目按 `@types/node ^22.14` 面向 Node 22。
- Linux bubblewrap 可用;测试主机需能创建 user / mount / pid namespace。
## 验收标准
- [x] 开发构建在没有 `/usr/bin` shim 的情况下从 fnm / nvm 找到 Node,并在沙箱内运行出真实 `node --version` 与 `npm --version`。
- [x] `.nvmrc` / `.node-version` 指定的版本未安装时命令失败关闭,且不回退到其它已安装版本;`engines.node` 不匹配时不阻塞。
- [x] 不支持的 pin 写法(如 `iojs`、`>20`、`<=20`)按未 pin 处理并回退,而不是误判为「已理解但未命中」。
- [x] 只有通过窄叶校验的完整安装前缀会被只读挂载;HOME、管理器根、`aliases`、宽泛目录、不完整前缀和逃逸 symlink 全部失败关闭。
- [x] npm 在 Linux 上以受信任 `node <npm-cli.js> ...` 启动,`npm install` 仍被判定为联网命令,普通 `npm run` 仍离线。
- [x] 宿主发现与沙箱挂载共用同一套窄叶校验,不存在第二份信任口径。
- [x] 真实 nvm(v0.40.8 + Node v22.23.3)安装前缀的端到端沙箱运行,以及在仅 nvm 环境(`NVM_DIR` + 空 PATH + 临时 HOME)下的托管解析;同时修正 nvm `alias/default` 只写主版本号(如 `22`)时被当作 `(22,0,0)` 而匹配不到已安装补丁版本的问题。
## 证据要求
- 自动化:
- `cargo test --locked -p genarrative-ai-game-creator-shell --bin genarrative-ai-game-creator-shell -- environment_check:: --test-threads=1`
- `cargo test --locked -p genarrative-ai-game-creator-shell --bin genarrative-ai-game-creator-shell -- command_sandbox:: command_exec:: process_session:: --test-threads=1`
- 可选真机:`GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1 cargo test … -- command_sandbox_real_linux_opt_in_runs_host_node_and_npm_cli`
- 可选真机 nvm:`NVM_DIR=<nvm 目录> GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1 cargo test … -- command_sandbox_real_linux_opt_in_runs_nvm_installation_prefix`;仅 nvm 解析:`env -i HOME=<临时家目录> NVM_DIR=<nvm 目录> PATH=/nonexistent GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1 <测试二进制> --exact environment_check::tests::real_node_npm_environment_versions --ignored`
- `cargo fmt --check`、`npm run check:encoding`、`git diff --check`。
- 运行时:真实 fnm v22 前缀下,bwrap 内 `node -e 'process.stdout.write(process.version)'` 与 `node <npm-cli.js> --version` 均成功且版本为 v22。
- 边界:`.nvmrc` pin 未安装、`engines.node` 不匹配、不支持 pin、宽泛 / 不完整前缀、逃逸 symlink、`npm run` 离线与 `npm install` 联网。
@@ -1,5 +1,16 @@
# 决策记录
## 2026-10-07 AGC 命令沙箱原生支持 fnm/nvm:只读挂载窄叶安装前缀,npm 改走 node + npm-cli.js
- 背景:开发构建里 AGC 让命令沙箱执行 `npm run build` / `npm install` 时,宿主 Node 由 fnm 托管,`node` / `npm` 实际是随 shell 会话变化的 fnm multishell 目录里的 shim;bwrap `--tmpfs /run` 会抹掉该路径,而只按单文件挂载 `<前缀>/bin/npm`(它软链到 `lib/node_modules/npm/bin/npm-cli.js`)会因 `Cannot find module '../lib/cli.js'` 失败。此前把宿主 `node` / `npm` / `npx` shim 指到 `/usr/bin/*` 是错误取舍:系统 Node 26 默认启用实验性 Web Storage,会顶掉 vitest 0.34 jsdom 的 localStorage,使 AGC 测试套件在 HEAD 即失败(见 `pitfalls.md` 2026-10-03 条)。
- 决策(宿主发现与沙箱挂载共用窄叶校验):新增 `validate_node_installation_prefix`,canonicalize 后拒绝 `/home`、`/root`、`/tmp`、`/var`、`/etc`、`/proc`、`/dev`、`/run`、`/sys`、`/boot`、`/srv` 根、HOME 及其祖先和相对路径,并要求前缀同时含 `bin/node` 与 `lib/node_modules/npm/bin/npm-cli.js`(bundle 形态为 `<前缀>/node` + `node_modules/npm/bin/npm-cli.js`)。宿主版本枚举与 Linux 沙箱只读挂载都调用它,避免两处信任口径漂移。
- 决策(版本选择):`.nvmrc` / `.node-version` 是权威 pin,能理解但未安装时返回 `node-version-pinned-not-installed` 失败关闭;`package.json` `engines.node` 只是偏好,永不阻塞;不支持的写法(`iojs`、`||`、部分 `>` / `<=`、hyphen range、prerelease)按未 pin 回退。整体顺序为 pin 命中 > PATH 可解析的可用 Node > 版本管理器回退链(`engines` 最高匹配 > 活动版本 > 默认别名 > 已安装最高版本),只实现文档化比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*`、`lts/*`);`v22.23.3` 这类带 `v` 的 `.nvmrc` 必须先剥前缀。nvm 的 `alias/default` 常只写主版本号(如 `22`),必须按同一 pin 子集在已安装版本里选最高匹配,不能当成完整三元组 `(22,0,0)`。
- 决策(沙箱内启动形态):Linux npm 改为 `node <npm-cli.js>`,因为单文件挂载 npm 软链必然丢 `../lib/cli.js`;只读挂载整棵已验证的安装前缀(不是整个 HOME、`FNM_DIR` 或 `NVM_DIR`)。`npm install` 的联网判定跟随真实启动形态(`node` + `npm-cli.js` + `install`),不因包装变化丢 `--share-net`。
- 决策(范围与非目标):托管版本管理器发现只在 `debug_assertions` / development 生效,发布构建继续只认随包 bundle;不把 fnm / nvm CLI 做成沙箱内工具;Windows 不变;不新增 fnm / nvm 之外的版本管理器。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/environment_check.rs`(窄叶校验、托管安装枚举、pin / engines 解析、选择与回退)、`command_sandbox.rs`(Node 工具链挂载收集与合并、`command_sandbox_requests_npm_install`、`FNM_MULTISHELL_PATH` 清理)、`command_exec.rs`(Linux npm `node_launcher`、`project_command_actual_target`、非 Node 程序 PATH 前置工具链 bin)、`process_session_bridge.rs`(`ProcessSessionLaunchPlan::from_launch` 改用实际目标)。
- 验证方式:`environment_check` 24 passed、`command_sandbox` 14 passed、`command_exec` 19 passed、`process_session` 27 passed(均 `--test-threads=1`);`GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1` 真机 bwrap 内 fnm v22.23.3 的 `node --version` 与 npm 10.9.9 通过;另装 nvm v0.40.8 + Node v22.23.3,`command_sandbox_real_linux_opt_in_runs_nvm_installation_prefix` 证明真实 nvm 前缀可在 bwrap 内跑 node / npm,`real_node_npm_environment_versions`(仅 `NVM_DIR` + 空 PATH + 临时 HOME)证明托管解析确实选中 nvm;`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。验证后 fnm 仍是宿主默认,nvm 未写入任何 shell profile。
- 边界:nvm 已在验证主机安装(v0.40.8 + Node v22.23.3)并跑通真实前缀与仅 nvm 解析;CI 仍由临时目录夹具覆盖 fnm / nvm 布局,真实安装路径测试保持 opt-in。真实验收前不宣称发布构建也支持 fnm / nvm。
## 2026-10-06 小红书导出 validate/pack:Chrome 61 能力按硬性 ERROR 拦下,pack 自带白名单不再共享
- 背景:真实项目适配反馈。① `validate.mjs` 的 `/#[A-Za-z_$][\w$]*/g`(class 私有字段)直接在原始文本上匹配,把 `"#e8f4ff"`、`document.querySelector('#hit')` 误判为 ES2018 语法并报 ERROR,把排查引向「构建链没转译」。② `CSS_MODERN_PATTERNS` / `MODERN_RUNTIME_API` 告警被移除后,`flex gap`(Chrome 84+)、`min()/max()/clamp()`(Chrome 79+)等晚于 Chrome 61 的写法被静默忽略却无人拦截,`references/manual-checks.md` 只有人读清单没有工具兜底。③ 宿主在**项目根** `.export/xhs-minitool.zip` 找产物,而 `--zip-out` 相对 cwd 解析;npm 脚本挂在 `game/` 子工程时 `.export/...` 会落到 `game/.export/`,没有任何提示。④ `pack.mjs` 直接 `import './validate.mjs'`,只复制 pack.mjs 会 `ERR_MODULE_NOT_FOUND`。⑤ `validate.mjs` 的 `[project]` 默认值只在源码 USAGE 里,SKILL 参数表没写默认值。
@@ -2,6 +2,23 @@
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
## 2026-10-07 fnm/nvm 托管的 Node 进 bwrap:单文件挂载 npm 必失败,`--tmpfs /run` 会抹掉 multishell PATH
- **现象**:开发构建在 Linux 命令沙箱里执行 `npm run build` / `npm install` 时,宿主 shell 里明明能跑通的 fnm Node,进沙箱后报 `Node.js v26.10.0` 与 `Cannot find module '../lib/cli.js'`,或 npm 命令在路径解析阶段就失败。
- **根因 1(单文件挂载软链前缀)**:fnm / nvm 的 `<前缀>/bin/npm` 是指向 `<前缀>/lib/node_modules/npm/bin/npm-cli.js` 的软链。bwrap `--ro-bind <前缀>/bin/npm <前缀>/bin/npm` 只挂载这一个文件,`npm-cli.js` 里的 `require('../lib/cli.js')` 找不到同安装内的相对目标,于是报错;必须整棵只读挂载通过窄叶校验的完整安装前缀(含 `bin/node`、`lib/node_modules/npm`),不能只挂 shim 或 `bin/`。
- **根因 2(`--tmpfs /run` 抹掉活动版本)**:fnm 的活动 `PATH` 项是 `/run/user/<uid>/fnm_multishells/<pid>/bin`;sandbox 的 `--tmpfs /run` 会清空该目录,sandbox 内解析到的 `node` 随之失效或退回系统版本。不要依赖宿主 `PATH` 原样进入沙箱:canonicalize 路径,并把活动版本管理器变量(如 `FNM_MULTISHELL_PATH`)从 sandbox 环境里剔除。
- **根因 3(错误取舍会打穿测试)**:把宿主 `node` / `npm` / `npx` shim 指到 `/usr/bin/*` 能让沙箱借用系统 Node,但在本机系统 Node 26 上会默认启用实验性 Web Storage,顶掉 vitest 0.34 jsdom 的 localStorage,AGC 测试在 HEAD 即红(见下方 2026-10-03「AGC 测试不在任何 tsconfig 里」条的环境提示)。正确方向是原生支持托管安装,而不是改宿主 shim。
- **补充(nvm default 别名是主版本号)**:`nvm alias default 22` 写进 `$NVM_DIR/alias/default` 的内容是 `22`,不是完整三元组。若按精确 `(22,0,0)` 去匹配 `versions/node/v22.23.3` 会永远落空,默认别名形同不存在;必须用与 `.nvmrc` 相同的比较子集在已安装版本里选最高匹配。
- **现行口径**:宿主发现与沙箱挂载共用 `validate_node_installation_prefix`;`.nvmrc` / `.node-version` 权威、`engines.node` 偏好;Linux npm 以 `node <npm-cli.js>` 启动并保留 `npm install` 联网判定。契约见技术方案 V1.11.2。
- **验证**:`GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1` 跑 `command_sandbox_real_linux_opt_in_runs_host_node_and_npm_cli`,在真实 fnm v22 前缀下 bwrap 内 `node --version` 与 `node <npm-cli.js> --version` 均通过;单元用例覆盖 pin / engines / 不支持写法 / 宽叶与逃逸前缀 / 联网判定。
## 2026-10-07 cargo 目标目录里被"刷新 mtime"的陈旧 shared-contracts 会让编译报源文件里明明存在的字段缺失
- **现象**:`cargo check` 在 AGC shell 上报 `unresolved import shared_contracts::runtime::ProfileMembershipUpgradeQuoteResponse`、`no field project_version / publication`、`LlmModelsResponse: Deserialize` 等一整组「契约落后」错误;但 `server-rs/crates/shared-contracts/src` 里这些符号确实存在,`git status` 干净,刚重建的 rlib 也含符号。
- **根因**:`target/debug/deps` 里留着早先构建的 `libshared_contracts-<hash>.rmeta`,其 `.d` 依赖文件停留在旧时间戳,而 `.rmeta` 的 mtime 在快照 / 拷贝过程中被刷新成新时间;cargo 按 mtime 判定该 crate 仍然新鲜,于是把 `--extern shared_contracts=` 指到旧 rmeta,下游就看到旧 API。多份不同 feature 组合的 `shared-contracts-<hash>` 并存时更容易踩中。
- **处理**:删除该 crate 的全部指纹与产物后重编即可,不必清整棵 target:`rm -rf target/debug/.fingerprint/shared-contracts-* target/debug/deps/*shared_contracts*`,再跑 `cargo check`。判断依据是错误集中在某个 `shared-contracts` API,而源文件与 `git status` 都正常;先用 `cargo check -v 2>&1 | grep -m1 -- '--extern shared_contracts='` 找到实际使用的 rmeta,再核对它同名 `.d` 里的源文件路径与时间戳。
- **边界**:这是构建缓存 / 快照产物问题,不是契约真源问题;不要因为这类报错去改 `shared-contracts` 或回退下游代码。
## 2026-10-05 PR #607 复核修复:档位点对齐/对比度、状态文案也走浮层、键盘去重、卸载 flush
- **档位圆点已删除(D1 的收口)**:这一轮把档位圆点**整体删除**(半透明备选方案未采用)。现在滑块只剩轨道 + 圆钮:轨道 6px 圆头、已选段 `--platform-accent` 由 `--strength-ratio` 驱动、**终点落在圆钮中心**(`calc(10px + ratio * (100% - 20px))`)、圆钮 20px 实心暖白(`--platform-panel-fill` + `--platform-subpanel-border` 1px 描边 + `color-mix` 柔影);强度区横向内边距 `4px 6px 2px` → `4px 0 2px`(滑块铺满卡片内容宽度,填充段与圆钮两端与轨道两端贴齐);相关 CSS(`space-between` 排布 / `z-index: 2` 抬层 / `.is-active{opacity:0}` / 点的 `color-mix` 底色 / 只为点对齐的 `padding: 0 7px`)与渲染标记一并删除。**判据**:`chatDialogFrameLayout.test.ts` 反向守卫(样式表里不再有 `.project-chat-composer-strength-stops` 规则、组件源码不再渲染该类名;滑块契约仍在:宽 100% / 高 26 / 圆钮 20×20、强度区左右内边距 0)+ `home.suite.ts` 首页菜单里查不到那组点。实测(447 视口,像素扫描):轨道 90..324(宽 234 = 卡片内容宽),档位 0 时圆钮左缘 90.5(距轨道左端 0.5px)、档位 4 时圆钮右缘 322.3(距右端 1.8px,扫描行不在圆钮正中所以略窄),填充段终点落在**圆钮中心**(`calc(10px + ratio * (100% - 20px))`,被圆钮盖住),因此圆钮右侧不会露出橙色(终点曾写成 `20px + …` = 圆钮右缘,4× 设备像素下能看到一小截溢出)。**历史成因(只留一句,细节由 Git 追溯)**:圆点此前被 6px 轨道盖住、且与圆钮两端错位 ±12.2px,曾用「抬到轨道之上 + space-between 对齐 + 浅暖色」修过一轮,最终整体删除。
@@ -616,6 +616,16 @@ Runner-kill E2E 不再以 latest task 或单个 process record 推断整体恢
`command.poll` 私有正文虽然必须进入 owning Agent context 供后续交互,但模型 prompt 不是持久化隔离边界。后台 finalization 在创建 assistant journal 前检查当前 run 的成功 poll observation;只要存在非空输出,就把模型最终回复整体收束为固定安全完成摘要,再计算 response fingerprint 并写 conversation/event/Agent DB。该边界不按长度猜测 token,因此 challenge、ready/echo/stopped 行和短 PIN 的局部回显都不能扩大到公共持久面;没有私有 poll 正文的普通回复保持原样。
### V1.11.2 命令沙箱 Node 版本管理器支持
开发构建(`debug_assertions`)下,AGC 生成和验证 Web 项目所用的 Node 往往由 fnm / nvm 等版本管理器托管:安装前缀位于用户目录下,活动 `PATH` 指向随 shell 会话变化的临时目录(如 fnm 的 multishell)。V1.11.2 让开发构建的命令沙箱原生解析并只读复用这些托管安装,不再要求用户改系统 Node、把宿主 shim 指到 `/usr/bin`,或把托管目录软链进系统路径。发布构建继续只认随包 bundle,不新增托管版本管理器探测,也不改变 bundle 缺失时的失败口径;Windows 行为不变。
- 版本来源是机器上可枚举的托管安装:fnm 的 `node-versions/<version>/installation`(含 `aliases/default` 指向的默认别名)与 nvm 的 `versions/node/<version>`。宿主发现和沙箱只读挂载共用同一套窄叶校验,不允许两处信任口径漂移。
- 解析优先级为:`.nvmrc` / `.node-version` 的权威 pin 命中 > 宿主 `PATH` 能解析出的可用 Node > 版本管理器回退链(`package.json` `engines.node` 偏好中的最高匹配 > 当前活动版本 > 默认别名 > 已安装最高版本)。`.nvmrc` / `.node-version` 能理解但未安装时必须失败关闭(`node-version-pinned-not-installed`),不得静默回退;`engines.node` 只是偏好,任何情况下都不阻塞。只实现文档化的比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*` 通配、`lts/*`);不支持或无法解析的写法按「未 pin」处理并回退。
- 只读挂载只允许通过窄叶校验的完整安装前缀(同时含 `bin/node` 与 npm 的 `npm-cli.js`)。HOME、`FNM_DIR` / `NVM_DIR` 根、`aliases` 目录、宽泛用户目录、不完整前缀,以及 canonicalize 后逃逸出受控前缀的 symlink 全部拒绝并失败关闭;不得为了兼容而挂载整个用户 HOME 或版本管理器数据目录。
- Linux 上 npm 不再直接执行 npm shim,而是以受信任的 `node <npm-cli.js> ...` 启动;`npm install` 的联网判定必须跟随这条真实启动形态,不能因为包装方式变化而丢失联网或反向放开。
- 沙箱内联环境不继承宿主活动版本管理器的临时变量(如 fnm multishell 路径);版本管理器 CLI(`fnm` / `nvm`)本身不需要在沙箱内可用。
## V1.12 受控本地 Git 提交
V1.12 首个切片补齐“修改、验证、审阅、提交”的单 Agent 本地闭环,只新增 `project.git_commit`。它不是通用 Git 写权限:不开放 `push / fetch / pull`、分支创建或切换、merge / rebase、reset、stash、tag、submodule、worktree,也不能通过 `command.exec` 绕过 `.git` 只读沙箱。
@@ -853,6 +853,8 @@ Agent 可见的系统指令、工具与参数说明、恢复指引和上下文
2026-07-14 V1.11.1 最终真实验收:发布 AppData 的真实 `gpt-5.5` `process-session` 形成 41 条 task、75 条 event、63 条 Agent DB 和 8 条 receipt,唯一 start、3 poll、唯一 stdin / terminate、3 次 cursor 推进及唯一 terminal / completed / assistant全部通过;Runner kill套件形成 13 条 task、19 条 event、19 条 Agent DB,真实 SIGKILL后项目 cwd进程清零、新 boot保持同 run / session并只形成 1 条 reconciliation。两套的 reconnect、重放、重复 action / message / receipt、公共进程正文、密钥和诱饵泄漏均为 0,disposable项目均自动清理;V1.11.1 持久进程链路据此完成验收。
2026-10-07 V1.11.2 切片:Linux 命令沙箱原生支持 fnm / nvm 托管的 Node。开发构建(`debug_assertions`)先从 `.nvmrc` / `.node-version` 的权威 pin、再按 `package.json` `engines.node` 偏好、活动版本、版本管理器默认别名和已安装最高版本选择托管安装;`.nvmrc` / `.node-version` 能理解但未安装时失败关闭,`engines.node` 和无法解析的写法不阻塞。宿主发现与沙箱只读挂载共用同一窄叶校验,只挂载完整安装前缀(含 `bin/node` 与 npm 的 `npm-cli.js`),拒绝 HOME、管理器根、`aliases`、宽泛目录、不完整前缀和逃逸 symlink。Linux npm 以 `node <npm-cli.js>` 启动,`npm install` 的联网判定跟随该真实形态;sandbox 环境剔除 fnm multishell 变量。发布构建仍只认随包 bundle,Windows 不变。定向 Rust 测试与真实 bwrap 内 fnm v22 的 `node` / npm 运行已通过;真实 nvm 端到端待补。
2026-07-14 起,同一文档的“V1.12 受控本地 Git 提交”补齐单 Agent 的修改、验证、审阅、本地提交闭环。新增且只新增 `project.git_commit`,输入绑定 `message / paths / expectedHead / expectedSnapshotFingerprint`,最多提交 12 个显式安全路径;它是不可降为 `auto` 的强制确认工具,legacy 空策略也继续要求确认,项目策略仍可显式拒绝。动态隔离 child 无条件禁用该工具,最终提交由父 Agent 统一发起。当前 run 必须在当前非零 project revision 上已有 passed verification gate。`git.inspect` 签发的 `commitSnapshotFingerprint` 绑定 HEAD、附着分支、规范化安全状态和全部安全变更文件内容;`.agent` 等控制面正常落盘不制造跨动作漂移,安全源码、HEAD、分支、revision 或 gate 任一变化仍失败关闭。提交只支持标准仓库根和本地附着分支,要求真实 index 没有 staged 内容,并用临时 index、真实 `index.lock`、`commit-tree` 和带 expected old HEAD 的 `update-ref HEAD` 精确前移 ref,同步 HEAD / branch reflog后跨平台原子安装 index;未选改动保持未暂存。它不开放 remote、分支切换、merge / rebase、reset、stash、tag、submodule 或 worktree 写操作,也不能通过 `command.exec` 绕过 `.git` 只读沙箱。成功 observation、Agent DB 审计和 terminal receipt 只保留 parent / commit SHA、分支、安全路径、message SHA-256 和剩余变更计数;ref 前移后的不确定错误或审计失败进入 `needs-reconciliation`,已知 commit 的审计失败仍在 fallback receipt 保存 commit SHA,恢复不得重放提交。
2026-07-14 V1.12 真实 `gpt-5.5` 验收已通过。现有 `llm-runtime` disposable 套件要求模型在完整修改、验证和审阅链路末尾自行创建唯一受控提交,并从原始 commit object、真实 Git parent / HEAD / tree、空 staged index、提交后所选路径状态、封闭字段专用审计、terminal receipt 和 HEAD / branch 双 reflog 验真。最终收紧版形成 151 条 task、258 条 event、266 条 Agent DB、17 次代表性成功工具执行、7 套确认生命周期、9 个实际副作用 action 和 44 条 receipt;唯一提交精确包含 2 个目标路径,预存 sentinel 未被夹带,project revision 保持 3。Runner 强杀恢复后 run / session 身份稳定,副作用重放、重复 action / message / receipt、密钥和诱饵泄漏均为 0,disposable 项目已自动清理。