Merge branch 'master' into fix/525-art-package-contract
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m5s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m5s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m52s
Project CI / Frontend tests (pull_request) Successful in 3m19s
Project CI / Backend tests (pull_request) Successful in 7m42s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m49s
Project CI / Repository checks (pull_request) Successful in 7m11s
Project CI / Native shell tests (pull_request) Successful in 8m43s

This commit is contained in:
2026-10-07 18:42:29 +08:00
13 changed files with 369 additions and 133 deletions
@@ -961,19 +961,24 @@ fn resolve_project_command_executable(
}
/// Linux 上 npm 以 `node <npm-cli.js> <args>` 启动;其余情况保持 `executable + args`。
pub(crate) fn project_command_actual_target(spec: &ProjectCommandSpec) -> (PathBuf, Vec<String>) {
pub(crate) fn project_command_actual_target(spec: &ProjectCommandSpec) -> (PathBuf, Vec<OsString>) {
#[cfg(target_os = "linux")]
{
if let Some((node, npm_cli)) = spec.node_launcher.as_ref() {
let mut arguments = Vec::with_capacity(spec.arguments.len() + 1);
arguments.push(npm_cli.to_string_lossy().into_owned());
arguments.extend(spec.arguments.iter().cloned());
// 保留原始 OsStr 字节:`npm_cli` 若含非 UTF-8,`to_string_lossy` 会变成 U+FFFD,
// 既改坏实际执行的脚本路径,也让沙箱的可信 npm_cli 比对失配。
arguments.push(npm_cli.as_os_str().to_owned());
arguments.extend(spec.arguments.iter().map(OsString::from));
return (node.clone(), arguments);
}
}
(
spec.executable.clone(),
project_command_actual_arguments(spec),
project_command_actual_arguments(spec)
.into_iter()
.map(OsString::from)
.collect(),
)
}
@@ -1674,6 +1679,9 @@ pub(crate) fn prepare_project_command_launch_spec(
&target_arguments,
&spec.cwd,
&environment,
spec.node_launcher
.as_ref()
.map(|(node, npm_cli)| (node.as_path(), npm_cli.as_path())),
)
.map_err(|error| {
ProjectCommandError::new(
@@ -2660,6 +2668,14 @@ mod tests {
fn npm_command_targets_node_plus_npm_cli_on_linux() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("game")).unwrap();
// 这条用例走真实 resolve_node_runtime:没有可用宿主 Node 的最小容器里跳过,
// 保持 cargo test 可跑;跳过时打印可见提示(配 --nocapture / --show-output 看得到)。
if let Err(error) = crate::environment_check::resolve_node_runtime(root.path()) {
eprintln!(
"skipping npm_command_targets_node_plus_npm_cli_on_linux: no host node runtime({error})"
);
return;
}
let spec = resolve_project_command_spec_at(
root.path(),
"npm",
@@ -2674,13 +2690,31 @@ mod tests {
Some("node"),
"{executable:?}"
);
assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}");
assert_eq!(&arguments[1..], ["run".to_string(), "build".to_string()]);
assert!(
arguments[0].to_string_lossy().ends_with("npm-cli.js"),
"{arguments:?}"
);
assert_eq!(
&arguments[1..],
[OsString::from("run"), OsString::from("build")]
);
// 沙箱联网判定要求目标与解析层给出的可信 (node, npm_cli) 精确一致。
let (trusted_node, trusted_npm_cli) = spec
.node_launcher
.as_ref()
.expect("Linux npm spec must carry the trusted node launcher");
assert_eq!(trusted_node, &executable);
// 逐字节相等:argv[0] 不再经 to_string_lossy 往返。
assert_eq!(trusted_npm_cli.as_os_str(), arguments[0].as_os_str());
let bootstrap = resolve_project_bootstrap_spec_at(root.path(), 30).unwrap();
let (_, arguments) = project_command_actual_target(&bootstrap);
assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}");
assert_eq!(arguments[1], "install");
assert!(
arguments[0].to_string_lossy().ends_with("npm-cli.js"),
"{arguments:?}"
);
assert_eq!(arguments[1], OsString::from("install"));
}
#[cfg(target_os = "linux")]
@@ -110,15 +110,26 @@ pub(crate) fn command_sandbox_platform_metadata() -> CommandSandboxMetadata {
/// Builds a fail-closed launcher for a direct executable plus structured argv.
/// It never falls back to launching the original command on the host.
///
/// `trusted_npm_install` 是解析层给出的可信 `(node, npm_cli)`;只有目标命令与它精确一致
/// 且子命令是 `install` 时才放网,避免同名项目文件冒充 npm。
#[cfg(target_os = "linux")]
pub(crate) fn prepare_command_sandbox_launch(
root: &Path,
executable: &Path,
arguments: &[String],
arguments: &[OsString],
cwd: &Path,
environment: &[(OsString, OsString)],
trusted_npm_install: Option<(&Path, &Path)>,
) -> Result<CommandSandboxLaunch, CommandSandboxError> {
prepare_linux_command_sandbox_launch(root, executable, arguments, cwd, environment)
prepare_linux_command_sandbox_launch(
root,
executable,
arguments,
cwd,
environment,
trusted_npm_install,
)
}
#[cfg(target_os = "linux")]
@@ -201,7 +212,8 @@ mod linux {
root: PathBuf,
cwd: PathBuf,
executable: PathBuf,
arguments: Vec<String>,
arguments: Vec<OsString>,
npm_install_network: bool,
target_environment: Vec<(OsString, OsString)>,
merged_usr_links: Vec<(OsString, PathBuf)>,
protected_read_only: Vec<PathBuf>,
@@ -290,12 +302,15 @@ mod linux {
pub(super) fn prepare_linux_command_sandbox_launch(
root: &Path,
executable: &Path,
arguments: &[String],
arguments: &[OsString],
cwd: &Path,
environment: &[(OsString, OsString)],
trusted_npm_install: Option<(&Path, &Path)>,
) -> Result<CommandSandboxLaunch, CommandSandboxError> {
let mut metadata = CommandSandboxMetadata::enforced_linux();
if command_sandbox_requests_npm_install(executable, arguments) {
let npm_install_network =
command_sandbox_requests_npm_install(executable, arguments, trusted_npm_install);
if npm_install_network {
metadata.network = "enabled";
}
let bwrap = find_trusted_bwrap().map_err(|error| {
@@ -372,6 +387,7 @@ mod linux {
cwd,
executable,
arguments: arguments.to_vec(),
npm_install_network,
target_environment,
merged_usr_links,
protected_read_only,
@@ -546,29 +562,24 @@ mod linux {
.collect())
}
/// `npm install` 是唯一允许联网的受控入口。Linux 以 `node <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
/// `npm install` 是唯一允许联网的受控入口。Linux 上 npm 以 `node <npm-cli.js> install`
/// 启动,只看 executable 或第一个参数的 basename 会被项目里同名的文件冒充,因此必须与解析层
/// 给出的可信 `(node, npm_cli)` 精确比对;拿不到可信对时一律不放网。
fn command_sandbox_requests_npm_install(
executable: &Path,
arguments: &[OsString],
trusted_npm_install: Option<(&Path, &Path)>,
) -> bool {
let Some((trusted_node, trusted_npm_cli)) = trusted_npm_install else {
return false;
};
executable == trusted_node
&& arguments
.first()
.is_some_and(|argument| argument == "install");
}
arguments
.first()
.map(Path::new)
.and_then(Path::file_name)
.and_then(OsStr::to_str)
.is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js"))
.is_some_and(|argument| Path::new(argument) == trusted_npm_cli)
&& arguments
.get(1)
.is_some_and(|argument| argument == "install")
.is_some_and(|argument| argument.as_os_str() == OsStr::new("install"))
}
/// fnm / nvm / 系统 / 随包 Node 的安装前缀必须整棵只读挂进沙箱:只挂单个 `node` 或 `npm`
@@ -713,10 +724,7 @@ mod linux {
fn build_linux_bwrap_launch(plan: LinuxSandboxPlan) -> CommandSandboxLaunch {
let mut args = Vec::<OsString>::new();
push_namespace_arguments(
&mut args,
command_sandbox_requests_npm_install(&plan.executable, &plan.arguments),
);
push_namespace_arguments(&mut args, plan.npm_install_network);
push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr"));
for (target, destination) in &plan.merged_usr_links {
push_option(
@@ -776,7 +784,7 @@ mod linux {
push_option(&mut args, "--chdir", [plan.cwd.as_os_str()]);
args.push(OsString::from("--"));
args.push(plan.executable.as_os_str().to_owned());
args.extend(plan.arguments.iter().map(OsString::from));
args.extend(plan.arguments.iter().cloned());
CommandSandboxLaunch {
executable: plan.bwrap,
@@ -1056,6 +1064,16 @@ mod linux {
})
}
#[test]
fn namespace_arguments_share_net_only_when_trusted() {
let mut shared = Vec::new();
push_namespace_arguments(&mut shared, true);
assert!(shared.contains(&OsString::from("--share-net")));
let mut isolated = Vec::new();
push_namespace_arguments(&mut isolated, false);
assert!(!isolated.contains(&OsString::from("--share-net")));
}
#[test]
fn pure_builder_constructs_empty_workspace_namespace_and_private_environment() {
let launch = build_linux_bwrap_launch(LinuxSandboxPlan {
@@ -1063,7 +1081,8 @@ mod linux {
root: PathBuf::from("/workspace/project"),
cwd: PathBuf::from("/workspace/project/game"),
executable: PathBuf::from("/opt/toolchain/bin/tool"),
arguments: vec!["check".to_string(), "--flag".to_string()],
arguments: vec![OsString::from("check"), OsString::from("--flag")],
npm_install_network: false,
target_environment: vec![
(
OsString::from("HOME"),
@@ -1185,9 +1204,10 @@ mod linux {
let error = prepare_command_sandbox_launch(
&root,
Path::new("/usr/bin/python3"),
&["-c".to_string(), script],
&[OsString::from("-c"), OsString::from(script)],
&root,
&[(OsString::from("PATH"), OsString::from("/usr/bin"))],
None,
)
.expect_err("invalid protected path must fail closed");
assert!(error.to_string().contains(".codex"));
@@ -1332,26 +1352,64 @@ mod linux {
}
#[test]
fn npm_install_network_detection_supports_node_launcher() {
fn npm_install_network_detection_requires_the_trusted_node_launcher() {
let node = Path::new("/opt/node/bin/node");
let npm_cli = Path::new("/opt/node/lib/node_modules/npm/bin/npm-cli.js");
let trusted = Some((node, npm_cli));
let install_arguments = vec![npm_cli.as_os_str().to_owned(), OsString::from("install")];
assert!(command_sandbox_requests_npm_install(
Path::new("/opt/node/bin/node"),
&[
"/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(),
"install".to_string(),
],
node,
&install_arguments,
trusted,
));
// 项目里同名的 npm-cli.js 不能再冒充。
assert!(!command_sandbox_requests_npm_install(
Path::new("/opt/node/bin/node"),
node,
&[
"/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(),
"run".to_string(),
"build".to_string(),
OsString::from("/workspace/game/npm-cli.js"),
OsString::from("install"),
],
trusted,
));
assert!(command_sandbox_requests_npm_install(
// executable 与可信 node 不一致时不放网。
assert!(!command_sandbox_requests_npm_install(
Path::new("/opt/node/bin/other"),
&install_arguments,
trusted,
));
// 非 install 子命令不放网。
assert!(!command_sandbox_requests_npm_install(
node,
&[
npm_cli.as_os_str().to_owned(),
OsString::from("run"),
OsString::from("build"),
],
trusted,
));
// 没有可信 launcher(例如直接 npm shim)时不放网。
assert!(!command_sandbox_requests_npm_install(
Path::new("/usr/bin/npm"),
&["install".to_string()],
&[OsString::from("install")],
None,
));
// 非 UTF-8 的 npm_cli:argv 保留原始字节时必须逐字节命中
// (旧实现经 to_string_lossy 会变成 U+FFFD,从而误拒合法的 npm install)。
{
use std::os::unix::ffi::OsStringExt;
let raw_npm_cli = PathBuf::from(OsString::from_vec(
b"/opt/node/lib/node_modules/npm/bin/n\xffpm-cli.js".to_vec(),
));
let raw_arguments = vec![
raw_npm_cli.as_os_str().to_owned(),
OsString::from("install"),
];
assert!(command_sandbox_requests_npm_install(
node,
&raw_arguments,
Some((node, raw_npm_cli.as_path())),
));
}
}
struct TempTree(PathBuf);
@@ -1448,9 +1506,10 @@ print("SANDBOX_OK")
let launch = prepare_command_sandbox_launch(
&root,
Path::new("/usr/bin/python3"),
&["-c".to_string(), script],
&[OsString::from("-c"), OsString::from(script)],
&root,
&environment,
None,
)
.expect("prepare real Linux sandbox");
@@ -1502,13 +1561,14 @@ print("SANDBOX_OK")
(OsString::from("PATH"), runtime.safe_path.clone()),
(OsString::from("HOME"), OsString::from("/host/home")),
];
let run = |executable: &Path, arguments: &[String]| {
let run = |executable: &Path, arguments: &[OsString]| {
let launch = prepare_command_sandbox_launch(
&root,
executable,
arguments,
&root,
&environment,
None,
)
.expect("prepare node sandbox");
let output = Command::new(&launch.executable)
@@ -1529,8 +1589,8 @@ print("SANDBOX_OK")
let version = run(
&runtime.node,
&[
"-e".to_string(),
"process.stdout.write(process.version)".to_string(),
OsString::from("-e"),
OsString::from("process.stdout.write(process.version)"),
],
);
assert!(
@@ -1538,8 +1598,13 @@ print("SANDBOX_OK")
"unexpected node version: {version}"
);
let npm_cli = runtime.npm_cli.to_string_lossy().into_owned();
let npm_version = run(&runtime.node, &[npm_cli, "--version".to_string()]);
let npm_version = run(
&runtime.node,
&[
runtime.npm_cli.as_os_str().to_owned(),
OsString::from("--version"),
],
);
assert!(!npm_version.is_empty(), "npm --version returned nothing");
}
@@ -1577,10 +1642,16 @@ print("SANDBOX_OK")
(OsString::from("PATH"), prefix.join("bin").into_os_string()),
(OsString::from("HOME"), OsString::from("/host/home")),
];
let run = |arguments: &[String]| {
let launch =
prepare_command_sandbox_launch(&root, &node, arguments, &root, &environment)
.expect("prepare nvm sandbox");
let run = |arguments: &[OsString]| {
let launch = prepare_command_sandbox_launch(
&root,
&node,
arguments,
&root,
&environment,
None,
)
.expect("prepare nvm sandbox");
let output = Command::new(&launch.executable)
.args(&launch.arguments)
.current_dir(&launch.cwd)
@@ -1597,17 +1668,14 @@ print("SANDBOX_OK")
};
let version = run(&[
"-e".to_string(),
"process.stdout.write(process.version)".to_string(),
OsString::from("-e"),
OsString::from("process.stdout.write(process.version)"),
]);
assert!(
version.starts_with('v'),
"unexpected node version: {version}"
);
let npm_version = run(&[
npm_cli.to_string_lossy().into_owned(),
"--version".to_string(),
]);
let npm_version = run(&[npm_cli.as_os_str().to_owned(), OsString::from("--version")]);
assert!(!npm_version.is_empty(), "npm --version returned nothing");
}
@@ -1627,7 +1695,11 @@ print("SANDBOX_OK")
"from pathlib import Path; import os; assert os.readlink('/proc/self/fd/0') == '/dev/null'; assert all(not Path(f'/proc/self/fd/{{fd}}').exists() for fd in (3, 4, 5, 6)); Path({:?}).write_text('COMMITTED')",
marker.to_string_lossy()
);
let arguments = vec!["-c".to_string(), script, "--".to_string()];
let arguments = vec![
OsString::from("-c"),
OsString::from(script),
OsString::from("--"),
];
let environment = vec![(OsString::from("PATH"), OsString::from("/usr/bin"))];
let launch = prepare_linux_command_sandbox_launch(
&root,
@@ -1635,9 +1707,10 @@ print("SANDBOX_OK")
&arguments,
&root,
&environment,
None,
)
.expect("prepare real Linux sandbox");
let target_arguments = arguments.iter().map(OsString::from).collect::<Vec<_>>();
let target_arguments = arguments.clone();
let gate =
LaunchGate::new_for_sandbox_stdin(Path::new("/usr/bin/python3"), &target_arguments)
.expect("create real Linux sandbox gate");
@@ -633,11 +633,15 @@ fn version_matches_pin(version: (u64, u64, u64), pin: &str) -> Option<bool> {
// `.nvmrc` 两种写法都常见:`v22.23.3` 与 `22.23.3`。
let pin = pin.strip_prefix('v').unwrap_or(pin);
let lower = pin.to_ascii_lowercase();
if matches!(lower.as_str(), "*" | "x" | "node" | "latest" | "lts/*")
|| lower.starts_with("lts/")
{
if matches!(lower.as_str(), "*" | "x" | "node" | "latest") {
return Some(true);
}
if lower.starts_with("lts/") {
// `lts/*`(最新 LTS)与 `lts/<codename>`(如 `lts/iron` = Node 20)都需要 LTS 行映射,
// 当前不维护;按“未 pin”回退到宿主已激活/默认选择,绝不能声称匹配任意版本而静默选中
// 已安装的最高版本——最高版本通常不是 LTS。
return None;
}
if lower.starts_with("iojs") || lower.contains("||") {
return None;
}
@@ -656,8 +660,11 @@ fn version_matches_pin(version: (u64, u64, u64), pin: &str) -> Option<bool> {
}
enum PinSelection<'a> {
/// pin 被理解且在已安装版本里命中,取其中最高匹配。
Matched(&'a InstalledNodeVersion),
/// pin 被理解但已安装版本里没有命中;调用方按「未 pin」继续回退,不阻塞。
NoMatch,
/// pin 超出当前实现的比较子集;同样按「未 pin」继续回退,不阻塞。
Unsupported,
}
@@ -725,22 +732,6 @@ fn read_project_engines_range(root: &Path) -> Option<String> {
}
}
fn active_managed_prefix() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("FNM_MULTISHELL_PATH") {
if let Ok(prefix) = validate_node_installation_prefix(Path::new(&path)) {
return Some(prefix);
}
}
if let Some(bin) = std::env::var_os("NVM_BIN") {
if let Some(parent) = Path::new(&bin).parent() {
if let Ok(prefix) = validate_node_installation_prefix(parent) {
return Some(prefix);
}
}
}
None
}
fn default_managed_installation<'a>(
installed: &'a [InstalledNodeVersion],
fnm_roots: &[PathBuf],
@@ -758,8 +749,9 @@ fn default_managed_installation<'a>(
let Ok(content) = fs::read_to_string(root.join("alias").join("default")) else {
continue;
};
// nvm 的 default 别名常写成 `22`、`v22.23.3` 或 `lts/*`,不是完整三元组;按 pin 的
// 同一子集在已安装版本里选最高匹配,避免 `22` 被当成 (22,0,0) 而永远匹配不到。
// nvm 的 default 别名常写成 `22` 或 `v22.23.3`,不是完整三元组;按 pin 的同一子集在
// 已安装版本里选最高匹配,避免 `22` 被当成 (22,0,0) 而永远匹配不到。`lts/*` 之类需要
// LTS 行映射的别名会落空,由调用方回退到已安装最高版本。
if let PinSelection::Matched(node) = select_pinned_installation(installed, content.trim()) {
return Some(node);
}
@@ -780,10 +772,11 @@ fn resolve_pinned_managed_runtime(
let Some(pin) = read_project_version_file_pin(root) else {
return Ok(None);
};
// `.nvmrc` / `.node-version`(nvm / fnm 都读)只改变搜索顺序,不是硬性要求:
// 命中已安装版本就优先用它,否则继续走 PATH 和回退链,绝不因为“没装”而阻塞。
match select_pinned_installation(&installed, &pin) {
PinSelection::Matched(node) => Ok(Some(runtime_from_installed(node, root, path)?)),
PinSelection::NoMatch => Err("node-version-pinned-not-installed".into()),
PinSelection::Unsupported => Ok(None),
PinSelection::NoMatch | PinSelection::Unsupported => Ok(None),
}
}
@@ -803,11 +796,6 @@ fn resolve_managed_fallback_runtime(
return Ok(Some(runtime_from_installed(node, root, path)?));
}
}
if let Some(prefix) = active_managed_prefix() {
if let Some(node) = installed.iter().find(|node| node.prefix == prefix) {
return Ok(Some(runtime_from_installed(node, root, path)?));
}
}
if let Some(node) = default_managed_installation(&installed, fnm_roots, nvm_roots) {
return Ok(Some(runtime_from_installed(node, root, path)?));
}
@@ -1417,12 +1405,36 @@ mod tests {
assert_eq!(default.version, (22, 23, 3));
}
#[cfg(unix)]
#[test]
fn version_file_pin_is_authoritative_and_blocks_when_not_installed() {
fn fnm_default_alias_symlink_selects_the_linked_installation() {
use std::os::unix::fs::symlink;
let fnm_root = tempfile::tempdir().unwrap();
let older = install_fnm_version(fnm_root.path(), "v20.11.0");
install_fnm_version(fnm_root.path(), "v22.23.3");
let alias_dir = fnm_root.path().join("aliases");
fs::create_dir_all(&alias_dir).unwrap();
// fnm 1.39 的 default 别名是指向安装目录的符号链接,不是版本字符串文件。
symlink(&older, alias_dir.join("default")).unwrap();
let installed = installed_node_versions(&[fnm_root.path().to_path_buf()], &[]);
assert_eq!(installed.len(), 2);
let default =
default_managed_installation(&installed, &[fnm_root.path().to_path_buf()], &[])
.expect("fnm default alias should resolve");
// 必须跟随别名选中 v20,而不是回退到最高的 v22。
assert_eq!(default.prefix, older.canonicalize().unwrap());
assert_eq!(default.version, (20, 11, 0));
}
#[test]
fn version_file_pin_prefers_the_installed_version_and_falls_back_when_missing() {
let fnm_root = tempfile::tempdir().unwrap();
install_fnm_version(fnm_root.path(), "v20.11.0");
install_fnm_version(fnm_root.path(), "v22.23.3");
let project = tempfile::tempdir().unwrap();
// 命中已安装版本:.nvmrc 改变搜索顺序,优先选它而不是最高版本。
fs::write(project.path().join(".nvmrc"), "20\n").unwrap();
let runtime = resolve_at_with_managed_roots(
project.path(),
@@ -1436,19 +1448,48 @@ mod tests {
assert_eq!(runtime.source, "fnm");
assert!(runtime.node.to_string_lossy().contains("v20.11.0"));
fs::write(project.path().join(".nvmrc"), "v18.0.0\n").unwrap();
assert_eq!(
resolve_at_with_managed_roots(
project.path(),
None,
true,
OsStr::new(""),
&[fnm_root.path().to_path_buf()],
&[],
)
.unwrap_err(),
"node-version-pinned-not-installed"
);
// fnm 的 .node-version 同样参与搜索顺序。
fs::remove_file(project.path().join(".nvmrc")).unwrap();
fs::write(project.path().join(".node-version"), "20\n").unwrap();
let runtime = resolve_at_with_managed_roots(
project.path(),
None,
true,
OsStr::new(""),
&[fnm_root.path().to_path_buf()],
&[],
)
.unwrap();
assert!(runtime.node.to_string_lossy().contains("v20.11.0"));
// 未安装时不再失败关闭:回退到已安装最高版本。
fs::write(project.path().join(".node-version"), "v18.0.0\n").unwrap();
let runtime = resolve_at_with_managed_roots(
project.path(),
None,
true,
OsStr::new(""),
&[fnm_root.path().to_path_buf()],
&[],
)
.unwrap();
assert_eq!(runtime.source, "fnm");
assert!(runtime.node.to_string_lossy().contains("v22.23.3"));
// nvm 的 .nvmrc 未安装时走同一条宽松回退。
let nvm_root = tempfile::tempdir().unwrap();
install_nvm_version(nvm_root.path(), "v20.11.0");
let runtime = resolve_at_with_managed_roots(
project.path(),
None,
true,
OsStr::new(""),
&[],
&[nvm_root.path().to_path_buf()],
)
.unwrap();
assert_eq!(runtime.source, "nvm");
assert!(runtime.node.to_string_lossy().contains("v20.11.0"));
}
#[test]
@@ -1495,7 +1536,7 @@ mod tests {
let fnm_root = tempfile::tempdir().unwrap();
install_fnm_version(fnm_root.path(), "v22.23.3");
let project = tempfile::tempdir().unwrap();
for pin in ["iojs\n", ">20\n", "<=20\n"] {
for pin in ["iojs\n", ">20\n", "<=20\n", "lts/iron\n", "lts/*\n"] {
fs::write(project.path().join(".node-version"), pin).unwrap();
let runtime = resolve_at_with_managed_roots(
project.path(),
@@ -1519,8 +1560,11 @@ mod tests {
assert_eq!(version_matches_pin((20, 11, 0), "^20.0.0"), Some(true));
assert_eq!(version_matches_pin((20, 11, 0), "~20.11.0"), Some(true));
assert_eq!(version_matches_pin((20, 11, 0), ">=20 <23"), Some(true));
assert_eq!(version_matches_pin((20, 11, 0), "lts/*"), Some(true));
assert_eq!(version_matches_pin((20, 11, 0), "lts/iron"), Some(true));
// `lts/*` 与 codename 都需要 LTS 行映射,未维护时必须按未 pin 回退,不能匹配任意版本。
assert_eq!(version_matches_pin((20, 11, 0), "lts/*"), None);
assert_eq!(version_matches_pin((22, 23, 3), "lts/*"), None);
assert_eq!(version_matches_pin((20, 11, 0), "lts/iron"), None);
assert_eq!(version_matches_pin((22, 23, 3), "lts/iron"), None);
assert_eq!(version_matches_pin((20, 11, 0), ">=22 || 20"), None);
assert_eq!(version_matches_pin((20, 11, 0), ">20"), None);
assert_eq!(version_matches_pin((20, 11, 0), "<=20"), None);
@@ -73,7 +73,7 @@ mod linux {
target_executable: target_executable.as_os_str().as_bytes().to_vec(),
target_arguments: target_arguments
.into_iter()
.map(|argument| OsString::from(argument).into_vec())
.map(|argument| argument.into_vec())
.collect(),
})
}
+2
View File
@@ -186,6 +186,8 @@ timer 在上次执行结束后约 5 分钟再次检查,文件锁防止人工
入口遇到“已发送 FetchTask,但上游响应未完整结束”会持久化 `uncertain` 并拒绝继续领取/自动切换;不会因为客户端的 5 秒超时就认定服务端事务已回滚。其它 RPC 和任务上报仍继续转发。维护者需先核实 Gitea 在途领取事务与该 runner 的任务全部收敛,在维护窗口停止入口,核实并修复它的 `tasks.json` 任务账本及 `uncertain` / `inflight` 标记后再启动并恢复领取。网关按 Gitea 1.26.4 / Runner 2.0.0 的 Connect Protobuf 协议,在最终日志确认及 Runner 执行结束后的最终任务上报确认后才清账;取消响应不等于进程停止,任务 ID 不按超时自动删除。禁止自动删这些标记绕过屏障。停用自动维护先 `systemctl disable --now genarrative-ci-cache.timer`;不要为了停 timer 停止运行中的 CI 容器。已接入的领取入口继续运行,不影响普通 CI。
Gitea 1.26.4 的 `FetchTask` 若完整返回 HTTP 500、未压缩的 Connect JSON `code=unknown`,且 message 精确为 `rpc error: code = Internal desc = pick task: CreateTaskForRunner: update run <正整数>: run has changed`,网关原样返回错误并允许 runner 下次轮询,不写入 `uncertain`。已核对上游 [CreateTaskForRunner](https://github.com/go-gitea/gitea/blob/v1.26.4/models/actions/task.go) 和 [PickTask](https://github.com/go-gitea/gitea/blob/v1.26.4/services/actions/task.go):此并发更新错误发生在分配事务提交前并回滚。该例外不覆盖其它 5xx、未知格式、响应截断或网络断连,也不会清除既有保护锁、维护暂停和活跃任务账本。网关将已回滚冲突和进入保护状态的原因分类写入容器日志,不输出 RPC 正文或凭据;旧版本留下的保护锁仍须核实后人工恢复。
人工 bootstrap 预热容器上限为 4 核、12 GiB,移除 capabilities,不挂宿主目录/socket,也不注入 Git/OSS/Jenkins 凭据。源码通过 `git archive` 复制,当前工作区、ignored 文件和 `.git` 不进入容器。最终从原镜像重新组装,仅复制 `/opt/genarrative-ci/rust-cache` 的 sccache、对象和来源元数据,不提交含源码/target 的预热容器;镜像本身的下载缓存与工具链校验保持原样。
快照由固定 Image ID 分发,每个 job 仅修改容器自己的写时复制层,缓存上限 4 GiB,只有 master push 在结束前回传新增对象;PR 不扫描基线、不打包、不回传。`ci-rust-cache.sh prepare` 清空继承的 `SCCACHE_*` 远程配置,使用独立配置和 Unix socket;旧镜像、工具链不匹配或限时 wrapper 探测失败时使用直接 rustc,正式编译启用 sccache 的 server IO 错误回退。真实编译/测试失败保留非零退出码。`report` 输出命中统计并停止本 job daemon,分片日志输出独立编译耗时。sccache 0.18.0 的只读模式在 miss 后仍打包再拒绝写入,不能用它宣称零 miss 开销;普通 CI 继续只向容器层写入。
@@ -9,13 +9,13 @@
## 目标
开发构建的 Linux 命令沙箱能原生解析并复用宿主 fnm / nvm 托管的 Node 安装:宿主发现与沙箱只读挂载使用同一套窄叶校验;`.nvmrc` / `.node-version` pin 权威、`engines.node` 仅为偏好;npm 以受信任 `node <npm-cli.js>` 形态启动且不丢失 `npm install` 的联网判定。不再要求用户改系统 Node、把宿主 shim 指向 `/usr/bin`,或把托管目录软链进系统路径。
开发构建的 Linux 命令沙箱能原生解析并复用宿主 fnm / nvm 托管的 Node 安装:宿主发现与沙箱只读挂载使用同一套窄叶校验;`.nvmrc` / `.node-version`(nvm / fnm 都读)命中已安装版本时优先、未安装时继续回退,`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` 最高匹配 > 活动版本 > 默认别名 > 已安装最高版本)。
- 版本选择:`.nvmrc` / `.node-version` pin > PATH 可解析的可用 Node > 版本管理器回退链(`engines.node` 最高匹配 > 默认别名 > 已安装最高版本)。活动版本不单独查询:激活时它已在 `PATH` 里,回退链不再重复一份可能失效的副本(原 `FNM_MULTISHELL_PATH` / `NVM_BIN` 分支已删除)。
- Linux 命令沙箱把通过校验的完整安装前缀只读挂载,并以 `node <npm-cli.js>` 启动 npm;`npm install` 保持联网判定。
- 沙箱内联环境不继承 fnm multishell 等临时版本管理器变量。
@@ -35,7 +35,7 @@
## 验收标准
- [x] 开发构建在没有 `/usr/bin` shim 的情况下从 fnm / nvm 找到 Node,并在沙箱内运行出真实 `node --version` 与 `npm --version`。
- [x] `.nvmrc` / `.node-version` 指定的版本未安装时命令失败关闭,且不回退到其它已安装版本;`engines.node` 不匹配时不阻塞。
- [x] `.nvmrc` / `.node-version`(nvm / fnm 都读)指定的版本未安装时命令不阻塞,继续回退到 PATH / `engines` 偏好 / 默认别名 / 已安装最高版本;`engines.node` 不匹配时同样不阻塞。
- [x] 不支持的 pin 写法(如 `iojs`、`>20`、`<=20`)按未 pin 处理并回退,而不是误判为「已理解但未命中」。
- [x] 只有通过窄叶校验的完整安装前缀会被只读挂载;HOME、管理器根、`aliases`、宽泛目录、不完整前缀和逃逸 symlink 全部失败关闭。
- [x] npm 在 Linux 上以受信任 `node <npm-cli.js> ...` 启动,`npm install` 仍被判定为联网命令,普通 `npm run` 仍离线。
@@ -4,11 +4,11 @@
- 背景:开发构建里 AGC 让命令沙箱执行 `npm run build` / `npm install` 时,宿主 Node 由 fnm 托管,`node` / `npm` 实际是随 shell 会话变化的 fnm multishell 目录里的 shim;bwrap `--tmpfs /run` 会抹掉该路径,而只按单文件挂载 `<前缀>/bin/npm`(它软链到 `lib/node_modules/npm/bin/npm-cli.js`)会因 `Cannot find module '../lib/cli.js'` 失败。此前把宿主 `node` / `npm` / `npx` shim 指到 `/usr/bin/*` 是错误取舍:系统 Node 26 默认启用实验性 Web Storage,会顶掉 vitest 0.34 jsdom 的 localStorage,使 AGC 测试套件在 HEAD 即失败(见 `pitfalls.md` 2026-10-03 条)。
- 决策(宿主发现与沙箱挂载共用窄叶校验):新增 `validate_node_installation_prefix`,canonicalize 后拒绝 `/home`、`/root`、`/tmp`、`/var`、`/etc`、`/proc`、`/dev`、`/run`、`/sys`、`/boot`、`/srv` 根、HOME 及其祖先和相对路径,并要求前缀同时含 `bin/node` 与 `lib/node_modules/npm/bin/npm-cli.js`(bundle 形态为 `<前缀>/node` + `node_modules/npm/bin/npm-cli.js`)。宿主版本枚举与 Linux 沙箱只读挂载都调用它,避免两处信任口径漂移。
- 决策(版本选择):`.nvmrc` / `.node-version` 是权威 pin,能理解但未安装时返回 `node-version-pinned-not-installed` 失败关闭;`package.json` `engines.node` 只是偏好,永不阻塞;不支持的写法(`iojs`、`||`、部分 `>` / `<=`、hyphen range、prerelease)按未 pin 回退。整体顺序为 pin 命中 > PATH 可解析的可用 Node > 版本管理器回退链(`engines` 最高匹配 > 活动版本 > 默认别名 > 已安装最高版本),只实现文档化比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*`、`lts/*`);`v22.23.3` 这类带 `v` 的 `.nvmrc` 必须先剥前缀。nvm 的 `alias/default` 常只写主版本号(如 `22`),必须按同一 pin 子集在已安装版本里选最高匹配,不能当成完整三元组 `(22,0,0)`。
- 决策(沙箱内启动形态):Linux npm 改为 `node <npm-cli.js>`,因为单文件挂载 npm 软链必然丢 `../lib/cli.js`;只读挂载整棵已验证的安装前缀(不是整个 HOME、`FNM_DIR` 或 `NVM_DIR`)。`npm install` 的联网判定跟随真实启动形态(`node` + `npm-cli.js` + `install`),不因包装变化丢 `--share-net`。
- 决策(版本选择):`.nvmrc` / `.node-version`(nvm / fnm 都读)只改变搜索顺序、不是硬性要求:命中已安装版本就优先用它,能理解但未安装时按未 pin 继续回退,不再返回 `node-version-pinned-not-installed`;`package.json` `engines.node` 同样是偏好,永不阻塞;不支持的写法(`iojs`、`||`、部分 `>` / `<=`、hyphen range、prerelease)按未 pin 回退。整体顺序为 pin 命中 > PATH 可解析的可用 Node > 版本管理器回退链(`engines` 最高匹配 > 默认别名 > 已安装最高版本);活动版本不单独查询(`FNM_MULTISHELL_PATH` / `NVM_BIN`),因为激活时它已在 `PATH` 里,回退链无需重复一份可能失效的副本;只实现文档化比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*`;`lts/*` 与 `lts/<codename>` 都需要 LTS 行映射,当前按未 pin 回退而不猜);`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)` 精确匹配(executable 是对应 node、首个参数是对应 `npm-cli.js`、子命令是 `install`),不再按 basename 判定:项目里同名的 `npm-cli.js` 可写,只加「路径必须绝对」也能绕过;拿不到可信对时一律不放网。
- 决策(范围与非目标):托管版本管理器发现只在 `debug_assertions` / development 生效,发布构建继续只认随包 bundle;不把 fnm / nvm CLI 做成沙箱内工具;Windows 不变;不新增 fnm / nvm 之外的版本管理器。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/environment_check.rs`(窄叶校验、托管安装枚举、pin / engines 解析、选择与回退)、`command_sandbox.rs`(Node 工具链挂载收集与合并、`command_sandbox_requests_npm_install`、`FNM_MULTISHELL_PATH` 清理)、`command_exec.rs`(Linux npm `node_launcher`、`project_command_actual_target`、非 Node 程序 PATH 前置工具链 bin)、`process_session_bridge.rs`(`ProcessSessionLaunchPlan::from_launch` 改用实际目标)。
- 验证方式:`environment_check` 24 passed、`command_sandbox` 14 passed、`command_exec` 19 passed、`process_session` 27 passed(均 `--test-threads=1`);`GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1` 真机 bwrap 内 fnm v22.23.3 的 `node --version` 与 npm 10.9.9 通过;另装 nvm v0.40.8 + Node v22.23.3,`command_sandbox_real_linux_opt_in_runs_nvm_installation_prefix` 证明真实 nvm 前缀可在 bwrap 内跑 node / npm,`real_node_npm_environment_versions`(仅 `NVM_DIR` + 空 PATH + 临时 HOME)证明托管解析确实选中 nvm;`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。验证后 fnm 仍是宿主默认,nvm 未写入任何 shell profile。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/environment_check.rs`(窄叶校验、托管安装枚举、pin / engines 解析、选择与回退)、`command_sandbox.rs`(Node 工具链挂载收集与合并、`prepare_command_sandbox_launch` 透传可信 `(node, npm_cli)`、`LinuxSandboxPlan::npm_install_network`、`FNM_MULTISHELL_PATH` 清理)、`command_exec.rs`(Linux npm `node_launcher`、`project_command_actual_target`、`prepare_project_command_launch_spec` 把可信 launcher 交给沙箱、非 Node 程序 PATH 前置工具链 bin)、`process_session_bridge.rs`(`ProcessSessionLaunchPlan::from_launch` 改用实际目标)。
- 验证方式:`environment_check` 25 passed、`command_sandbox` 15 passed、`command_exec` 19 passed、`process_session` 27 passed(均 `--test-threads=1`);`npm_install_network_detection_requires_the_trusted_node_launcher` 覆盖「项目同名 `npm-cli.js` 不放网、无可信 launcher 不放网、executable 不匹配不放网」,`namespace_arguments_share_net_only_when_trusted` 覆盖 `--share-net` 开关,`npm_command_targets_node_plus_npm_cli_on_linux` 断言解析层可信 `(node, npm_cli)` 与实际目标逐字一致;`GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1` 真机 bwrap 内 fnm v22.23.3 的 `node --version` 与 npm 10.9.9 通过;另装 nvm v0.40.8 + Node v22.23.3,`command_sandbox_real_linux_opt_in_runs_nvm_installation_prefix` 证明真实 nvm 前缀可在 bwrap 内跑 node / npm,`real_node_npm_environment_versions`(仅 `NVM_DIR` + 空 PATH + 临时 HOME)证明托管解析确实选中 nvm;`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。验证后 fnm 仍是宿主默认,nvm 未写入任何 shell profile。
- 边界:nvm 已在验证主机安装(v0.40.8 + Node v22.23.3)并跑通真实前缀与仅 nvm 解析;CI 仍由临时目录夹具覆盖 fnm / nvm 布局,真实安装路径测试保持 opt-in。真实验收前不宣称发布构建也支持 fnm / nvm。
## 2026-10-06 小红书导出 validate/pack:Chrome 61 能力按硬性 ERROR 拦下,pack 自带白名单不再共享
@@ -112,6 +112,8 @@ Gitea 基础镜像通过专用 `genarrative-ci-images` Buildx builder 持久复
Gitea Rust 缓存自动维护由宿主 `genarrative-ci-cache.timer` 收集同一 master push run 六个 Rust job 的原生 V4 缓存产物,不重复执行 Cargo 预热。只传本轮新 key,命中对象只传使用时间;宿主与真实来源镜像对象合并、去重、按新近使用时间裁剪到 4 GiB,从无对象缓存基础镜像重新组装。源 run 不要求全绿,但取消、缺组、旧 attempt、未完成上传或混用来源镜像不得采用。网关暂停新 FetchTask、在途领取结束、持久化任务账本清空且内层活动容器为空才切换,不打断运行中的 CI。首次接入/升级网关需空闲窗口;Token 只需普通仓库 `write:repository`,不查管理员 API。候选装载后清理已收集 artifact,遗留项保留 7 天;真实 master CI 验证后才清理旧镜像,保留当前、一个回滚版、基础镜像及容器引用。部署入口见 `deploy/container/README.md`,合并代码不等于服务启用。
领取网关只对已核对 Gitea 1.26.4 事务回滚的精确 `CreateTaskForRunner: update run <id>: run has changed` Connect 错误允许 runner 重试,避免普通并发更新冲突永久阻断 CI;完整响应格式约束见 `deploy/container/README.md`。其它未知领取结果继续持久化保护,不自动清除 `uncertain`;恢复既有锁前需核实任务与在途请求收敛。容器日志只记录固定原因分类,不打印请求、响应或认证信息。
修改 Gitea workflow 的 job 显示名称、ID 或缓存导出组时,必须同步维护器的 `JOBS` / `RUST_JOB_IDS`;`test_gitea_cache_maintenance.py` 直接对照实际 workflow 检查全集和导出映射,避免自动刷新或镜像验收因名单漂移长期等待。维护器 `Api.request` 的 `method` 是必填关键字参数,GET 也必须显式指定,不根据 body 推断请求方法。
Gitea 缓存部署必须区分网络:runner 的 RPC 走 `gitea-runner-fetch-gate:8080`;内层 job 的 checkout/上传走映射到 `172.30.0.3` 的 `http://genarrative-station/git`;宿主专用 clone 走 `http://127.0.0.1:3003`。不要把 runner 可达的 `gitea:3000` 配给 job。内层 Docker 使用 `10.240.0.0/16`、每 job `/24` 的默认地址池,避开外层 `172.30/172.31` 网段;恢复领取前必须在真实 job 网络里验证 checkout 与 Gitea API,不能只验证 FetchTask。具体配置与遗留空网络处理见 `deploy/container/README.md`。
@@ -25,8 +25,9 @@
- **根因 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 / 不支持写法 / 宽叶与逃逸前缀 / 联网判定。
- **根因 4(联网放行只看 basename 会被同名文件冒充)**:`npm install` 是唯一允许联网的入口,最初只按「首个参数 basename 是 `npm-cli.js` 且第二个参数是 `install`」放行。Linux 上 `command.exec` 不对 `node` 的脚本参数做路径校验,项目根又可写,于是在项目里放一个自写的 `npm-cli.js` 再 `node 项目/npm-cli.js install` 就能拿到 `--share-net`。修法是让联网放行与解析层给出的可信 `(node, npm_cli)` 精确比对,拿不到可信对时一律不放网;只加「路径必须绝对」不够,绝对路径的项目内文件照样绕过。
- **现行口径**:宿主发现与沙箱挂载共用 `validate_node_installation_prefix`;`.nvmrc` / `.node-version`(nvm / fnm 都读)命中已安装版本时优先、未安装时继续回退,`engines.node` 同为偏好;Linux npm 以 `node <npm-cli.js>` 启动,`npm install` 联网放行只认解析层给出的可信 `(node, npm_cli)` 精确匹配。契约见技术方案 V1.11.2。
- **验证**:`GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1` 跑 `command_sandbox_real_linux_opt_in_runs_host_node_and_npm_cli`,在真实 fnm v22 前缀下 bwrap 内 `node --version` 与 `node <npm-cli.js> --version` 均通过;单元用例覆盖 pin / engines / 不支持写法 / 宽叶与逃逸前缀 / 可信 launcher 联网放行。
## 2026-10-07 cargo 目标目录里被"刷新 mtime"的陈旧 shared-contracts 会让编译报源文件里明明存在的字段缺失
@@ -621,9 +621,9 @@ Runner-kill E2E 不再以 latest task 或单个 process record 推断整体恢
开发构建(`debug_assertions`)下,AGC 生成和验证 Web 项目所用的 Node 往往由 fnm / nvm 等版本管理器托管:安装前缀位于用户目录下,活动 `PATH` 指向随 shell 会话变化的临时目录(如 fnm 的 multishell)。V1.11.2 让开发构建的命令沙箱原生解析并只读复用这些托管安装,不再要求用户改系统 Node、把宿主 shim 指到 `/usr/bin`,或把托管目录软链进系统路径。发布构建继续只认随包 bundle,不新增托管版本管理器探测,也不改变 bundle 缺失时的失败口径;Windows 行为不变。
- 版本来源是机器上可枚举的托管安装:fnm 的 `node-versions/<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」处理并回退。
- 解析优先级为:`.nvmrc` / `.node-version`(nvm / fnm 都读)命中已安装版本 > 宿主 `PATH` 能解析出的可用 Node > 版本管理器回退链(`package.json` `engines.node` 偏好中的最高匹配 > 默认别名 > 已安装最高版本)。不单独查询活动版本(`FNM_MULTISHELL_PATH` / `NVM_BIN`):激活时它已经在 `PATH` 里,回退链重复一份可能失效的副本只会带来误导。`.nvmrc` / `.node-version` 只改变搜索顺序,不是硬性要求:能理解但未安装时按「未 pin」继续回退,不返回 `node-version-pinned-not-installed`、不阻塞命令;`engines.node` 同样只是偏好,任何情况下都不阻塞。只实现文档化的比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*` 通配);`lts/*` 与 `lts/<codename>` 都需要 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` 的联网判定必须跟随这条真实启动形态,不能因为包装方式变化而丢失联网或反向放开。
- Linux 上 npm 不再直接执行 npm shim,而是以受信任的 `node <npm-cli.js> ...` 启动;`npm install` 的联网放行必须与解析层给出的可信 `(node, npm_cli)` 精确一致(executable 是对应的 node、首个参数是对应的 `npm-cli.js`、子命令是 `install`),不能只匹配 basename——项目根可写,项目里同名的 `npm-cli.js` 会骗到 `--share-net`;拿不到可信对时一律不放网。
- 沙箱内联环境不继承宿主活动版本管理器的临时变量(如 fnm multishell 路径);版本管理器 CLI(`fnm` / `nvm`)本身不需要在沙箱内可用。
## V1.12 受控本地 Git 提交
@@ -865,7 +865,7 @@ Agent 可见的系统指令、工具与参数说明、恢复指引和上下文
2026-07-14 V1.11.1 最终真实验收:发布 AppData 的真实 `gpt-5.5` `process-session` 形成 41 条 task、75 条 event、63 条 Agent DB 和 8 条 receipt,唯一 start、3 poll、唯一 stdin / terminate、3 次 cursor 推进及唯一 terminal / completed / assistant全部通过;Runner kill套件形成 13 条 task、19 条 event、19 条 Agent DB,真实 SIGKILL后项目 cwd进程清零、新 boot保持同 run / session并只形成 1 条 reconciliation。两套的 reconnect、重放、重复 action / message / receipt、公共进程正文、密钥和诱饵泄漏均为 0,disposable项目均自动清理;V1.11.1 持久进程链路据此完成验收。
2026-10-07 V1.11.2 切片:Linux 命令沙箱原生支持 fnm / nvm 托管的 Node。开发构建(`debug_assertions`)先从 `.nvmrc` / `.node-version` 的权威 pin、再按 `package.json` `engines.node` 偏好、活动版本、版本管理器默认别名和已安装最高版本选择托管安装;`.nvmrc` / `.node-version` 能理解但未安装时失败关闭,`engines.node` 和无法解析的写法不阻塞。宿主发现与沙箱只读挂载共用同一窄叶校验,只挂载完整安装前缀(含 `bin/node` 与 npm 的 `npm-cli.js`),拒绝 HOME、管理器根、`aliases`、宽泛目录、不完整前缀和逃逸 symlink。Linux npm 以 `node <npm-cli.js>` 启动,`npm install` 的联网判定跟随该真实形态;sandbox 环境剔除 fnm multishell 变量。发布构建仍只认随包 bundle,Windows 不变。定向 Rust 测试与真实 bwrap 内 fnm v22 的 `node` / npm 运行已通过;真实 nvm 端到端待补。
2026-10-07 V1.11.2 切片:Linux 命令沙箱原生支持 fnm / nvm 托管的 Node。开发构建(`debug_assertions`)按 `.nvmrc` / `.node-version`(nvm / fnm 都读)命中、`package.json` `engines.node` 偏好、版本管理器默认别名和已安装最高版本的顺序选择托管安装;`.nvmrc` / `.node-version` 未安装时继续回退、不阻塞,`engines.node` 和无法解析的写法同样不阻塞。宿主发现与沙箱只读挂载共用同一窄叶校验,只挂载完整安装前缀(含 `bin/node` 与 npm 的 `npm-cli.js`),拒绝 HOME、管理器根、`aliases`、宽泛目录、不完整前缀和逃逸 symlink。Linux npm 以 `node <npm-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,恢复不得重放提交。
+38 -5
View File
@@ -8,7 +8,9 @@ import io
import json
import os
from pathlib import Path
import re
import socketserver
import sys
import threading
import time
import urllib.parse
@@ -22,6 +24,30 @@ HOP_HEADERS = {
RPC_PREFIX = "/api/actions/runner.v1.RunnerService/"
def fetch_conflict_rolled_back(status, body, headers):
"""仅识别 Gitea 1.26.4 在任务分配事务提交前返回的 run 更新冲突。"""
# PickTask/WithTx -> CreateTaskForRunner -> UpdateRunJob -> UpdateRun。
# 该错误会回滚任务分配;其它 5xx、截断响应和未知协议仍须阻断领取。
if (status != 500 or len(body) > 4096
or headers.get("Content-Encoding", "identity").lower() != "identity"
or headers.get("Content-Type", "").split(";", 1)[0] != "application/json"):
return False
try:
error = json.loads(body)
except (ValueError, UnicodeError):
return False
return (isinstance(error, dict) and error.get("code") == "unknown"
and isinstance(error.get("message"), str)
and re.fullmatch(
r"rpc error: code = Internal desc = pick task: CreateTaskForRunner: "
r"update run [1-9][0-9]*: run has changed", error["message"]) is not None)
def diagnostic(message):
# 仅传固定分类,不输出上游正文、URL 或 RPC 凭据。
print(f"[runner-fetch-gate] {message}", file=sys.stderr, flush=True)
def protobuf_fields(data):
"""只解码已核对的 actions-proto-go v0.4.1 字段,不引入 protobuf 运行时。"""
fields = {}
@@ -153,8 +179,10 @@ class Gate:
os.replace(temporary, self.directory / "tasks.json")
self.sync_directory()
def fail_closed(self):
def fail_closed(self, reason):
with self.lock:
if not self.uncertain:
diagnostic(f"uncertain: {reason}; operator recovery required")
self.uncertain = self.paused = True
self.mark("uncertain")
@@ -238,6 +266,8 @@ class Gate:
with self.lock:
self.inflight -= 1
if not completed:
if not self.uncertain:
diagnostic("uncertain: incomplete FetchTask response; operator recovery required")
self.uncertain = self.paused = True
self.mark("uncertain")
if self.inflight == 0 and not self.uncertain:
@@ -369,11 +399,14 @@ class Proxy(http.server.BaseHTTPRequestHandler):
completed = True
if is_fetch:
try:
if oversized or response.status != 200:
if not oversized and fetch_conflict_rolled_back(response.status, result, response.headers):
diagnostic("FetchTask transaction rolled back: run update conflict; runner may retry")
elif oversized or response.status != 200:
raise ValueError("unknown FetchTask result")
self.server.gate.assigned(rpc_fields(bytes(result), response.headers))
else:
self.server.gate.assigned(rpc_fields(bytes(result), response.headers))
except (ValueError, TypeError, OSError, EOFError, zlib.error):
self.server.gate.fail_closed()
self.server.gate.fail_closed("unrecognized FetchTask response or assignment tracking failure")
self.server.gate.leave(True)
is_fetch = False
if oversized:
@@ -398,7 +431,7 @@ class Proxy(http.server.BaseHTTPRequestHandler):
self.server.gate.reported(method, rpc_fields(request, self.headers),
rpc_fields(response, headers))
except (ValueError, TypeError, OSError, EOFError, zlib.error):
self.server.gate.fail_closed()
self.server.gate.fail_closed("task report tracking failure")
class Control(socketserver.StreamRequestHandler):
+47
View File
@@ -326,6 +326,53 @@ class GateTests(unittest.TestCase):
self.assertTrue(self.gate.control("status")["uncertain"])
self.assertTrue(MODULE.Gate(self.temp.name).control("status")["uncertain"])
def test_rolled_back_run_conflict_allows_next_fetch_and_preserves_tasks(self):
self.fetch_task()
body = json.dumps({"code": "unknown", "message":
"rpc error: code = Internal desc = pick task: CreateTaskForRunner: "
"update run 3673: run has changed"}).encode()
self.upstream.responses[FETCH] = body
self.upstream.statuses[FETCH] = 500
self.upstream.content_type = "application/json"
self.assertEqual(self.request(FETCH), (500, body))
self.assertEqual(core_state(self.gate.control("status")),
{"paused": False, "inflight": 0, "uncertain": False})
self.assertEqual(self.gate.control("status")["task_ids"], ["42"])
self.assertFalse(MODULE.Gate(self.temp.name).control("status")["uncertain"])
self.assertFalse((Path(self.temp.name) / "inflight").exists())
self.upstream.statuses[FETCH] = 200
self.upstream.content_type = "application/proto"
self.upstream.responses[FETCH] = field(1, field(1, 43))
self.assertEqual(self.request(FETCH)[0], 200)
self.assertEqual(self.gate.control("status")["task_ids"], ["42", "43"])
def test_unrecognized_fetch_error_still_latches_uncertainty(self):
self.upstream.release.set()
self.upstream.content_type = "application/json"
self.upstream.statuses[FETCH] = 500
self.upstream.responses[FETCH] = json.dumps({"code": "unknown", "message":
"rpc error: code = Internal desc = pick task: commit failed"}).encode()
self.assertEqual(self.request(FETCH)[0], 500)
self.assertTrue(self.gate.control("status")["uncertain"])
self.assertEqual(self.request(FETCH)[0], 503)
def test_conflict_exception_requires_exact_error_envelope(self):
error = {"code": "unknown", "message":
"rpc error: code = Internal desc = pick task: CreateTaskForRunner: "
"update run 3673: run has changed"}
headers = {"Content-Type": "application/json"}
body = json.dumps(error).encode()
for status, payload, response_headers in [
(502, body, headers), (200, body, headers),
(500, body, {"Content-Type": "text/html"}),
(500, body, {**headers, "Content-Encoding": "gzip"}),
(500, body[:-1], headers), (500, b'[]', headers),
(500, json.dumps({**error, "code": "internal"}).encode(), headers),
(500, json.dumps({**error, "message": error["message"] + " extra"}).encode(), headers),
]:
with self.subTest(status=status, payload=payload, headers=response_headers):
self.assertFalse(MODULE.fetch_conflict_rolled_back(status, payload, response_headers))
def test_task_report_without_observed_assignment_requires_idle_bootstrap(self):
self.assertEqual(self.request(UPDATE, state(42))[0], 200)
self.wait_for(lambda: self.gate.control("status")["uncertain"])