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 aec26aced..a4a09d8d1 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)] @@ -538,6 +548,7 @@ where cwd: root.to_path_buf(), timeout_seconds: 15, verification_eligible: false, + node_launcher: None, }; let launch = prepare_project_command_launch_spec(root, &spec)?; let mut staged = stage_project_command_launch_spec(&spec, launch)?; @@ -645,17 +656,18 @@ pub(crate) fn resolve_project_bootstrap_spec_at( )); } let program = "npm".to_string(); - let (executable, safe_path) = resolve_project_command_executable(root, &program) + let resolved = resolve_project_command_executable(root, &program) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; Ok(ProjectCommandSpec { program, - executable, - safe_path, + executable: resolved.executable, + safe_path: resolved.safe_path, arguments: vec!["install".to_string()], cwd_relative, cwd, timeout_seconds, verification_eligible: false, + node_launcher: resolved.node_launcher, }) } @@ -689,17 +701,18 @@ fn resolve_project_command_spec_inner( if program == "node" { validate_project_command_node_test_files(&cwd, arguments)?; } - let (executable, safe_path) = resolve_project_command_executable(root, &program)?; + let resolved = resolve_project_command_executable(root, &program)?; let verification_eligible = project_command_verification_eligible(&program, arguments); Ok(ProjectCommandSpec { program, - executable, - safe_path, + executable: resolved.executable, + safe_path: resolved.safe_path, arguments: arguments.to_vec(), cwd_relative, cwd, timeout_seconds, verification_eligible, + node_launcher: resolved.node_launcher, }) } @@ -887,36 +900,81 @@ fn project_command_npm_verification_script_suffix_allowed(suffix: &str) -> bool fn resolve_project_command_executable( root: &Path, program: &str, -) -> Result<(PathBuf, OsString), String> { +) -> Result { 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( @@ -1604,13 +1662,16 @@ pub(crate) fn prepare_project_command_launch_spec( } } + #[cfg(target_os = "linux")] + let (target_executable, target_arguments) = project_command_actual_target(spec); + #[cfg(not(target_os = "linux"))] let arguments = project_command_actual_arguments(spec); #[cfg(target_os = "linux")] { let sandbox = prepare_command_sandbox_launch( root, - &spec.executable, - &arguments, + &target_executable, + &target_arguments, &spec.cwd, &environment, ) @@ -1713,7 +1774,8 @@ pub(crate) fn stage_project_command_launch_spec( ) -> Result { #[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::>(); @@ -1725,7 +1787,7 @@ pub(crate) fn stage_project_command_launch_spec( environment: launch.environment, metadata: command_sandbox_platform_metadata(), }, - &spec.executable, + &target_executable, &target_arguments, ) .map_err(|error| { @@ -2593,6 +2655,34 @@ where mod tests { use super::*; + #[cfg(target_os = "linux")] + #[test] + fn npm_command_targets_node_plus_npm_cli_on_linux() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("game")).unwrap(); + let spec = resolve_project_command_spec_at( + root.path(), + "npm", + &["run".to_string(), "build".to_string()], + ".", + 30, + ) + .unwrap(); + let (executable, arguments) = project_command_actual_target(&spec); + assert_eq!( + executable.file_name().and_then(|name| name.to_str()), + Some("node"), + "{executable:?}" + ); + assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}"); + assert_eq!(&arguments[1..], ["run".to_string(), "build".to_string()]); + + let bootstrap = resolve_project_bootstrap_spec_at(root.path(), 30).unwrap(); + let (_, arguments) = project_command_actual_target(&bootstrap); + assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}"); + assert_eq!(arguments[1], "install"); + } + #[cfg(target_os = "linux")] #[tokio::test] async fn exited_group_accepts_orphan_zombies_but_rejects_live_members() { @@ -2792,6 +2882,7 @@ mod tests { cwd: root.to_path_buf(), timeout_seconds: 20, verification_eligible: false, + node_launcher: None, }; let staged = StagedProjectCommandLaunchSpec { launch: ProjectCommandLaunchSpec { 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..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 @@ -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,130 @@ print("SANDBOX_OK") } } + #[test] + fn command_sandbox_real_linux_opt_in_runs_host_node_and_npm_cli() { + if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() { + return; + } + let tree = unique_temp_tree(); + let root = tree.0.join("workspace-node-runtime"); + std::fs::create_dir_all(root.join(".agent")).expect("create runtime control"); + let Ok(runtime) = crate::environment_check::resolve_node_runtime(&root) else { + return; + }; + let environment = vec![ + (OsString::from("PATH"), runtime.safe_path.clone()), + (OsString::from("HOME"), OsString::from("/host/home")), + ]; + let run = |executable: &Path, arguments: &[String]| { + let launch = prepare_command_sandbox_launch( + &root, + executable, + arguments, + &root, + &environment, + ) + .expect("prepare node sandbox"); + let output = Command::new(&launch.executable) + .args(&launch.arguments) + .current_dir(&launch.cwd) + .env_clear() + .envs(launch.environment.iter().cloned()) + .output() + .expect("run node sandbox"); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() + }; + + let version = run( + &runtime.node, + &[ + "-e".to_string(), + "process.stdout.write(process.version)".to_string(), + ], + ); + assert!( + version.starts_with('v'), + "unexpected node version: {version}" + ); + + let npm_cli = runtime.npm_cli.to_string_lossy().into_owned(); + let npm_version = run(&runtime.node, &[npm_cli, "--version".to_string()]); + assert!(!npm_version.is_empty(), "npm --version returned nothing"); + } + + #[test] + fn command_sandbox_real_linux_opt_in_runs_nvm_installation_prefix() { + if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() { + return; + } + let Some(nvm_dir) = std::env::var_os("NVM_DIR").map(PathBuf::from) else { + return; + }; + let versions_dir = nvm_dir.join("versions").join("node"); + let Ok(entries) = std::fs::read_dir(&versions_dir) else { + return; + }; + let mut versions = entries + .flatten() + .filter_map(|entry| entry.file_name().to_str().map(str::to_string)) + .collect::>(); + 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 e187a12a1..ec6176520 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 @@ -353,11 +353,488 @@ 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; + }; + // 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); + } + } + 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() @@ -375,16 +852,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"), @@ -401,10 +893,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 { @@ -721,7 +1215,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")); @@ -808,4 +1302,227 @@ 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 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(); + 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/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 8c3b2535d..af78f75d4 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -11892,7 +11892,10 @@ button.design-workspace-tree__entry:hover, left: 0; margin: 16px; max-height: none; - z-index: 1; + /* 抬到列表里「回到底部」胶囊(z-index: 10)之上:输入盒自己是层叠上下文,模型 / 附件 + 菜单的 z-index 只在盒内有效,盒子不抬高就会被胶囊盖住。30 仍低于拖拽落点(60)与 + 设置弹窗(240)这两个整屏浮层,它们照旧盖在输入盒之上。 */ + z-index: 30; /* 盒内内边距四边同值:文字/光标的左内缩与上内缩必须相等, 否则点进输入框时(`.resource-reference-input:focus-within` 会画出焦点环) 会明显看出文字离左边近、离上边远。 */ @@ -12858,7 +12861,13 @@ button.design-workspace-tree__entry:hover, right: 0; bottom: 0; left: 0; - z-index: 240; + /* + * 必须低于二级浮层:这里点「运行配置」打开的是既有 `.settings-overlay` + * (z-index 220),插件面板 `.agc-plugin-panel-overlay` 为 230。 + * 设置浮层若压在最上层,二级弹窗就会盖在它下面;210 仍高于工作台内容 + * 与启动器(200),同时让运行配置 / 插件面板正常叠在其上。 + */ + z-index: 210; display: grid; padding: 20px; background: rgb(65 43 34 / 42%); @@ -13401,10 +13410,11 @@ button.design-workspace-tree__entry:hover, grid-row: 1 / span 2; } -/* 消息之间的间距:消息列表是块级滚动容器(不是 flex/grid),基础规则里的 `gap: 14px` - 对它无效,而被改写成 `margin-top: 0` 的 `.message + .message` 又让相邻消息贴在一起。 - 这里给列表的相邻子元素统一加间距(消息、工具调用折叠块、思考过程块都适用), - 间距值与消息内部的行距观感一致。 */ +/* 消息之间的间距:消息列表是块级滚动流,基础规则里的 `gap: 14px` 对它无效,而被改写成 + `margin-top: 0` 的 `.message + .message` 又让相邻消息贴在一起。这里给列表的相邻子元素统一 + 加间距(消息、工具调用折叠块、思考过程块都适用),间距值与消息内部的行距观感一致。 + (列表下面被改成 flex 列容器,只是为了给「回到底部」胶囊一个 `margin-top: auto` 的落点; + flex 里 margin 不折叠,这条间距规则仍照常生效。) */ .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-message-list @@ -13413,6 +13423,37 @@ button.design-workspace-tree__entry:hover, margin-top: 14px; } +/* 「回到底部」胶囊的位置真源:把消息列表做成 **flex 列容器**,胶囊是最后一个 flex 项。 + - 内容不足一屏:`margin-top: auto` 吃掉剩余空间,把胶囊顶到列表可见底边; + - 内容溢出一屏:auto 归零,胶囊落回内容末尾,再由自身的 `sticky bottom-3` 上拉贴底。 + 只有 sticky 是不够的:sticky 只能把元素**上拉**,不能下推——内容不足一屏时胶囊会停在 + 文档流里(表现为悬空,见 `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`)。 + `gap: 0` 必须显式写:上面 `.… .project-chat-message-list { gap: 14px }` 是给 flex 预备的, + 改成 flex 后它会生效,与下面那些 `> * + *` 的 margin 叠成双倍间距。 + `> * { flex: 0 0 auto }` 保证内容溢出一屏时子项不被纵向压缩,列表仍是唯一滚动区; + 子项间距仍由上面那条 `> * + *` 给(flex 里 margin 不折叠,差值与块级最多 2px)。 */ +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-message-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-message-list + > * { + flex: 0 0 auto; +} + +.game-workbench-chat + .project-chat-surface.is-direct-codex + .project-chat-message-list + > .project-chat-scroll-to-bottom { + margin-top: auto; +} + /* 发送时间与完成后的过程区沿用对话面板的次要信息样式。 */ .message-sent-at { display: block; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx index cbf78edf9..6814a56df 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx @@ -3,9 +3,17 @@ import { ArrowDown } from 'lucide-react'; /** * 底部居中的「回到底部」胶囊。 * - * 用 `sticky bottom-*` 而不是浮层:工作台里 `.project-chat-conversation` 是 `display: block` - * 加 `height: 100%` 的几何,在列表外套一层定位容器会把列表的 `height: 100%` 塌成内容高度; - * 粘在列表内部的胶囊零结构改动,两块宿主(工作台侧栏与独立页面)都能拿到。 + * 它是消息列表的**最后一个 flex 项**,贴底靠两件事配合(见 `styles.css` 里 + * `.project-chat-scroll-to-bottom` 那组规则): + * - `margin-top: auto`:内容不足一屏时吃掉剩余空间,把胶囊顶到列表可见底边; + * - `sticky bottom-3`:内容溢出一屏时把胶囊从内容末尾**上拉**贴住可见底边。 + * 只用 `sticky` 不够——它只能上拉、不能下推,内容不足一屏时元素会停在文档流里(悬空)。 + * + * 用列表内的 flex 项而不是浮层:工作台里 `.project-chat-surface.is-direct-codex > + * .project-chat-conversation` 是纵向 flex,`.project-chat-message-list` 是其中 + * `flex: 1 1 auto` 的唯一滚动项;在列表外套一层定位容器会让列表塌成内容高度, + * 改公共类又会连带 `PlanningChatView`。粘在列表内部零结构改动,两块宿主 + * (工作台侧栏与导出面板)都能拿到。 * * 显隐与文案由调用方决定(距底超过阈值才出现;不跟随时来了新回复就换文案),这里只负责表现。 */ @@ -19,7 +27,7 @@ export function DirectProjectScrollToBottomCapsule({ return (