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