From 51e6d1d9b958440666a5f8881812b0da65929aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 11:07:16 +0800 Subject: [PATCH 1/2] =?UTF-8?q?AGC=E5=91=BD=E4=BB=A4=E6=B2=99=E7=AE=B1?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E6=94=AF=E6=8C=81fnm/nvm=E6=89=98=E7=AE=A1No?= =?UTF-8?q?de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增共享窄叶校验 validate_node_installation_prefix,只接受含 bin/node 与 lib/node_modules/npm/bin/npm-cli.js 的完整 Node 安装前缀,拒绝 HOME、管理器根、宽泛目录和逃逸 symlink - 开发构建枚举 fnm node-versions//installation 与 nvm versions/node/,按 .nvmrc / .node-version 权威 pin、PATH、engines.node 偏好、活动版本、默认别名、最高版本选择 - .nvmrc / .node-version 能理解但未安装时返回 node-version-pinned-not-installed 失败关闭;engines.node 与无法解析的 pin 不阻塞 - Linux 命令沙箱只读挂载通过校验的完整 Node 安装前缀,并在内联环境剔除 FNM_MULTISHELL_PATH - Linux npm 改以受信任 node 启动,npm install 联网判定跟随真实启动形态 - 非 Node 程序在 Linux 受信任 PATH 前置 Node 工具链 bin,npx / corepack 等随 node 同版本 - 同步技术方案 V1.11.2、实施计划切片、里程碑规范、decision-log 与 pitfalls --- .../src-tauri/src/command_exec.rs | 153 +++- .../src-tauri/src/command_sandbox.rs | 300 +++++++- .../src-tauri/src/environment_check.rs | 712 +++++++++++++++++- .../src-tauri/src/process_session_bridge.rs | 8 +- ...‘】AGC命令沙箱Node版本管理器支持-2026-10-07.md | 53 ++ .../shared-memory/decision-log.md | 11 + docs/project-memory/shared-memory/pitfalls.md | 16 + ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 10 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + 9 files changed, 1205 insertions(+), 60 deletions(-) create mode 100644 docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md 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 14a4e6096..5d6e9f58a 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 @@ -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)] @@ -525,6 +535,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)?; @@ -632,17 +643,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, }) } @@ -676,17 +688,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, }) } @@ -874,36 +887,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 { 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 ` 启动;其余情况保持 `executable + args`。 +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()); + return (node.clone(), arguments); + } + } + ( + spec.executable.clone(), + project_command_actual_arguments(spec), + ) } fn resolve_project_command_executable_from_path( @@ -1591,13 +1649,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, ) @@ -1700,7 +1761,8 @@ pub(crate) fn stage_project_command_launch_spec( ) -> Result { #[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::>(); @@ -1712,7 +1774,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| { @@ -2580,6 +2642,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() { @@ -2779,6 +2869,7 @@ mod tests { cwd: root.to_path_buf(), timeout_seconds: 20, verification_eligible: false, + node_launcher: None, }; let staged = StagedProjectCommandLaunchSpec { launch: ProjectCommandLaunchSpec { 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 c944151cb..ffcbf2714 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 @@ -295,16 +295,7 @@ mod linux { environment: &[(OsString, OsString)], ) -> Result { 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::::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 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, String> { + let mut mounts = BTreeMap::::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 { + 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) { + 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, + second: Vec, + ) -> Vec { + 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::::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,62 @@ 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_staged_gate_real_linux_opt_in_blocks_until_commit() { if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() { 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 5025dc08c..26b392dc6 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 @@ -282,11 +282,489 @@ fn validate_bundle(directory: &Path) -> Result<(), String> { Ok(()) } +fn host_home_directory() -> Option { + #[cfg(windows)] + { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + } + #[cfg(not(windows))] + { + std::env::var_os("HOME").map(PathBuf::from) + } +} + +/// 受控 Node 安装前缀:`/bin/node` + `/lib/node_modules/npm`(fnm / nvm / 系统 +/// 发行版)或随包 `/node` + `/node_modules/npm`。宽泛宿主目录、用户 HOME 及其 +/// 祖先、逃逸符号链接都在这里失败关闭;命令沙箱复用同一校验,避免主机发现与 bwrap mount 两套 +/// 信任规则漂移。 +pub(crate) fn validate_node_installation_prefix(prefix: &Path) -> Result { + let canonical = fs::canonicalize(prefix) + .map_err(|error| format!("无法 canonicalize node 安装前缀:{error}"))?; + if !canonical.is_absolute() || canonical.parent().is_none() { + return Err("node 安装前缀必须是非根目录的绝对路径".to_string()); + } + const DENIED_ROOTS: [&str; 11] = [ + "/home", "/root", "/tmp", "/var", "/etc", "/proc", "/dev", "/run", "/sys", "/boot", "/srv", + ]; + if DENIED_ROOTS + .iter() + .any(|denied| canonical == Path::new(denied)) + { + return Err(format!( + "拒绝把宽泛宿主目录作为 node 安装前缀:{}", + canonical.display() + )); + } + if let Some(home) = host_home_directory().and_then(|home| fs::canonicalize(home).ok()) { + if canonical == home || home.starts_with(&canonical) { + return Err("拒绝把用户主目录或其祖先作为 node 安装前缀".to_string()); + } + } + let bin_node = canonical.join("bin").join(executable_name()); + let bundle_node = canonical.join(executable_name()); + let bin_npm_cli = canonical.join("lib/node_modules/npm/bin/npm-cli.js"); + let bundle_npm_cli = canonical.join("node_modules/npm/bin/npm-cli.js"); + let node = if bin_node.is_file() { + Some(bin_node) + } else if bundle_node.is_file() { + Some(bundle_node) + } else { + None + }; + let npm_cli = if bin_npm_cli.is_file() { + Some(bin_npm_cli) + } else if bundle_npm_cli.is_file() { + Some(bundle_npm_cli) + } else { + None + }; + let (node, npm_cli) = match (node, npm_cli) { + (Some(node), Some(npm_cli)) => (node, npm_cli), + _ => { + return Err(format!( + "{} 不是完整的 node 安装前缀(缺少 node 或 npm-cli.js)", + canonical.display() + )) + } + }; + for path in [&node, &npm_cli] { + let resolved = fs::canonicalize(path) + .map_err(|error| format!("node 安装前缀内路径不可解析:{error}"))?; + if !resolved.starts_with(&canonical) { + return Err("node 安装前缀存在逃逸符号链接".to_string()); + } + } + Ok(canonical) +} + +fn parse_numeric_version(value: &str) -> Option<(u64, u64, u64)> { + let value = value.trim().trim_start_matches('v'); + let mut parts = value.split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts + .next() + .map_or(Some(0), |part| part.parse::().ok())?; + let patch = parts + .next() + .map_or(Some(0), |part| part.parse::().ok())?; + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct InstalledNodeVersion { + manager: &'static str, + version: (u64, u64, u64), + prefix: PathBuf, +} + +fn environment_managed_roots() -> (Vec, Vec) { + let mut fnm_roots = Vec::new(); + let mut nvm_roots = Vec::new(); + if let Some(dir) = std::env::var_os("FNM_DIR") { + fnm_roots.push(PathBuf::from(dir)); + } + if let Some(dir) = std::env::var_os("NVM_DIR") { + nvm_roots.push(PathBuf::from(dir)); + } + if let Some(home) = host_home_directory() { + fnm_roots.push(home.join(".local").join("share").join("fnm")); + nvm_roots.push(home.join(".nvm")); + } + dedup_paths(&mut fnm_roots); + dedup_paths(&mut nvm_roots); + (fnm_roots, nvm_roots) +} + +fn dedup_paths(paths: &mut Vec) { + let mut seen = BTreeSet::new(); + paths.retain(|path| seen.insert(path.clone())); +} + +fn installed_node_versions( + fnm_roots: &[PathBuf], + nvm_roots: &[PathBuf], +) -> Vec { + let mut installed = Vec::new(); + for root in fnm_roots { + installed.extend(collect_installed_node_versions( + &root.join("node-versions"), + "fnm", + true, + )); + } + for root in nvm_roots { + installed.extend(collect_installed_node_versions( + &root.join("versions").join("node"), + "nvm", + false, + )); + } + installed +} + +fn collect_installed_node_versions( + versions_dir: &Path, + manager: &'static str, + nested_installation: bool, +) -> Vec { + let Ok(entries) = fs::read_dir(versions_dir) else { + return Vec::new(); + }; + let mut installed = Vec::new(); + for entry in entries.flatten() { + let Some(version) = parse_numeric_version(&entry.file_name().to_string_lossy()) else { + continue; + }; + let candidate = if nested_installation { + entry.path().join("installation") + } else { + entry.path() + }; + let Ok(prefix) = validate_node_installation_prefix(&candidate) else { + continue; + }; + installed.push(InstalledNodeVersion { + manager, + version, + prefix, + }); + } + installed +} + +fn runtime_from_installed( + installed: &InstalledNodeVersion, + root: &Path, + path: &OsStr, +) -> Result { + let prefix = &installed.prefix; + let bin_node = prefix.join("bin").join(executable_name()); + let node = if bin_node.is_file() { + bin_node + } else { + prefix.join(executable_name()) + }; + let bin_npm_cli = prefix.join("lib/node_modules/npm/bin/npm-cli.js"); + let npm_cli = if bin_npm_cli.is_file() { + bin_npm_cli + } else { + prefix.join("node_modules/npm/bin/npm-cli.js") + }; + runtime_from_paths(root, node, npm_cli, installed.manager, path) +} + +/// 把 `.nvmrc` / `.node-version` / `engines.node` 里的单个比较项解释成对某个已安装版本的判定。 +/// 返回 `None` 表示当前比较项超出受支持子集,调用方必须整体回落到宿主 PATH,不能猜。故意不支持 +/// `||`、连字符区间、prerelease 与部分 `>` / `<=` 语义,避免用错误匹配替换用户选中的版本。 +fn comparator_matches_version(version: (u64, u64, u64), comparator: &str) -> Option { + let (operator, rest) = if let Some(rest) = comparator.strip_prefix(">=") { + (">=", rest) + } else if let Some(rest) = comparator.strip_prefix("<=") { + ("<=", rest) + } else if let Some(rest) = comparator.strip_prefix('>') { + (">", rest) + } else if let Some(rest) = comparator.strip_prefix('<') { + ("<", rest) + } else if let Some(rest) = comparator.strip_prefix('=') { + ("=", rest) + } else if let Some(rest) = comparator.strip_prefix('^') { + ("^", rest) + } else if let Some(rest) = comparator.strip_prefix('~') { + ("~", rest) + } else { + ("=", comparator) + }; + let rest = rest.trim(); + if rest.is_empty() { + return None; + } + if rest.eq_ignore_ascii_case("x") || rest == "*" || rest.eq_ignore_ascii_case("latest") { + return Some(true); + } + let mut parsed = Vec::new(); + for component in rest.split('.') { + if component.eq_ignore_ascii_case("x") || component == "*" { + break; + } + parsed.push(component.parse::().ok()?); + } + if parsed.is_empty() || parsed.len() > 3 { + return None; + } + let parts = parsed.len(); + let major = parsed[0]; + let minor = *parsed.get(1).unwrap_or(&0); + let patch = *parsed.get(2).unwrap_or(&0); + Some(match operator { + "=" => match parts { + 1 => version.0 == major, + 2 => version.0 == major && version.1 == minor, + _ => version == (major, minor, patch), + }, + ">=" => version >= (major, minor, patch), + ">" if parts < 3 => return None, + ">" => version > (major, minor, patch), + "<" => version < (major, minor, patch), + "<=" if parts < 3 => return None, + "<=" => version <= (major, minor, patch), + "^" => { + if major > 0 { + version >= (major, minor, patch) && version.0 == major + } else if parts >= 2 && minor > 0 { + version >= (0, minor, patch) && version.0 == 0 && version.1 == minor + } else if parts >= 3 { + version >= (0, 0, patch) && version.0 == 0 && version.1 == 0 && version.2 == patch + } else { + version.0 == 0 + } + } + "~" => { + if parts >= 2 { + version >= (major, minor, patch) && version.0 == major && version.1 == minor + } else { + version >= (major, 0, 0) && version.0 == major + } + } + _ => return None, + }) +} + +fn version_matches_pin(version: (u64, u64, u64), pin: &str) -> Option { + let pin = pin.trim(); + if pin.is_empty() { + return Some(true); + } + // `.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/") + { + return Some(true); + } + if lower.starts_with("iojs") || lower.contains("||") { + return None; + } + let mut any = false; + for comparator in pin.split_whitespace() { + any = true; + if !comparator_matches_version(version, comparator)? { + return Some(false); + } + } + if any { + Some(true) + } else { + None + } +} + +enum PinSelection<'a> { + Matched(&'a InstalledNodeVersion), + NoMatch, + Unsupported, +} + +fn select_pinned_installation<'a>( + installed: &'a [InstalledNodeVersion], + pin: &str, +) -> PinSelection<'a> { + let mut best: Option<&InstalledNodeVersion> = None; + let mut understood = false; + for candidate in installed { + match version_matches_pin(candidate.version, pin) { + Some(true) => { + understood = true; + if best.is_none_or(|current| candidate.version > current.version) { + best = Some(candidate); + } + } + Some(false) => understood = true, + None => return PinSelection::Unsupported, + } + } + match best { + Some(best) => PinSelection::Matched(best), + None if understood => PinSelection::NoMatch, + None => PinSelection::Unsupported, + } +} + +fn read_project_version_file_pin(root: &Path) -> Option { + for name in [".nvmrc", ".node-version"] { + let path = root.join(name); + let Ok(metadata) = fs::metadata(&path) else { + continue; + }; + if !metadata.is_file() || metadata.len() > 4096 { + continue; + } + let Ok(content) = fs::read_to_string(&path) else { + continue; + }; + if let Some(pin) = content + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with('#')) + { + return Some(pin.to_string()); + } + } + None +} + +fn read_project_engines_range(root: &Path) -> Option { + let path = root.join("package.json"); + let metadata = fs::metadata(&path).ok()?; + if !metadata.is_file() || metadata.len() > 4 * 1024 * 1024 { + return None; + } + let bytes = fs::read(&path).ok()?; + let value: Value = serde_json::from_slice(&bytes).ok()?; + let range = value.get("engines")?.get("node")?.as_str()?.trim(); + if range.is_empty() { + None + } else { + Some(range.to_string()) + } +} + +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], + nvm_roots: &[PathBuf], +) -> Option<&'a InstalledNodeVersion> { + for root in fnm_roots { + let alias = root.join("aliases").join("default"); + if let Ok(prefix) = validate_node_installation_prefix(&alias) { + if let Some(node) = installed.iter().find(|node| node.prefix == prefix) { + return Some(node); + } + } + } + for root in nvm_roots { + let Ok(content) = fs::read_to_string(root.join("alias").join("default")) else { + continue; + }; + let Some(version) = parse_numeric_version(content.trim()) else { + continue; + }; + if let Some(node) = installed.iter().find(|node| node.version == version) { + return Some(node); + } + } + None +} + +fn resolve_pinned_managed_runtime( + root: &Path, + path: &OsStr, + fnm_roots: &[PathBuf], + nvm_roots: &[PathBuf], +) -> Result, String> { + let installed = installed_node_versions(fnm_roots, nvm_roots); + if installed.is_empty() { + return Ok(None); + } + let Some(pin) = read_project_version_file_pin(root) else { + return Ok(None); + }; + 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), + } +} + +fn resolve_managed_fallback_runtime( + root: &Path, + path: &OsStr, + fnm_roots: &[PathBuf], + nvm_roots: &[PathBuf], +) -> Result, String> { + let installed = installed_node_versions(fnm_roots, nvm_roots); + if installed.is_empty() { + return Ok(None); + } + // engines.node 只作为“在已安装版本里优先选谁”的偏好,不阻塞;真正的版本文件 pin 才失败关闭。 + if let Some(range) = read_project_engines_range(root) { + if let PinSelection::Matched(node) = select_pinned_installation(&installed, &range) { + 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)?)); + } + let node = installed + .iter() + .max_by_key(|node| node.version) + .expect("non-empty installed versions"); + Ok(Some(runtime_from_installed(node, root, path)?)) +} + fn resolve_at( root: &Path, bundle: Option<&Path>, development: bool, path: &OsStr, +) -> Result { + let (fnm_roots, nvm_roots) = environment_managed_roots(); + resolve_at_with_managed_roots(root, bundle, development, path, &fnm_roots, &nvm_roots) +} + +fn resolve_at_with_managed_roots( + root: &Path, + bundle: Option<&Path>, + development: bool, + path: &OsStr, + fnm_roots: &[PathBuf], + nvm_roots: &[PathBuf], ) -> Result { let root = root .canonicalize() @@ -304,16 +782,31 @@ fn resolve_at( if !development { return Err("node-runtime-bundle-missing".into()); } - let directories = safe_directories(&root, path); + if let Some(runtime) = resolve_pinned_managed_runtime(&root, path, fnm_roots, nvm_roots)? { + return Ok(runtime); + } + if let Some(runtime) = resolve_host_path_runtime(&root, path) { + return Ok(runtime); + } + if let Some(runtime) = resolve_managed_fallback_runtime(&root, path, fnm_roots, nvm_roots)? { + return Ok(runtime); + } + Err("node-npm-runtime-missing".into()) +} + +fn resolve_host_path_runtime(root: &Path, path: &OsStr) -> Option { + let directories = safe_directories(root, path); for directory in &directories { let candidate = directory.join(executable_name()); let Ok(node) = candidate.canonicalize() else { continue; }; - if !node.is_file() || node.starts_with(&root) { + if !node.is_file() || node.starts_with(root) { continue; } - let parent = node.parent().ok_or("node-runtime-invalid")?; + let Some(parent) = node.parent() else { + continue; + }; let mut npm_candidates = vec![ parent.join("node_modules/npm/bin/npm-cli.js"), parent.join("../lib/node_modules/npm/bin/npm-cli.js"), @@ -330,10 +823,12 @@ fn resolve_at( .into_iter() .find(|candidate| candidate.is_file()) { - return runtime_from_paths(&root, node, npm_cli, "development", path); + if let Ok(runtime) = runtime_from_paths(root, node, npm_cli, "development", path) { + return Some(runtime); + } } } - Err("node-npm-runtime-missing".into()) + None } pub(crate) fn resolve_node_runtime(root: &Path) -> Result { @@ -617,7 +1112,7 @@ mod tests { fs::write(root.path().join(executable_name()), b"fake").unwrap(); let path = std::env::join_paths([Path::new("."), root.path()]).unwrap(); assert_eq!( - resolve_at(root.path(), None, true, &path).unwrap_err(), + resolve_at_with_managed_roots(root.path(), None, true, &path, &[], &[]).unwrap_err(), "node-npm-runtime-missing" ); assert!(!valid_version("v24.0.0\nSECRET")); @@ -682,4 +1177,209 @@ mod tests { "1.0.0" ); } + + fn install_node_fixture(prefix: &Path) { + let bin = prefix.join("bin"); + let npm = prefix.join("lib/node_modules/npm/bin"); + fs::create_dir_all(&bin).unwrap(); + fs::create_dir_all(&npm).unwrap(); + fs::write(bin.join(executable_name()), b"node").unwrap(); + fs::write(npm.join("npm-cli.js"), b"npm").unwrap(); + } + + fn install_fnm_version(root: &Path, version: &str) -> PathBuf { + let installation = root + .join("node-versions") + .join(version) + .join("installation"); + install_node_fixture(&installation); + installation + } + + fn install_nvm_version(root: &Path, version: &str) -> PathBuf { + let prefix = root.join("versions").join("node").join(version); + install_node_fixture(&prefix); + prefix + } + + #[test] + fn node_installation_prefix_rejects_home_incomplete_and_symlink_escape() { + let fnm_root = tempfile::tempdir().unwrap(); + let installation = install_fnm_version(fnm_root.path(), "v22.23.3"); + assert_eq!( + validate_node_installation_prefix(&installation).unwrap(), + installation.canonicalize().unwrap() + ); + if let Some(home) = host_home_directory() { + assert!(validate_node_installation_prefix(&home).is_err()); + } + let incomplete = tempfile::tempdir().unwrap(); + fs::create_dir_all(incomplete.path().join("bin")).unwrap(); + fs::write( + incomplete.path().join("bin").join(executable_name()), + b"node", + ) + .unwrap(); + assert!(validate_node_installation_prefix(incomplete.path()).is_err()); + + let link_root = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + + let valid_link = link_root.path().join("installation-link"); + symlink(&installation, &valid_link).unwrap(); + // 指向真实安装的 symlink 解析后仍是完整前缀。 + assert_eq!( + validate_node_installation_prefix(&valid_link).unwrap(), + installation.canonicalize().unwrap() + ); + let version_link = link_root.path().join("version-link"); + symlink( + fnm_root.path().join("node-versions").join("v22.23.3"), + &version_link, + ) + .unwrap(); + // 解析后不是安装前缀(缺 bin/node + lib/node_modules/npm)必须失败关闭。 + assert!(validate_node_installation_prefix(&version_link).is_err()); + } + let _ = link_root; + } + + #[test] + fn collects_fnm_and_nvm_installations_and_falls_back_to_highest() { + let fnm_root = tempfile::tempdir().unwrap(); + let nvm_root = tempfile::tempdir().unwrap(); + install_fnm_version(fnm_root.path(), "v22.23.3"); + install_nvm_version(nvm_root.path(), "v20.11.0"); + let installed = installed_node_versions( + &[fnm_root.path().to_path_buf()], + &[nvm_root.path().to_path_buf()], + ); + assert_eq!(installed.len(), 2); + assert_eq!(installed[0].manager, "fnm"); + assert_eq!(installed[1].manager, "nvm"); + + let project = tempfile::tempdir().unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[nvm_root.path().to_path_buf()], + ) + .unwrap(); + assert_eq!(runtime.source, "fnm"); + assert!(runtime.node.to_string_lossy().contains("v22.23.3")); + } + + #[test] + fn version_file_pin_is_authoritative_and_blocks_when_not_installed() { + 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(); + fs::write(project.path().join(".nvmrc"), "20\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("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" + ); + } + + #[test] + fn engines_range_is_a_preference_and_never_blocks() { + 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(); + fs::write( + project.path().join("package.json"), + r#"{"engines":{"node":"^20.0.0"}}"#, + ) + .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("package.json"), + r#"{"engines":{"node":"^18.0.0"}}"#, + ) + .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("v22.23.3")); + } + + #[test] + fn unsupported_version_pin_falls_back_instead_of_blocking() { + 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"] { + fs::write(project.path().join(".node-version"), pin).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"); + } + } + + #[test] + fn version_pin_comparators_follow_the_documented_subset() { + assert_eq!(version_matches_pin((20, 11, 0), "20"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "20.11"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "20.11.0"), Some(true)); + assert_eq!(version_matches_pin((20, 11, 0), "22"), Some(false)); + 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)); + 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 36ac074df..040ed3d36 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 @@ -44,9 +44,11 @@ mod linux { launch: &ProjectCommandLaunchSpec, spec: &ProjectCommandSpec, ) -> Result { + 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(), diff --git a/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md b/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md new file mode 100644 index 000000000..3cd4deaa0 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md @@ -0,0 +1,53 @@ +# 【里程碑】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 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` 最高匹配 > 活动版本 > 默认别名 > 已安装最高版本)。 +- Linux 命令沙箱把通过校验的完整安装前缀只读挂载,并以 `node ` 启动 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 install` 仍被判定为联网命令,普通 `npm run` 仍离线。 +- [x] 宿主发现与沙箱挂载共用同一套窄叶校验,不存在第二份信任口径。 +- [ ] 真实 nvm 安装前缀的端到端沙箱运行(本机未安装 nvm,改用临时目录夹具覆盖布局与解析)。 + +## 证据要求 + +- 自动化: + - `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` + - `cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 +- 运行时:真实 fnm v22 前缀下,bwrap 内 `node -e 'process.stdout.write(process.version)'` 与 `node --version` 均成功且版本为 v22。 +- 边界:`.nvmrc` pin 未安装、`engines.node` 不匹配、不支持 pin、宽泛 / 不完整前缀、逃逸 symlink、`npm run` 离线与 `npm install` 联网。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e9144cb56..d77c3a6bd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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` 必须先剥前缀。 +- 决策(沙箱内启动形态):Linux npm 改为 `node `,因为单文件挂载 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` 23 passed、`command_sandbox` 13 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 通过;`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。 +- 边界:本机未安装 nvm,nvm 布局由临时目录夹具覆盖;真实 nvm 端到端留待有 nvm 的机器。真实验收前不宣称发布构建也支持 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 参数表没写默认值。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 54d255a4e..ba13072dd 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,22 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 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//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。 +- **现行口径**:宿主发现与沙箱挂载共用 `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 / 不支持写法 / 宽叶与逃逸前缀 / 联网判定。 + +## 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-.rmeta`,其 `.d` 依赖文件停留在旧时间戳,而 `.rmeta` 的 mtime 在快照 / 拷贝过程中被刷新成新时间;cargo 按 mtime 判定该 crate 仍然新鲜,于是把 `--extern shared_contracts=` 指到旧 rmeta,下游就看到旧 API。多份不同 feature 组合的 `shared-contracts-` 并存时更容易踩中。 +- **处理**:删除该 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 对齐 + 浅暖色」修过一轮,最终整体删除。 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 79a475a62..64eb65456 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 @@ -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//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」处理并回退。 +- 只读挂载只允许通过窄叶校验的完整安装前缀(同时含 `bin/node` 与 npm 的 `npm-cli.js`)。HOME、`FNM_DIR` / `NVM_DIR` 根、`aliases` 目录、宽泛用户目录、不完整前缀,以及 canonicalize 后逃逸出受控前缀的 symlink 全部拒绝并失败关闭;不得为了兼容而挂载整个用户 HOME 或版本管理器数据目录。 +- Linux 上 npm 不再直接执行 npm shim,而是以受信任的 `node ...` 启动;`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` 只读沙箱。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 1b544abe4..65beac350 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -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 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 项目已自动清理。 From 7ed3696959d5d35028a03f86fd435972aa74e3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 11:12:49 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dnvm=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=88=AB=E5=90=8D=E5=B9=B6=E8=A1=A5=E7=9C=9F=E5=AE=9Envm?= =?UTF-8?q?=E6=B2=99=E7=AE=B1=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nvm 的 alias/default 只写主版本号(如 22),改为按同一 pin 子集在已安装版本里选最高匹配,避免按 (22,0,0) 精确匹配永远落空 - 新增 opt-in 真机测试 command_sandbox_real_linux_opt_in_runs_nvm_installation_prefix,在 bwrap 内跑真实 nvm 安装前缀的 node 与 npm - 新增单测 nvm_major_only_default_alias_selects_the_installed_patch 守住主版本号别名 - 里程碑、decision-log 与 pitfalls 记录真实 nvm v0.40.8 + Node v22.23.3 验证与 default 别名口径 --- .../src-tauri/src/command_sandbox.rs | 68 +++++++++++++++++++ .../src-tauri/src/environment_check.rs | 25 +++++-- ...‘】AGC命令沙箱Node版本管理器支持-2026-10-07.md | 3 +- .../shared-memory/decision-log.md | 6 +- docs/project-memory/shared-memory/pitfalls.md | 1 + 5 files changed, 95 insertions(+), 8 deletions(-) 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 ffcbf2714..109304b59 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 @@ -1543,6 +1543,74 @@ print("SANDBOX_OK") 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::>(); + 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() { 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 26b392dc6..52fe1dbb1 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 @@ -687,10 +687,9 @@ fn default_managed_installation<'a>( let Ok(content) = fs::read_to_string(root.join("alias").join("default")) else { continue; }; - let Some(version) = parse_numeric_version(content.trim()) else { - continue; - }; - if let Some(node) = installed.iter().find(|node| node.version == version) { + // nvm 的 default 别名常写成 `22`、`v22.23.3` 或 `lts/*`,不是完整三元组;按 pin 的 + // 同一子集在已安装版本里选最高匹配,避免 `22` 被当成 (22,0,0) 而永远匹配不到。 + if let PinSelection::Matched(node) = select_pinned_installation(installed, content.trim()) { return Some(node); } } @@ -1274,6 +1273,24 @@ mod tests { assert!(runtime.node.to_string_lossy().contains("v22.23.3")); } + #[test] + fn nvm_major_only_default_alias_selects_the_installed_patch() { + let nvm_root = tempfile::tempdir().unwrap(); + install_nvm_version(nvm_root.path(), "v20.11.0"); + let expected = install_nvm_version(nvm_root.path(), "v22.23.3"); + let alias_dir = nvm_root.path().join("alias"); + fs::create_dir_all(&alias_dir).unwrap(); + // `nvm alias default 22` 写的是主版本号,不是完整三元组。 + fs::write(alias_dir.join("default"), "22\n").unwrap(); + let installed = installed_node_versions(&[], &[nvm_root.path().to_path_buf()]); + assert_eq!(installed.len(), 2); + let default = + default_managed_installation(&installed, &[], &[nvm_root.path().to_path_buf()]) + .expect("major-only default alias should resolve"); + assert_eq!(default.prefix, expected.canonicalize().unwrap()); + assert_eq!(default.version, (22, 23, 3)); + } + #[test] fn version_file_pin_is_authoritative_and_blocks_when_not_installed() { let fnm_root = tempfile::tempdir().unwrap(); 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 3cd4deaa0..56a673578 100644 --- a/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md +++ b/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md @@ -40,7 +40,7 @@ - [x] 只有通过窄叶校验的完整安装前缀会被只读挂载;HOME、管理器根、`aliases`、宽泛目录、不完整前缀和逃逸 symlink 全部失败关闭。 - [x] npm 在 Linux 上以受信任 `node ...` 启动,`npm install` 仍被判定为联网命令,普通 `npm run` 仍离线。 - [x] 宿主发现与沙箱挂载共用同一套窄叶校验,不存在第二份信任口径。 -- [ ] 真实 nvm 安装前缀的端到端沙箱运行(本机未安装 nvm,改用临时目录夹具覆盖布局与解析)。 +- [x] 真实 nvm(v0.40.8 + Node v22.23.3)安装前缀的端到端沙箱运行,以及在仅 nvm 环境(`NVM_DIR` + 空 PATH + 临时 HOME)下的托管解析;同时修正 nvm `alias/default` 只写主版本号(如 `22`)时被当作 `(22,0,0)` 而匹配不到已安装补丁版本的问题。 ## 证据要求 @@ -48,6 +48,7 @@ - `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= GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1 cargo test … -- command_sandbox_real_linux_opt_in_runs_nvm_installation_prefix`;仅 nvm 解析:`env -i HOME=<临时家目录> NVM_DIR= 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 --version` 均成功且版本为 v22。 - 边界:`.nvmrc` pin 未安装、`engines.node` 不匹配、不支持 pin、宽泛 / 不完整前缀、逃逸 symlink、`npm run` 离线与 `npm install` 联网。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d77c3a6bd..7c09a4b3e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,12 +4,12 @@ - 背景:开发构建里 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` 必须先剥前缀。 +- 决策(版本选择):`.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`。 - 决策(范围与非目标):托管版本管理器发现只在 `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` 23 passed、`command_sandbox` 13 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 通过;`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。 -- 边界:本机未安装 nvm,nvm 布局由临时目录夹具覆盖;真实 nvm 端到端留待有 nvm 的机器。真实验收前不宣称发布构建也支持 fnm / nvm。 +- 验证方式:`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 自带白名单不再共享 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index ba13072dd..e3f38e736 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -8,6 +8,7 @@ - **根因 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//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 / 不支持写法 / 宽叶与逃逸前缀 / 联网判定。