diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index a4a09d8d1..01f2d1638 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -961,19 +961,24 @@ fn resolve_project_command_executable( } /// Linux 上 npm 以 `node ` 启动;其余情况保持 `executable + args`。 -pub(crate) fn project_command_actual_target(spec: &ProjectCommandSpec) -> (PathBuf, Vec) { +pub(crate) fn project_command_actual_target(spec: &ProjectCommandSpec) -> (PathBuf, Vec) { #[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()); + // 保留原始 OsStr 字节:`npm_cli` 若含非 UTF-8,`to_string_lossy` 会变成 U+FFFD, + // 既改坏实际执行的脚本路径,也让沙箱的可信 npm_cli 比对失配。 + arguments.push(npm_cli.as_os_str().to_owned()); + arguments.extend(spec.arguments.iter().map(OsString::from)); return (node.clone(), arguments); } } ( spec.executable.clone(), - project_command_actual_arguments(spec), + project_command_actual_arguments(spec) + .into_iter() + .map(OsString::from) + .collect(), ) } @@ -1674,6 +1679,9 @@ pub(crate) fn prepare_project_command_launch_spec( &target_arguments, &spec.cwd, &environment, + spec.node_launcher + .as_ref() + .map(|(node, npm_cli)| (node.as_path(), npm_cli.as_path())), ) .map_err(|error| { ProjectCommandError::new( @@ -2660,6 +2668,14 @@ mod tests { 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(); + // 这条用例走真实 resolve_node_runtime:没有可用宿主 Node 的最小容器里跳过, + // 保持 cargo test 可跑;跳过时打印可见提示(配 --nocapture / --show-output 看得到)。 + if let Err(error) = crate::environment_check::resolve_node_runtime(root.path()) { + eprintln!( + "skipping npm_command_targets_node_plus_npm_cli_on_linux: no host node runtime({error})" + ); + return; + } let spec = resolve_project_command_spec_at( root.path(), "npm", @@ -2674,13 +2690,31 @@ mod tests { Some("node"), "{executable:?}" ); - assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}"); - assert_eq!(&arguments[1..], ["run".to_string(), "build".to_string()]); + assert!( + arguments[0].to_string_lossy().ends_with("npm-cli.js"), + "{arguments:?}" + ); + assert_eq!( + &arguments[1..], + [OsString::from("run"), OsString::from("build")] + ); + + // 沙箱联网判定要求目标与解析层给出的可信 (node, npm_cli) 精确一致。 + let (trusted_node, trusted_npm_cli) = spec + .node_launcher + .as_ref() + .expect("Linux npm spec must carry the trusted node launcher"); + assert_eq!(trusted_node, &executable); + // 逐字节相等:argv[0] 不再经 to_string_lossy 往返。 + assert_eq!(trusted_npm_cli.as_os_str(), arguments[0].as_os_str()); 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"); + assert!( + arguments[0].to_string_lossy().ends_with("npm-cli.js"), + "{arguments:?}" + ); + assert_eq!(arguments[1], OsString::from("install")); } #[cfg(target_os = "linux")] diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs index 109304b59..7678a5dea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs @@ -110,15 +110,26 @@ pub(crate) fn command_sandbox_platform_metadata() -> CommandSandboxMetadata { /// Builds a fail-closed launcher for a direct executable plus structured argv. /// It never falls back to launching the original command on the host. +/// +/// `trusted_npm_install` 是解析层给出的可信 `(node, npm_cli)`;只有目标命令与它精确一致 +/// 且子命令是 `install` 时才放网,避免同名项目文件冒充 npm。 #[cfg(target_os = "linux")] pub(crate) fn prepare_command_sandbox_launch( root: &Path, executable: &Path, - arguments: &[String], + arguments: &[OsString], cwd: &Path, environment: &[(OsString, OsString)], + trusted_npm_install: Option<(&Path, &Path)>, ) -> Result { - prepare_linux_command_sandbox_launch(root, executable, arguments, cwd, environment) + prepare_linux_command_sandbox_launch( + root, + executable, + arguments, + cwd, + environment, + trusted_npm_install, + ) } #[cfg(target_os = "linux")] @@ -201,7 +212,8 @@ mod linux { root: PathBuf, cwd: PathBuf, executable: PathBuf, - arguments: Vec, + arguments: Vec, + npm_install_network: bool, target_environment: Vec<(OsString, OsString)>, merged_usr_links: Vec<(OsString, PathBuf)>, protected_read_only: Vec, @@ -290,12 +302,15 @@ mod linux { pub(super) fn prepare_linux_command_sandbox_launch( root: &Path, executable: &Path, - arguments: &[String], + arguments: &[OsString], cwd: &Path, environment: &[(OsString, OsString)], + trusted_npm_install: Option<(&Path, &Path)>, ) -> Result { let mut metadata = CommandSandboxMetadata::enforced_linux(); - if command_sandbox_requests_npm_install(executable, arguments) { + let npm_install_network = + command_sandbox_requests_npm_install(executable, arguments, trusted_npm_install); + if npm_install_network { metadata.network = "enabled"; } let bwrap = find_trusted_bwrap().map_err(|error| { @@ -372,6 +387,7 @@ mod linux { cwd, executable, arguments: arguments.to_vec(), + npm_install_network, target_environment, merged_usr_links, protected_read_only, @@ -546,29 +562,24 @@ mod linux { .collect()) } - /// `npm install` 是唯一允许联网的受控入口。Linux 以 `node 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 + /// `npm install` 是唯一允许联网的受控入口。Linux 上 npm 以 `node install` + /// 启动,只看 executable 或第一个参数的 basename 会被项目里同名的文件冒充,因此必须与解析层 + /// 给出的可信 `(node, npm_cli)` 精确比对;拿不到可信对时一律不放网。 + fn command_sandbox_requests_npm_install( + executable: &Path, + arguments: &[OsString], + trusted_npm_install: Option<(&Path, &Path)>, + ) -> bool { + let Some((trusted_node, trusted_npm_cli)) = trusted_npm_install else { + return false; + }; + executable == trusted_node + && 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")) + .is_some_and(|argument| Path::new(argument) == trusted_npm_cli) && arguments .get(1) - .is_some_and(|argument| argument == "install") + .is_some_and(|argument| argument.as_os_str() == OsStr::new("install")) } /// fnm / nvm / 系统 / 随包 Node 的安装前缀必须整棵只读挂进沙箱:只挂单个 `node` 或 `npm` @@ -713,10 +724,7 @@ mod linux { fn build_linux_bwrap_launch(plan: LinuxSandboxPlan) -> CommandSandboxLaunch { let mut args = Vec::::new(); - push_namespace_arguments( - &mut args, - command_sandbox_requests_npm_install(&plan.executable, &plan.arguments), - ); + push_namespace_arguments(&mut args, plan.npm_install_network); push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); for (target, destination) in &plan.merged_usr_links { push_option( @@ -776,7 +784,7 @@ mod linux { push_option(&mut args, "--chdir", [plan.cwd.as_os_str()]); args.push(OsString::from("--")); args.push(plan.executable.as_os_str().to_owned()); - args.extend(plan.arguments.iter().map(OsString::from)); + args.extend(plan.arguments.iter().cloned()); CommandSandboxLaunch { executable: plan.bwrap, @@ -1056,6 +1064,16 @@ mod linux { }) } + #[test] + fn namespace_arguments_share_net_only_when_trusted() { + let mut shared = Vec::new(); + push_namespace_arguments(&mut shared, true); + assert!(shared.contains(&OsString::from("--share-net"))); + let mut isolated = Vec::new(); + push_namespace_arguments(&mut isolated, false); + assert!(!isolated.contains(&OsString::from("--share-net"))); + } + #[test] fn pure_builder_constructs_empty_workspace_namespace_and_private_environment() { let launch = build_linux_bwrap_launch(LinuxSandboxPlan { @@ -1063,7 +1081,8 @@ mod linux { root: PathBuf::from("/workspace/project"), cwd: PathBuf::from("/workspace/project/game"), executable: PathBuf::from("/opt/toolchain/bin/tool"), - arguments: vec!["check".to_string(), "--flag".to_string()], + arguments: vec![OsString::from("check"), OsString::from("--flag")], + npm_install_network: false, target_environment: vec![ ( OsString::from("HOME"), @@ -1185,9 +1204,10 @@ mod linux { let error = prepare_command_sandbox_launch( &root, Path::new("/usr/bin/python3"), - &["-c".to_string(), script], + &[OsString::from("-c"), OsString::from(script)], &root, &[(OsString::from("PATH"), OsString::from("/usr/bin"))], + None, ) .expect_err("invalid protected path must fail closed"); assert!(error.to_string().contains(".codex")); @@ -1332,26 +1352,64 @@ mod linux { } #[test] - fn npm_install_network_detection_supports_node_launcher() { + fn npm_install_network_detection_requires_the_trusted_node_launcher() { + let node = Path::new("/opt/node/bin/node"); + let npm_cli = Path::new("/opt/node/lib/node_modules/npm/bin/npm-cli.js"); + let trusted = Some((node, npm_cli)); + let install_arguments = vec![npm_cli.as_os_str().to_owned(), OsString::from("install")]; 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(), - ], + node, + &install_arguments, + trusted, )); + // 项目里同名的 npm-cli.js 不能再冒充。 assert!(!command_sandbox_requests_npm_install( - Path::new("/opt/node/bin/node"), + node, &[ - "/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(), - "run".to_string(), - "build".to_string(), + OsString::from("/workspace/game/npm-cli.js"), + OsString::from("install"), ], + trusted, )); - assert!(command_sandbox_requests_npm_install( + // executable 与可信 node 不一致时不放网。 + assert!(!command_sandbox_requests_npm_install( + Path::new("/opt/node/bin/other"), + &install_arguments, + trusted, + )); + // 非 install 子命令不放网。 + assert!(!command_sandbox_requests_npm_install( + node, + &[ + npm_cli.as_os_str().to_owned(), + OsString::from("run"), + OsString::from("build"), + ], + trusted, + )); + // 没有可信 launcher(例如直接 npm shim)时不放网。 + assert!(!command_sandbox_requests_npm_install( Path::new("/usr/bin/npm"), - &["install".to_string()], + &[OsString::from("install")], + None, )); + // 非 UTF-8 的 npm_cli:argv 保留原始字节时必须逐字节命中 + // (旧实现经 to_string_lossy 会变成 U+FFFD,从而误拒合法的 npm install)。 + { + use std::os::unix::ffi::OsStringExt; + let raw_npm_cli = PathBuf::from(OsString::from_vec( + b"/opt/node/lib/node_modules/npm/bin/n\xffpm-cli.js".to_vec(), + )); + let raw_arguments = vec![ + raw_npm_cli.as_os_str().to_owned(), + OsString::from("install"), + ]; + assert!(command_sandbox_requests_npm_install( + node, + &raw_arguments, + Some((node, raw_npm_cli.as_path())), + )); + } } struct TempTree(PathBuf); @@ -1448,9 +1506,10 @@ print("SANDBOX_OK") let launch = prepare_command_sandbox_launch( &root, Path::new("/usr/bin/python3"), - &["-c".to_string(), script], + &[OsString::from("-c"), OsString::from(script)], &root, &environment, + None, ) .expect("prepare real Linux sandbox"); @@ -1502,13 +1561,14 @@ print("SANDBOX_OK") (OsString::from("PATH"), runtime.safe_path.clone()), (OsString::from("HOME"), OsString::from("/host/home")), ]; - let run = |executable: &Path, arguments: &[String]| { + let run = |executable: &Path, arguments: &[OsString]| { let launch = prepare_command_sandbox_launch( &root, executable, arguments, &root, &environment, + None, ) .expect("prepare node sandbox"); let output = Command::new(&launch.executable) @@ -1529,8 +1589,8 @@ print("SANDBOX_OK") let version = run( &runtime.node, &[ - "-e".to_string(), - "process.stdout.write(process.version)".to_string(), + OsString::from("-e"), + OsString::from("process.stdout.write(process.version)"), ], ); assert!( @@ -1538,8 +1598,13 @@ print("SANDBOX_OK") "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()]); + let npm_version = run( + &runtime.node, + &[ + runtime.npm_cli.as_os_str().to_owned(), + OsString::from("--version"), + ], + ); assert!(!npm_version.is_empty(), "npm --version returned nothing"); } @@ -1577,10 +1642,16 @@ print("SANDBOX_OK") (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 run = |arguments: &[OsString]| { + let launch = prepare_command_sandbox_launch( + &root, + &node, + arguments, + &root, + &environment, + None, + ) + .expect("prepare nvm sandbox"); let output = Command::new(&launch.executable) .args(&launch.arguments) .current_dir(&launch.cwd) @@ -1597,17 +1668,14 @@ print("SANDBOX_OK") }; let version = run(&[ - "-e".to_string(), - "process.stdout.write(process.version)".to_string(), + OsString::from("-e"), + OsString::from("process.stdout.write(process.version)"), ]); assert!( version.starts_with('v'), "unexpected node version: {version}" ); - let npm_version = run(&[ - npm_cli.to_string_lossy().into_owned(), - "--version".to_string(), - ]); + let npm_version = run(&[npm_cli.as_os_str().to_owned(), OsString::from("--version")]); assert!(!npm_version.is_empty(), "npm --version returned nothing"); } @@ -1627,7 +1695,11 @@ print("SANDBOX_OK") "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 arguments = vec![ + OsString::from("-c"), + OsString::from(script), + OsString::from("--"), + ]; let environment = vec![(OsString::from("PATH"), OsString::from("/usr/bin"))]; let launch = prepare_linux_command_sandbox_launch( &root, @@ -1635,9 +1707,10 @@ print("SANDBOX_OK") &arguments, &root, &environment, + None, ) .expect("prepare real Linux sandbox"); - let target_arguments = arguments.iter().map(OsString::from).collect::>(); + let target_arguments = arguments.clone(); let gate = LaunchGate::new_for_sandbox_stdin(Path::new("/usr/bin/python3"), &target_arguments) .expect("create real Linux sandbox gate"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs index ec6176520..3b0f49130 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs @@ -633,11 +633,15 @@ fn version_matches_pin(version: (u64, u64, u64), pin: &str) -> Option { // `.nvmrc` 两种写法都常见:`v22.23.3` 与 `22.23.3`。 let pin = pin.strip_prefix('v').unwrap_or(pin); let lower = pin.to_ascii_lowercase(); - if matches!(lower.as_str(), "*" | "x" | "node" | "latest" | "lts/*") - || lower.starts_with("lts/") - { + if matches!(lower.as_str(), "*" | "x" | "node" | "latest") { return Some(true); } + if lower.starts_with("lts/") { + // `lts/*`(最新 LTS)与 `lts/`(如 `lts/iron` = Node 20)都需要 LTS 行映射, + // 当前不维护;按“未 pin”回退到宿主已激活/默认选择,绝不能声称匹配任意版本而静默选中 + // 已安装的最高版本——最高版本通常不是 LTS。 + return None; + } if lower.starts_with("iojs") || lower.contains("||") { return None; } @@ -656,8 +660,11 @@ fn version_matches_pin(version: (u64, u64, u64), pin: &str) -> Option { } enum PinSelection<'a> { + /// pin 被理解且在已安装版本里命中,取其中最高匹配。 Matched(&'a InstalledNodeVersion), + /// pin 被理解但已安装版本里没有命中;调用方按「未 pin」继续回退,不阻塞。 NoMatch, + /// pin 超出当前实现的比较子集;同样按「未 pin」继续回退,不阻塞。 Unsupported, } @@ -725,22 +732,6 @@ fn read_project_engines_range(root: &Path) -> Option { } } -fn active_managed_prefix() -> Option { - if let Some(path) = std::env::var_os("FNM_MULTISHELL_PATH") { - if let Ok(prefix) = validate_node_installation_prefix(Path::new(&path)) { - return Some(prefix); - } - } - if let Some(bin) = std::env::var_os("NVM_BIN") { - if let Some(parent) = Path::new(&bin).parent() { - if let Ok(prefix) = validate_node_installation_prefix(parent) { - return Some(prefix); - } - } - } - None -} - fn default_managed_installation<'a>( installed: &'a [InstalledNodeVersion], fnm_roots: &[PathBuf], @@ -758,8 +749,9 @@ fn default_managed_installation<'a>( let Ok(content) = fs::read_to_string(root.join("alias").join("default")) else { continue; }; - // nvm 的 default 别名常写成 `22`、`v22.23.3` 或 `lts/*`,不是完整三元组;按 pin 的 - // 同一子集在已安装版本里选最高匹配,避免 `22` 被当成 (22,0,0) 而永远匹配不到。 + // nvm 的 default 别名常写成 `22` 或 `v22.23.3`,不是完整三元组;按 pin 的同一子集在 + // 已安装版本里选最高匹配,避免 `22` 被当成 (22,0,0) 而永远匹配不到。`lts/*` 之类需要 + // LTS 行映射的别名会落空,由调用方回退到已安装最高版本。 if let PinSelection::Matched(node) = select_pinned_installation(installed, content.trim()) { return Some(node); } @@ -780,10 +772,11 @@ fn resolve_pinned_managed_runtime( let Some(pin) = read_project_version_file_pin(root) else { return Ok(None); }; + // `.nvmrc` / `.node-version`(nvm / fnm 都读)只改变搜索顺序,不是硬性要求: + // 命中已安装版本就优先用它,否则继续走 PATH 和回退链,绝不因为“没装”而阻塞。 match select_pinned_installation(&installed, &pin) { PinSelection::Matched(node) => Ok(Some(runtime_from_installed(node, root, path)?)), - PinSelection::NoMatch => Err("node-version-pinned-not-installed".into()), - PinSelection::Unsupported => Ok(None), + PinSelection::NoMatch | PinSelection::Unsupported => Ok(None), } } @@ -803,11 +796,6 @@ fn resolve_managed_fallback_runtime( return Ok(Some(runtime_from_installed(node, root, path)?)); } } - if let Some(prefix) = active_managed_prefix() { - if let Some(node) = installed.iter().find(|node| node.prefix == prefix) { - return Ok(Some(runtime_from_installed(node, root, path)?)); - } - } if let Some(node) = default_managed_installation(&installed, fnm_roots, nvm_roots) { return Ok(Some(runtime_from_installed(node, root, path)?)); } @@ -1417,12 +1405,36 @@ mod tests { assert_eq!(default.version, (22, 23, 3)); } + #[cfg(unix)] #[test] - fn version_file_pin_is_authoritative_and_blocks_when_not_installed() { + fn fnm_default_alias_symlink_selects_the_linked_installation() { + use std::os::unix::fs::symlink; + + let fnm_root = tempfile::tempdir().unwrap(); + let older = install_fnm_version(fnm_root.path(), "v20.11.0"); + install_fnm_version(fnm_root.path(), "v22.23.3"); + let alias_dir = fnm_root.path().join("aliases"); + fs::create_dir_all(&alias_dir).unwrap(); + // fnm 1.39 的 default 别名是指向安装目录的符号链接,不是版本字符串文件。 + symlink(&older, alias_dir.join("default")).unwrap(); + let installed = installed_node_versions(&[fnm_root.path().to_path_buf()], &[]); + assert_eq!(installed.len(), 2); + let default = + default_managed_installation(&installed, &[fnm_root.path().to_path_buf()], &[]) + .expect("fnm default alias should resolve"); + // 必须跟随别名选中 v20,而不是回退到最高的 v22。 + assert_eq!(default.prefix, older.canonicalize().unwrap()); + assert_eq!(default.version, (20, 11, 0)); + } + + #[test] + fn version_file_pin_prefers_the_installed_version_and_falls_back_when_missing() { let fnm_root = tempfile::tempdir().unwrap(); install_fnm_version(fnm_root.path(), "v20.11.0"); install_fnm_version(fnm_root.path(), "v22.23.3"); let project = tempfile::tempdir().unwrap(); + + // 命中已安装版本:.nvmrc 改变搜索顺序,优先选它而不是最高版本。 fs::write(project.path().join(".nvmrc"), "20\n").unwrap(); let runtime = resolve_at_with_managed_roots( project.path(), @@ -1436,19 +1448,48 @@ mod tests { assert_eq!(runtime.source, "fnm"); assert!(runtime.node.to_string_lossy().contains("v20.11.0")); - fs::write(project.path().join(".nvmrc"), "v18.0.0\n").unwrap(); - assert_eq!( - resolve_at_with_managed_roots( - project.path(), - None, - true, - OsStr::new(""), - &[fnm_root.path().to_path_buf()], - &[], - ) - .unwrap_err(), - "node-version-pinned-not-installed" - ); + // fnm 的 .node-version 同样参与搜索顺序。 + fs::remove_file(project.path().join(".nvmrc")).unwrap(); + fs::write(project.path().join(".node-version"), "20\n").unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[], + ) + .unwrap(); + assert!(runtime.node.to_string_lossy().contains("v20.11.0")); + + // 未安装时不再失败关闭:回退到已安装最高版本。 + fs::write(project.path().join(".node-version"), "v18.0.0\n").unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[], + ) + .unwrap(); + assert_eq!(runtime.source, "fnm"); + assert!(runtime.node.to_string_lossy().contains("v22.23.3")); + + // nvm 的 .nvmrc 未安装时走同一条宽松回退。 + let nvm_root = tempfile::tempdir().unwrap(); + install_nvm_version(nvm_root.path(), "v20.11.0"); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[], + &[nvm_root.path().to_path_buf()], + ) + .unwrap(); + assert_eq!(runtime.source, "nvm"); + assert!(runtime.node.to_string_lossy().contains("v20.11.0")); } #[test] @@ -1495,7 +1536,7 @@ mod tests { let fnm_root = tempfile::tempdir().unwrap(); install_fnm_version(fnm_root.path(), "v22.23.3"); let project = tempfile::tempdir().unwrap(); - for pin in ["iojs\n", ">20\n", "<=20\n"] { + for pin in ["iojs\n", ">20\n", "<=20\n", "lts/iron\n", "lts/*\n"] { fs::write(project.path().join(".node-version"), pin).unwrap(); let runtime = resolve_at_with_managed_roots( project.path(), @@ -1519,8 +1560,11 @@ mod tests { assert_eq!(version_matches_pin((20, 11, 0), "^20.0.0"), Some(true)); assert_eq!(version_matches_pin((20, 11, 0), "~20.11.0"), Some(true)); assert_eq!(version_matches_pin((20, 11, 0), ">=20 <23"), Some(true)); - assert_eq!(version_matches_pin((20, 11, 0), "lts/*"), Some(true)); - assert_eq!(version_matches_pin((20, 11, 0), "lts/iron"), Some(true)); + // `lts/*` 与 codename 都需要 LTS 行映射,未维护时必须按未 pin 回退,不能匹配任意版本。 + assert_eq!(version_matches_pin((20, 11, 0), "lts/*"), None); + assert_eq!(version_matches_pin((22, 23, 3), "lts/*"), None); + assert_eq!(version_matches_pin((20, 11, 0), "lts/iron"), None); + assert_eq!(version_matches_pin((22, 23, 3), "lts/iron"), None); assert_eq!(version_matches_pin((20, 11, 0), ">=22 || 20"), None); assert_eq!(version_matches_pin((20, 11, 0), ">20"), None); assert_eq!(version_matches_pin((20, 11, 0), "<=20"), None); diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs index 040ed3d36..c3be2b6e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs @@ -73,7 +73,7 @@ mod linux { target_executable: target_executable.as_os_str().as_bytes().to_vec(), target_arguments: target_arguments .into_iter() - .map(|argument| OsString::from(argument).into_vec()) + .map(|argument| argument.into_vec()) .collect(), }) } diff --git a/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md b/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md index 56a673578..93cc9e577 100644 --- a/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md +++ b/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md @@ -9,13 +9,13 @@ ## 目标 -开发构建的 Linux 命令沙箱能原生解析并复用宿主 fnm / nvm 托管的 Node 安装:宿主发现与沙箱只读挂载使用同一套窄叶校验;`.nvmrc` / `.node-version` pin 权威、`engines.node` 仅为偏好;npm 以受信任 `node ` 形态启动且不丢失 `npm install` 的联网判定。不再要求用户改系统 Node、把宿主 shim 指向 `/usr/bin`,或把托管目录软链进系统路径。 +开发构建的 Linux 命令沙箱能原生解析并复用宿主 fnm / nvm 托管的 Node 安装:宿主发现与沙箱只读挂载使用同一套窄叶校验;`.nvmrc` / `.node-version`(nvm / fnm 都读)命中已安装版本时优先、未安装时继续回退,`engines.node` 仅为偏好;npm 以受信任 `node ` 形态启动且不丢失 `npm install` 的联网判定。不再要求用户改系统 Node、把宿主 shim 指向 `/usr/bin`,或把托管目录软链进系统路径。 ## 范围 - 移除把 `node` / `npm` / `npx` 指向 `/usr/bin/*` 的宿主 shim 依赖,开发构建默认解析托管安装。 - 枚举 fnm `node-versions//installation`(含 `aliases/default`)与 nvm `versions/node/`,按窄叶规则校验完整安装前缀。 -- 版本选择:`.nvmrc` / `.node-version` pin > PATH 可解析的可用 Node > 版本管理器回退链(`engines.node` 最高匹配 > 活动版本 > 默认别名 > 已安装最高版本)。 +- 版本选择:`.nvmrc` / `.node-version` pin > PATH 可解析的可用 Node > 版本管理器回退链(`engines.node` 最高匹配 > 默认别名 > 已安装最高版本)。活动版本不单独查询:激活时它已在 `PATH` 里,回退链不再重复一份可能失效的副本(原 `FNM_MULTISHELL_PATH` / `NVM_BIN` 分支已删除)。 - Linux 命令沙箱把通过校验的完整安装前缀只读挂载,并以 `node ` 启动 npm;`npm install` 保持联网判定。 - 沙箱内联环境不继承 fnm multishell 等临时版本管理器变量。 @@ -35,7 +35,7 @@ ## 验收标准 - [x] 开发构建在没有 `/usr/bin` shim 的情况下从 fnm / nvm 找到 Node,并在沙箱内运行出真实 `node --version` 与 `npm --version`。 -- [x] `.nvmrc` / `.node-version` 指定的版本未安装时命令失败关闭,且不回退到其它已安装版本;`engines.node` 不匹配时不阻塞。 +- [x] `.nvmrc` / `.node-version`(nvm / fnm 都读)指定的版本未安装时命令不阻塞,继续回退到 PATH / `engines` 偏好 / 默认别名 / 已安装最高版本;`engines.node` 不匹配时同样不阻塞。 - [x] 不支持的 pin 写法(如 `iojs`、`>20`、`<=20`)按未 pin 处理并回退,而不是误判为「已理解但未命中」。 - [x] 只有通过窄叶校验的完整安装前缀会被只读挂载;HOME、管理器根、`aliases`、宽泛目录、不完整前缀和逃逸 symlink 全部失败关闭。 - [x] npm 在 Linux 上以受信任 `node ...` 启动,`npm install` 仍被判定为联网命令,普通 `npm run` 仍离线。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 27d5327b4..9c53b070b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,11 +4,11 @@ - 背景:开发构建里 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 软链必然丢 `../lib/cli.js`;只读挂载整棵已验证的安装前缀(不是整个 HOME、`FNM_DIR` 或 `NVM_DIR`)。`npm install` 的联网判定跟随真实启动形态(`node` + `npm-cli.js` + `install`),不因包装变化丢 `--share-net`。 +- 决策(版本选择):`.nvmrc` / `.node-version`(nvm / fnm 都读)只改变搜索顺序、不是硬性要求:命中已安装版本就优先用它,能理解但未安装时按未 pin 继续回退,不再返回 `node-version-pinned-not-installed`;`package.json` `engines.node` 同样是偏好,永不阻塞;不支持的写法(`iojs`、`||`、部分 `>` / `<=`、hyphen range、prerelease)按未 pin 回退。整体顺序为 pin 命中 > PATH 可解析的可用 Node > 版本管理器回退链(`engines` 最高匹配 > 默认别名 > 已安装最高版本);活动版本不单独查询(`FNM_MULTISHELL_PATH` / `NVM_BIN`),因为激活时它已在 `PATH` 里,回退链无需重复一份可能失效的副本;只实现文档化比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*`;`lts/*` 与 `lts/` 都需要 LTS 行映射,当前按未 pin 回退而不猜);`v22.23.3` 这类带 `v` 的 `.nvmrc` 必须先剥前缀。nvm 的 `alias/default` 常只写主版本号(如 `22`),必须按同一 pin 子集在已安装版本里选最高匹配,不能当成完整三元组 `(22,0,0)`。 +- 决策(沙箱内启动形态与联网放行):Linux npm 改为 `node `,因为单文件挂载 npm 软链必然丢 `../lib/cli.js`;只读挂载整棵已验证的安装前缀(不是整个 HOME、`FNM_DIR` 或 `NVM_DIR`)。`npm install` 的联网放行只认解析层给出的可信 `(node, npm_cli)` 精确匹配(executable 是对应 node、首个参数是对应 `npm-cli.js`、子命令是 `install`),不再按 basename 判定:项目里同名的 `npm-cli.js` 可写,只加「路径必须绝对」也能绕过;拿不到可信对时一律不放网。 - 决策(范围与非目标):托管版本管理器发现只在 `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。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/environment_check.rs`(窄叶校验、托管安装枚举、pin / engines 解析、选择与回退)、`command_sandbox.rs`(Node 工具链挂载收集与合并、`prepare_command_sandbox_launch` 透传可信 `(node, npm_cli)`、`LinuxSandboxPlan::npm_install_network`、`FNM_MULTISHELL_PATH` 清理)、`command_exec.rs`(Linux npm `node_launcher`、`project_command_actual_target`、`prepare_project_command_launch_spec` 把可信 launcher 交给沙箱、非 Node 程序 PATH 前置工具链 bin)、`process_session_bridge.rs`(`ProcessSessionLaunchPlan::from_launch` 改用实际目标)。 +- 验证方式:`environment_check` 25 passed、`command_sandbox` 15 passed、`command_exec` 19 passed、`process_session` 27 passed(均 `--test-threads=1`);`npm_install_network_detection_requires_the_trusted_node_launcher` 覆盖「项目同名 `npm-cli.js` 不放网、无可信 launcher 不放网、executable 不匹配不放网」,`namespace_arguments_share_net_only_when_trusted` 覆盖 `--share-net` 开关,`npm_command_targets_node_plus_npm_cli_on_linux` 断言解析层可信 `(node, npm_cli)` 与实际目标逐字一致;`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 自带白名单不再共享 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b738c2294..0764f1310 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -17,8 +17,9 @@ - **根因 2(`--tmpfs /run` 抹掉活动版本)**:fnm 的活动 `PATH` 项是 `/run/user//fnm_multishells//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 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 --version` 均通过;单元用例覆盖 pin / engines / 不支持写法 / 宽叶与逃逸前缀 / 联网判定。 +- **根因 4(联网放行只看 basename 会被同名文件冒充)**:`npm install` 是唯一允许联网的入口,最初只按「首个参数 basename 是 `npm-cli.js` 且第二个参数是 `install`」放行。Linux 上 `command.exec` 不对 `node` 的脚本参数做路径校验,项目根又可写,于是在项目里放一个自写的 `npm-cli.js` 再 `node 项目/npm-cli.js install` 就能拿到 `--share-net`。修法是让联网放行与解析层给出的可信 `(node, npm_cli)` 精确比对,拿不到可信对时一律不放网;只加「路径必须绝对」不够,绝对路径的项目内文件照样绕过。 +- **现行口径**:宿主发现与沙箱挂载共用 `validate_node_installation_prefix`;`.nvmrc` / `.node-version`(nvm / fnm 都读)命中已安装版本时优先、未安装时继续回退,`engines.node` 同为偏好;Linux npm 以 `node ` 启动,`npm install` 联网放行只认解析层给出的可信 `(node, npm_cli)` 精确匹配。契约见技术方案 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 --version` 均通过;单元用例覆盖 pin / engines / 不支持写法 / 宽叶与逃逸前缀 / 可信 launcher 联网放行。 ## 2026-10-07 cargo 目标目录里被"刷新 mtime"的陈旧 shared-contracts 会让编译报源文件里明明存在的字段缺失 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 64eb65456..044cca4c8 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -621,9 +621,9 @@ Runner-kill E2E 不再以 latest task 或单个 process record 推断整体恢 开发构建(`debug_assertions`)下,AGC 生成和验证 Web 项目所用的 Node 往往由 fnm / nvm 等版本管理器托管:安装前缀位于用户目录下,活动 `PATH` 指向随 shell 会话变化的临时目录(如 fnm 的 multishell)。V1.11.2 让开发构建的命令沙箱原生解析并只读复用这些托管安装,不再要求用户改系统 Node、把宿主 shim 指到 `/usr/bin`,或把托管目录软链进系统路径。发布构建继续只认随包 bundle,不新增托管版本管理器探测,也不改变 bundle 缺失时的失败口径;Windows 行为不变。 - 版本来源是机器上可枚举的托管安装:fnm 的 `node-versions//installation`(含 `aliases/default` 指向的默认别名)与 nvm 的 `versions/node/`。宿主发现和沙箱只读挂载共用同一套窄叶校验,不允许两处信任口径漂移。 -- 解析优先级为:`.nvmrc` / `.node-version` 的权威 pin 命中 > 宿主 `PATH` 能解析出的可用 Node > 版本管理器回退链(`package.json` `engines.node` 偏好中的最高匹配 > 当前活动版本 > 默认别名 > 已安装最高版本)。`.nvmrc` / `.node-version` 能理解但未安装时必须失败关闭(`node-version-pinned-not-installed`),不得静默回退;`engines.node` 只是偏好,任何情况下都不阻塞。只实现文档化的比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*` 通配、`lts/*`);不支持或无法解析的写法按「未 pin」处理并回退。 +- 解析优先级为:`.nvmrc` / `.node-version`(nvm / fnm 都读)命中已安装版本 > 宿主 `PATH` 能解析出的可用 Node > 版本管理器回退链(`package.json` `engines.node` 偏好中的最高匹配 > 默认别名 > 已安装最高版本)。不单独查询活动版本(`FNM_MULTISHELL_PATH` / `NVM_BIN`):激活时它已经在 `PATH` 里,回退链重复一份可能失效的副本只会带来误导。`.nvmrc` / `.node-version` 只改变搜索顺序,不是硬性要求:能理解但未安装时按「未 pin」继续回退,不返回 `node-version-pinned-not-installed`、不阻塞命令;`engines.node` 同样只是偏好,任何情况下都不阻塞。只实现文档化的比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*` 通配);`lts/*` 与 `lts/` 都需要 LTS 行映射,当前与其它不支持写法一样按「未 pin」处理并回退,不声称匹配任意版本。 - 只读挂载只允许通过窄叶校验的完整安装前缀(同时含 `bin/node` 与 npm 的 `npm-cli.js`)。HOME、`FNM_DIR` / `NVM_DIR` 根、`aliases` 目录、宽泛用户目录、不完整前缀,以及 canonicalize 后逃逸出受控前缀的 symlink 全部拒绝并失败关闭;不得为了兼容而挂载整个用户 HOME 或版本管理器数据目录。 -- Linux 上 npm 不再直接执行 npm shim,而是以受信任的 `node ...` 启动;`npm install` 的联网判定必须跟随这条真实启动形态,不能因为包装方式变化而丢失联网或反向放开。 +- Linux 上 npm 不再直接执行 npm shim,而是以受信任的 `node ...` 启动;`npm install` 的联网放行必须与解析层给出的可信 `(node, npm_cli)` 精确一致(executable 是对应的 node、首个参数是对应的 `npm-cli.js`、子命令是 `install`),不能只匹配 basename——项目根可写,项目里同名的 `npm-cli.js` 会骗到 `--share-net`;拿不到可信对时一律不放网。 - 沙箱内联环境不继承宿主活动版本管理器的临时变量(如 fnm multishell 路径);版本管理器 CLI(`fnm` / `nvm`)本身不需要在沙箱内可用。 ## V1.12 受控本地 Git 提交 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index f5548ec51..48bfe7c74 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -865,7 +865,7 @@ 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 install` 的联网判定跟随该真实形态;sandbox 环境剔除 fnm multishell 变量。发布构建仍只认随包 bundle,Windows 不变。定向 Rust 测试与真实 bwrap 内 fnm v22 的 `node` / npm 运行已通过;真实 nvm 端到端待补。 +2026-10-07 V1.11.2 切片:Linux 命令沙箱原生支持 fnm / nvm 托管的 Node。开发构建(`debug_assertions`)按 `.nvmrc` / `.node-version`(nvm / fnm 都读)命中、`package.json` `engines.node` 偏好、版本管理器默认别名和已安装最高版本的顺序选择托管安装;`.nvmrc` / `.node-version` 未安装时继续回退、不阻塞,`engines.node` 和无法解析的写法同样不阻塞。宿主发现与沙箱只读挂载共用同一窄叶校验,只挂载完整安装前缀(含 `bin/node` 与 npm 的 `npm-cli.js`),拒绝 HOME、管理器根、`aliases`、宽泛目录、不完整前缀和逃逸 symlink。Linux npm 以 `node ` 启动,`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,恢复不得重放提交。