485ed50b26
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
## 背景 净月潭案例(2026-09-19 21:53 → 09-20 01:26,3 小时 33 分)的问题不是模型慢,而是环境未就绪、验收靠模型自述、预算只约束单个工具入口、工具调用被 SDK 全局串行闸门卡住。本 PR 落地确认后的七项交付效率合同,并把捆绑 Codex 升到当前 npm latest。 ## 结果 - 新建 Web 游戏在正式生成前由宿主自动预检:捆绑 Node/npm、真实 Vite 构建、受限浏览器桌面/移动截图;失败不启动生成或付费素材。 - 首个副作用前冻结交付合同,宿主保存权威证据与预算;证据齐全后先封口、排空并取得执行器完整退出证明,再产出交付报告,模型回复不再当作验收。 - 原生 shell、内置浏览器与托管命令、第三方 MCP 共用同一执行许可和累计执行时间;`validation.maxRuns` 改为执行/返修批次语义,另设 `maxTurnSeconds` 墙钟上限。 - 所有工具可并发:移除 SDK 全局串行的 `apply_patch`/`update_plan` 注册,改由宿主 `agc_apply_patch`(官方 parser + 当前回合写许可 + 受控进程树 + 短项目事务)和 `agc_update_plan` 提供等价能力;真实请求目录里已无串行注册,长 MCP 与补丁/计划实测在同一响应内重叠。 - 付费提交与本地写入绑定原回合原租约:容量与同动作锁等待可取消,封口、取消或预算耗尽后零新增提交;已越过提交边界的请求保留 operation ID 走 GET 对账,不自动重放。 - 新增请求与工具分段计时、有界并行批读和首轮上下文预取,未知耗时不补零。 - 捆绑 Codex 0.147.0 → 0.155.1:固定版本收敛到 `build_support/codex_bundle.rs` 单一声明,vendor 解析源码按 `rust-v0.155.1` 逐字节重取并更新 UPSTREAM 证据,适配 0.155 统一 exec(`exec_command`/`write_stdin`);macOS 侧车最小系统版本仍为 15.0。 ## 验证 - 生产 CLI → 捆绑 Codex 0.155.1 → 本地 Responses/MCP 夹具 9/9:补丁、完成收尾、批次、只读/可写 MCP 并发、原生命令并发、原生资源并发、统一 exec 会话、期限终止(真实重叠 775 / 764 / 999 ms)。 - 真实模型目录 2/2;vendor 上游库 111 项;宿主定向与回归 106 项;真实 Windows 进程与取消 21 项;Node 侧门禁 52 项。 - 发行载荷:artifact-only 重建 NSIS,解包验证侧车清单 `codex-cli 0.155.1`、6 个组件哈希、打包后 `bin/codex.exe --version`、Node 运行时 2130 文件与双端 PNG、`--environment-check` ready。 - `check-config`、TypeScript、编码(4991 文件)、文档索引、`git diff --check`、`cargo fmt --check` 全部通过。 ## 未覆盖 - 真实陶泥儿登录态 Provider 生成尚未执行(本机 `--llm-status` 返回 `authentication-required`)。 - macOS 侧车只在本机做静态 Mach-O 与清单解析,未在 macOS 上跑 `check-macos-bundle.mjs`。 - 全仓库聚合套件在本机仍因负载敏感的后台 mock-provider 用例而红,与本次改动无关:改动前的旧二进制同样失败,换单线程后相关用例 3/3 通过。 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/439
656 lines
22 KiB
Rust
656 lines
22 KiB
Rust
//! 客户端拥有的 Web 工具链;模型只读取版本和状态,不读取宿主目录或环境。
|
|
mod web_creation;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::ffi::{OsStr, OsString};
|
|
use std::fs;
|
|
use std::io::Read;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Stdio;
|
|
use std::time::{Duration, Instant};
|
|
pub(crate) use web_creation::{
|
|
host_web_creation_preflight, preflight_web_game_creation, prepare_new_web_project_at,
|
|
record_new_web_scaffold_at,
|
|
};
|
|
|
|
use serde::Deserialize;
|
|
use serde_json::{json, Value};
|
|
use sha2::{Digest, Sha256};
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
const SCHEMA: &str = "agc-node-runtime.v1";
|
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
|
const MAX_PROBE_BYTES: u64 = 4096;
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct NodeRuntime {
|
|
pub node: PathBuf,
|
|
pub npm_cli: PathBuf,
|
|
pub safe_path: OsString,
|
|
pub source: &'static str,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
struct RuntimeManifest {
|
|
schema_version: String,
|
|
platform: String,
|
|
arch: String,
|
|
node_version: String,
|
|
npm_version: String,
|
|
files: BTreeMap<String, String>,
|
|
}
|
|
|
|
fn native_platform() -> &'static str {
|
|
if cfg!(windows) {
|
|
"win32"
|
|
} else if cfg!(target_os = "macos") {
|
|
"darwin"
|
|
} else {
|
|
std::env::consts::OS
|
|
}
|
|
}
|
|
|
|
fn native_arch() -> &'static str {
|
|
if cfg!(target_arch = "x86_64") {
|
|
"x64"
|
|
} else if cfg!(target_arch = "aarch64") {
|
|
"arm64"
|
|
} else {
|
|
std::env::consts::ARCH
|
|
}
|
|
}
|
|
|
|
fn executable_name() -> &'static str {
|
|
if cfg!(windows) {
|
|
"node.exe"
|
|
} else {
|
|
"node"
|
|
}
|
|
}
|
|
|
|
fn bundle_directory(executable: &Path) -> Option<PathBuf> {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let macos = executable.parent()?;
|
|
let contents = macos.parent()?;
|
|
if macos.file_name()? != "MacOS"
|
|
|| contents.file_name()? != "Contents"
|
|
|| contents.parent()?.extension()? != "app"
|
|
{
|
|
return None;
|
|
}
|
|
Some(contents.join("Resources/game-runtime/node"))
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
Some(executable.parent()?.join("game-runtime/node"))
|
|
}
|
|
}
|
|
|
|
fn safe_directories(root: &Path, path: &OsStr) -> Vec<PathBuf> {
|
|
let mut seen = BTreeSet::new();
|
|
std::env::split_paths(path)
|
|
.filter_map(|path| {
|
|
if !path.is_absolute() {
|
|
return None;
|
|
}
|
|
let path = path.canonicalize().ok()?;
|
|
if !path.is_dir() || path.starts_with(root) || !seen.insert(path.clone()) {
|
|
return None;
|
|
}
|
|
Some(path)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn command_path(path: PathBuf) -> PathBuf {
|
|
#[cfg(windows)]
|
|
{
|
|
let value = path.to_string_lossy();
|
|
if let Some(unc) = value.strip_prefix(r"\\?\UNC\") {
|
|
return PathBuf::from(format!(r"\\{unc}"));
|
|
}
|
|
PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value))
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
path
|
|
}
|
|
}
|
|
|
|
fn diagnostic_path_from(root: &Path, path: &OsStr) -> OsString {
|
|
let Ok(root) = root.canonicalize() else {
|
|
return OsString::new();
|
|
};
|
|
// 缺失/损坏的随包运行时不能通过继承 PATH 静默变成系统 Node。
|
|
// 其余绝对、项目外目录保留给 PowerShell、Git 等诊断工具。
|
|
let directories = safe_directories(&root, path)
|
|
.into_iter()
|
|
.filter(|directory| {
|
|
!["node", "npm", "npx"].iter().any(|name| {
|
|
["", ".exe", ".com", ".cmd", ".bat", ".ps1"]
|
|
.iter()
|
|
.any(|suffix| directory.join(format!("{name}{suffix}")).is_file())
|
|
})
|
|
});
|
|
let directories = directories.map(command_path);
|
|
std::env::join_paths(directories).unwrap_or_default()
|
|
}
|
|
|
|
pub(crate) fn safe_diagnostic_path(root: &Path) -> OsString {
|
|
diagnostic_path_from(root, &std::env::var_os("PATH").unwrap_or_default())
|
|
}
|
|
|
|
fn runtime_from_paths(
|
|
root: &Path,
|
|
node: PathBuf,
|
|
npm_cli: PathBuf,
|
|
source: &'static str,
|
|
path: &OsStr,
|
|
) -> Result<NodeRuntime, String> {
|
|
let node = node.canonicalize().map_err(|_| "node-runtime-missing")?;
|
|
let npm_cli = npm_cli.canonicalize().map_err(|_| "npm-runtime-missing")?;
|
|
if !node.is_file() || !npm_cli.is_file() || node.starts_with(root) || npm_cli.starts_with(root)
|
|
{
|
|
return Err("node-runtime-untrusted".into());
|
|
}
|
|
let mut directories = vec![node.parent().ok_or("node-runtime-invalid")?.to_path_buf()];
|
|
directories.extend(safe_directories(root, path));
|
|
let directories = directories
|
|
.into_iter()
|
|
.map(command_path)
|
|
.collect::<Vec<_>>();
|
|
Ok(NodeRuntime {
|
|
node: command_path(node),
|
|
npm_cli: command_path(npm_cli),
|
|
safe_path: std::env::join_paths(directories).map_err(|_| "node-runtime-path-invalid")?,
|
|
source,
|
|
})
|
|
}
|
|
|
|
fn collect_bundle_files(
|
|
root: &Path,
|
|
directory: &Path,
|
|
files: &mut BTreeSet<String>,
|
|
) -> Result<(), String> {
|
|
for entry in fs::read_dir(directory).map_err(|_| "node-runtime-integrity-failed")? {
|
|
let entry = entry.map_err(|_| "node-runtime-integrity-failed")?;
|
|
let kind = entry
|
|
.file_type()
|
|
.map_err(|_| "node-runtime-integrity-failed")?;
|
|
if kind.is_symlink() {
|
|
return Err("node-runtime-integrity-failed".into());
|
|
}
|
|
if kind.is_dir() {
|
|
collect_bundle_files(root, &entry.path(), files)?;
|
|
} else if kind.is_file() {
|
|
let path = entry
|
|
.path()
|
|
.strip_prefix(root)
|
|
.map_err(|_| "node-runtime-integrity-failed")?
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
if path != "manifest.json" {
|
|
files.insert(path);
|
|
}
|
|
if files.len() > 20_000 {
|
|
return Err("node-runtime-integrity-failed".into());
|
|
}
|
|
} else {
|
|
return Err("node-runtime-integrity-failed".into());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_bundle(directory: &Path) -> Result<(), String> {
|
|
if fs::symlink_metadata(directory)
|
|
.map_err(|_| "node-runtime-bundle-missing")?
|
|
.file_type()
|
|
.is_symlink()
|
|
{
|
|
return Err("node-runtime-integrity-failed".into());
|
|
}
|
|
let file = fs::File::open(directory.join("manifest.json"))
|
|
.map_err(|_| "node-runtime-manifest-missing")?;
|
|
let mut bytes = Vec::new();
|
|
file.take(4 * 1024 * 1024 + 1)
|
|
.read_to_end(&mut bytes)
|
|
.map_err(|_| "node-runtime-manifest-invalid")?;
|
|
if bytes.len() > 4 * 1024 * 1024 {
|
|
return Err("node-runtime-manifest-invalid".into());
|
|
}
|
|
let manifest: RuntimeManifest =
|
|
serde_json::from_slice(&bytes).map_err(|_| "node-runtime-manifest-invalid")?;
|
|
if manifest.schema_version != SCHEMA
|
|
|| manifest.platform != native_platform()
|
|
|| manifest.arch != native_arch()
|
|
|| !valid_version(&manifest.node_version)
|
|
|| !valid_version(&manifest.npm_version)
|
|
{
|
|
return Err("node-runtime-manifest-invalid".into());
|
|
}
|
|
for required in [
|
|
executable_name(),
|
|
"node_modules/npm/LICENSE",
|
|
"node_modules/npm/package.json",
|
|
"node_modules/npm/bin/npm-cli.js",
|
|
if cfg!(windows) { "npm.cmd" } else { "npm" },
|
|
] {
|
|
if !manifest.files.contains_key(required) {
|
|
return Err("node-runtime-manifest-invalid".into());
|
|
}
|
|
}
|
|
if !manifest.files.contains_key("NODE-LICENSE")
|
|
&& !manifest.files.contains_key("NODE-LICENSE.rtf")
|
|
{
|
|
return Err("node-runtime-manifest-invalid".into());
|
|
}
|
|
let mut actual = BTreeSet::new();
|
|
collect_bundle_files(directory, directory, &mut actual)?;
|
|
if actual != manifest.files.keys().cloned().collect() {
|
|
return Err("node-runtime-integrity-failed".into());
|
|
}
|
|
for (relative, expected) in manifest.files {
|
|
if relative.contains('\\')
|
|
|| relative.contains(':')
|
|
|| relative
|
|
.split('/')
|
|
.any(|part| part.is_empty() || part == "." || part == "..")
|
|
|| expected.len() != 64
|
|
{
|
|
return Err("node-runtime-manifest-invalid".into());
|
|
}
|
|
let mut file = fs::File::open(directory.join(relative))
|
|
.map_err(|_| "node-runtime-integrity-failed")?;
|
|
let mut digest = Sha256::new();
|
|
let mut buffer = [0u8; 64 * 1024];
|
|
loop {
|
|
let count = file
|
|
.read(&mut buffer)
|
|
.map_err(|_| "node-runtime-integrity-failed")?;
|
|
if count == 0 {
|
|
break;
|
|
}
|
|
digest.update(&buffer[..count]);
|
|
}
|
|
if format!("{:x}", digest.finalize()) != expected {
|
|
return Err("node-runtime-integrity-failed".into());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_at(
|
|
root: &Path,
|
|
bundle: Option<&Path>,
|
|
development: bool,
|
|
path: &OsStr,
|
|
) -> Result<NodeRuntime, String> {
|
|
let root = root
|
|
.canonicalize()
|
|
.map_err(|_| "project-root-unavailable")?;
|
|
if let Some(bundle) = bundle.filter(|bundle| bundle.exists()) {
|
|
validate_bundle(bundle)?;
|
|
return runtime_from_paths(
|
|
&root,
|
|
bundle.join(executable_name()),
|
|
bundle.join("node_modules/npm/bin/npm-cli.js"),
|
|
"bundled",
|
|
path,
|
|
);
|
|
}
|
|
if !development {
|
|
return Err("node-runtime-bundle-missing".into());
|
|
}
|
|
let directories = safe_directories(&root, path);
|
|
for directory in &directories {
|
|
let candidate = directory.join(executable_name());
|
|
let Ok(node) = candidate.canonicalize() else {
|
|
continue;
|
|
};
|
|
if !node.is_file() || node.starts_with(&root) {
|
|
continue;
|
|
}
|
|
let parent = node.parent().ok_or("node-runtime-invalid")?;
|
|
let mut npm_candidates = vec![
|
|
parent.join("node_modules/npm/bin/npm-cli.js"),
|
|
parent.join("../lib/node_modules/npm/bin/npm-cli.js"),
|
|
parent.join("../share/nodejs/npm/bin/npm-cli.js"),
|
|
];
|
|
for directory in &directories {
|
|
if let Ok(npm) = directory.join("npm").canonicalize() {
|
|
if npm.file_name() == Some(OsStr::new("npm-cli.js")) {
|
|
npm_candidates.push(npm);
|
|
}
|
|
}
|
|
}
|
|
if let Some(npm_cli) = npm_candidates
|
|
.into_iter()
|
|
.find(|candidate| candidate.is_file())
|
|
{
|
|
return runtime_from_paths(&root, node, npm_cli, "development", path);
|
|
}
|
|
}
|
|
Err("node-npm-runtime-missing".into())
|
|
}
|
|
|
|
pub(crate) fn resolve_node_runtime(root: &Path) -> Result<NodeRuntime, String> {
|
|
let executable = std::env::current_exe().map_err(|_| "node-runtime-location-unavailable")?;
|
|
resolve_at(
|
|
root,
|
|
bundle_directory(&executable).as_deref(),
|
|
cfg!(debug_assertions),
|
|
&std::env::var_os("PATH").unwrap_or_default(),
|
|
)
|
|
}
|
|
|
|
fn valid_version(value: &str) -> bool {
|
|
let value = value.strip_prefix('v').unwrap_or(value);
|
|
value.len() <= 40
|
|
&& value.split('.').count() == 3
|
|
&& value
|
|
.split('.')
|
|
.all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
|
|
}
|
|
|
|
async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result<String, String> {
|
|
let mut command = tokio::process::Command::new(&runtime.node);
|
|
command
|
|
.env_clear()
|
|
.env("PATH", &runtime.safe_path)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.kill_on_drop(true);
|
|
for name in ["SystemRoot", "WINDIR", "ComSpec", "TEMP", "TMP"] {
|
|
if let Some(value) = std::env::var_os(name) {
|
|
command.env(name, value);
|
|
}
|
|
}
|
|
if npm {
|
|
command.arg(&runtime.npm_cli);
|
|
}
|
|
command.arg("--version");
|
|
#[cfg(windows)]
|
|
crate::configure_windows_background_tokio_command(&mut command, true);
|
|
let mut child = command
|
|
.spawn()
|
|
.map_err(|_| "runtime-version-start-failed")?;
|
|
let mut stdout = child
|
|
.stdout
|
|
.take()
|
|
.ok_or("runtime-version-output-missing")?
|
|
.take(MAX_PROBE_BYTES + 1);
|
|
let mut output = Vec::new();
|
|
let result = tokio::time::timeout(PROBE_TIMEOUT, async {
|
|
stdout
|
|
.read_to_end(&mut output)
|
|
.await
|
|
.map_err(|_| "runtime-version-output-failed")?;
|
|
if output.len() as u64 > MAX_PROBE_BYTES {
|
|
return Err("runtime-version-output-too-large");
|
|
}
|
|
let status = child
|
|
.wait()
|
|
.await
|
|
.map_err(|_| "runtime-version-wait-failed")?;
|
|
if !status.success() {
|
|
return Err("runtime-version-failed");
|
|
}
|
|
Ok(())
|
|
})
|
|
.await;
|
|
if !matches!(result, Ok(Ok(()))) {
|
|
let _ = child.kill().await;
|
|
let _ = child.wait().await;
|
|
}
|
|
result
|
|
.map_err(|_| "runtime-version-timeout")?
|
|
.map_err(str::to_string)?;
|
|
let version = String::from_utf8(output)
|
|
.map_err(|_| "runtime-version-invalid")?
|
|
.trim()
|
|
.to_string();
|
|
if !valid_version(&version) {
|
|
return Err("runtime-version-invalid".into());
|
|
}
|
|
Ok(version)
|
|
}
|
|
|
|
pub(crate) async fn check_environment(root: &Path) -> Value {
|
|
let started = Instant::now();
|
|
// 独立检查可并发,不占项目锁,也不写入 manifest/revision。
|
|
let node_check = async {
|
|
let started = Instant::now();
|
|
let root = root.to_path_buf();
|
|
let result = tokio::task::spawn_blocking(move || resolve_node_runtime(&root)).await;
|
|
let runtime = match result {
|
|
Ok(Ok(runtime)) => runtime,
|
|
Ok(Err(code)) => {
|
|
return json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()})
|
|
}
|
|
Err(_) => {
|
|
return json!({"status":"blocked","code":"runtime-check-failed","elapsedMs":started.elapsed().as_millis()})
|
|
}
|
|
};
|
|
let (node, npm) = tokio::join!(
|
|
probe_version(&runtime, false),
|
|
probe_version(&runtime, true)
|
|
);
|
|
match (node, npm) {
|
|
(Ok(node), Ok(npm)) => {
|
|
json!({"status":"ready","source":runtime.source,"nodeVersion":node,"npmVersion":npm,"elapsedMs":started.elapsed().as_millis()})
|
|
}
|
|
(Err(code), _) | (_, Err(code)) => {
|
|
json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()})
|
|
}
|
|
}
|
|
};
|
|
let browser_check = async {
|
|
let started = Instant::now();
|
|
match crate::browser::check_browser_health().await {
|
|
Ok(browser) => {
|
|
json!({"status":"ready","kind":browser.kind,"product":browser.product,"protocolVersion":browser.protocol_version,"elapsedMs":started.elapsed().as_millis()})
|
|
}
|
|
Err(code) => {
|
|
json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()})
|
|
}
|
|
}
|
|
};
|
|
let (runtime, browser) = tokio::join!(node_check, browser_check);
|
|
json!({"schemaVersion":"agc-environment-check.v1","status":if runtime["status"] == "ready" && browser["status"] == "ready" {"ready"} else {"blocked"},"runtime":runtime,"browser":browser,"elapsedMs":started.elapsed().as_millis()})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn bundle_fixture(directory: &Path) {
|
|
let mut files = BTreeMap::new();
|
|
for relative in [
|
|
executable_name(),
|
|
"NODE-LICENSE",
|
|
"node_modules/npm/LICENSE",
|
|
"node_modules/npm/package.json",
|
|
"node_modules/npm/bin/npm-cli.js",
|
|
if cfg!(windows) { "npm.cmd" } else { "npm" },
|
|
] {
|
|
let path = directory.join(relative);
|
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
fs::write(path, relative.as_bytes()).unwrap();
|
|
files.insert(
|
|
relative,
|
|
format!("{:x}", Sha256::digest(relative.as_bytes())),
|
|
);
|
|
}
|
|
fs::write(directory.join("manifest.json"), serde_json::to_vec(&json!({"schemaVersion":SCHEMA,"platform":native_platform(),"arch":native_arch(),"nodeVersion":"v24.0.0","npmVersion":"11.0.0","files":files})).unwrap()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_integrity_rejects_changed_missing_unlisted_and_wrong_architecture_files() {
|
|
let bundle = tempfile::tempdir().unwrap();
|
|
bundle_fixture(bundle.path());
|
|
validate_bundle(bundle.path()).unwrap();
|
|
fs::write(bundle.path().join(executable_name()), "modified").unwrap();
|
|
assert_eq!(
|
|
validate_bundle(bundle.path()).unwrap_err(),
|
|
"node-runtime-integrity-failed"
|
|
);
|
|
bundle_fixture(bundle.path());
|
|
fs::write(bundle.path().join("unlisted.js"), "unexpected").unwrap();
|
|
assert_eq!(
|
|
validate_bundle(bundle.path()).unwrap_err(),
|
|
"node-runtime-integrity-failed"
|
|
);
|
|
fs::remove_file(bundle.path().join("unlisted.js")).unwrap();
|
|
fs::remove_file(bundle.path().join("node_modules/npm/bin/npm-cli.js")).unwrap();
|
|
assert_eq!(
|
|
validate_bundle(bundle.path()).unwrap_err(),
|
|
"node-runtime-integrity-failed"
|
|
);
|
|
bundle_fixture(bundle.path());
|
|
let manifest_path = bundle.path().join("manifest.json");
|
|
let mut manifest: Value =
|
|
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
|
|
manifest["arch"] = json!("wrong-architecture");
|
|
fs::write(manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
|
|
assert_eq!(
|
|
validate_bundle(bundle.path()).unwrap_err(),
|
|
"node-runtime-manifest-invalid"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn complete_bundle_resolves_without_system_node_and_does_not_expose_project_path() {
|
|
let project = tempfile::tempdir().unwrap();
|
|
let bundle = tempfile::tempdir().unwrap();
|
|
bundle_fixture(bundle.path());
|
|
let runtime = resolve_at(
|
|
project.path(),
|
|
Some(bundle.path()),
|
|
false,
|
|
project.path().as_os_str(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(runtime.source, "bundled");
|
|
assert_eq!(
|
|
runtime.node.canonicalize().unwrap(),
|
|
bundle
|
|
.path()
|
|
.join(executable_name())
|
|
.canonicalize()
|
|
.unwrap()
|
|
);
|
|
assert_eq!(std::env::split_paths(&runtime.safe_path).count(), 1);
|
|
assert_eq!(fs::read_dir(project.path()).unwrap().count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn bundle_accepts_hashed_original_installer_rtf_license() {
|
|
let bundle = tempfile::tempdir().unwrap();
|
|
bundle_fixture(bundle.path());
|
|
fs::rename(
|
|
bundle.path().join("NODE-LICENSE"),
|
|
bundle.path().join("NODE-LICENSE.rtf"),
|
|
)
|
|
.unwrap();
|
|
let manifest_path = bundle.path().join("manifest.json");
|
|
let mut manifest: Value =
|
|
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
|
|
let digest = manifest["files"]
|
|
.as_object_mut()
|
|
.unwrap()
|
|
.remove("NODE-LICENSE")
|
|
.unwrap();
|
|
manifest["files"]["NODE-LICENSE.rtf"] = digest;
|
|
fs::write(manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
|
|
validate_bundle(bundle.path()).unwrap();
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn node_script_arguments_use_win32_paths_after_identity_validation() {
|
|
assert_eq!(
|
|
command_path(PathBuf::from(r"\\?\C:\node\npm-cli.js")),
|
|
PathBuf::from(r"C:\node\npm-cli.js")
|
|
);
|
|
assert_eq!(
|
|
command_path(PathBuf::from(r"\\?\UNC\server\node\npm-cli.js")),
|
|
PathBuf::from(r"\\server\node\npm-cli.js")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn installed_runtime_never_falls_back_when_bundle_is_missing_or_invalid() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let bundle = tempfile::tempdir().unwrap();
|
|
assert_eq!(
|
|
resolve_at(root.path(), None, false, OsStr::new("")).unwrap_err(),
|
|
"node-runtime-bundle-missing"
|
|
);
|
|
assert_eq!(
|
|
resolve_at(root.path(), Some(bundle.path()), true, OsStr::new("")).unwrap_err(),
|
|
"node-runtime-manifest-missing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn development_runtime_rejects_relative_and_project_path_entries() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
fs::write(root.path().join(executable_name()), b"fake").unwrap();
|
|
let path = std::env::join_paths([Path::new("."), root.path()]).unwrap();
|
|
assert_eq!(
|
|
resolve_at(root.path(), None, true, &path).unwrap_err(),
|
|
"node-npm-runtime-missing"
|
|
);
|
|
assert!(!valid_version("v24.0.0\nSECRET"));
|
|
}
|
|
|
|
#[test]
|
|
fn diagnostic_path_removes_runtime_and_project_entries_but_keeps_other_tools() {
|
|
let project = tempfile::tempdir().unwrap();
|
|
let host = tempfile::tempdir().unwrap();
|
|
let system = host.path().join("system-tools");
|
|
let node = host.path().join("node");
|
|
let npm = host.path().join("npm-shim");
|
|
let npx = host.path().join("npx-shim");
|
|
for directory in [&system, &node, &npm, &npx] {
|
|
fs::create_dir(directory).unwrap();
|
|
}
|
|
fs::write(system.join("git.exe"), "diagnostic fixture").unwrap();
|
|
fs::write(node.join("node.exe"), "unvalidated node").unwrap();
|
|
fs::write(npm.join("npm.ps1"), "unvalidated npm shim").unwrap();
|
|
fs::write(npx.join("npx"), "unvalidated npx shim").unwrap();
|
|
let path =
|
|
std::env::join_paths([Path::new("."), project.path(), &node, &npm, &system, &npx])
|
|
.unwrap();
|
|
let kept: Vec<_> =
|
|
std::env::split_paths(&diagnostic_path_from(project.path(), &path)).collect();
|
|
assert_eq!(kept.len(), 1);
|
|
assert_eq!(
|
|
kept[0].canonicalize().unwrap(),
|
|
system.canonicalize().unwrap()
|
|
);
|
|
assert_eq!(fs::read_dir(project.path()).unwrap().count(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires an installed development Node/npm; no network or project mutation"]
|
|
async fn real_node_npm_environment_versions() {
|
|
let root = tempfile::tempdir().unwrap();
|
|
let runtime = resolve_at(
|
|
root.path(),
|
|
None,
|
|
true,
|
|
&std::env::var_os("PATH").unwrap_or_default(),
|
|
)
|
|
.unwrap();
|
|
assert!(valid_version(
|
|
&probe_version(&runtime, false).await.unwrap()
|
|
));
|
|
assert!(valid_version(&probe_version(&runtime, true).await.unwrap()));
|
|
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 0);
|
|
}
|
|
}
|