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
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:
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,恢复不得重放提交。
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user