构建脚本改为按声明生成并只读校验随包资源

- 平台布局、插件随包子目录与跳过规则、Codex 上游候选路径与第三方声明来源全部改由声明提供,删除脚本内的字面量
- 新增只读校验:Codex 目录校验清单 schema/平台/版本、文件集合、逐文件 sha256、第三方声明、可执行位与白名单外文件;插件目录校验必需组件与整树符号链接
- AGC_BUILD_TARGET 的 rustc-env 移到无条件路径(保留职责),新增 AGC_SKIP_RESOURCE_STAGING=1 以在既有产物上只跑校验
- 删除脚本内与 package_layout 重复的 sha256 实现,复用同一份
This commit is contained in:
2026-09-27 18:36:04 +08:00
parent d8b37e0da9
commit 05d455d4ef
+167 -84
View File
@@ -15,35 +15,20 @@ use std::env;
use std::fs;
use std::path::PathBuf;
use std::io::{BufReader, Read};
fn sha256_file(path: &std::path::Path) -> Result<String, std::io::Error> {
let file = fs::File::open(path)?;
let mut reader = BufReader::new(file);
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = reader.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
use codex_bundle::package_layout;
fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
let target = env::var("TARGET").expect("Cargo TARGET");
println!("cargo:rustc-env=AGC_BUILD_TARGET={target}");
if target.contains("apple-darwin") {
let resource_directory = manifest_dir.join(package_layout::codex().resource_directory);
if let Some(group) = package_layout::codex_universal_group(&target) {
// Tauri 的 universal 两次 Cargo 编译共用 resource staging,
// 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。
let staging = manifest_dir.join("resources/codex/mac-native");
let staging = resource_directory.join(group.directory);
if staging.exists() {
fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败");
}
for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] {
stage_codex_target(manifest_dir, target);
for member in group.targets {
stage_codex_target(manifest_dir, member);
}
} else {
stage_codex_target(manifest_dir, &target);
@@ -66,18 +51,9 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
.parent()
.and_then(|apps_dir| apps_dir.parent())
.expect("AI 游戏创作应用必须位于仓库 apps 目录下");
let package = format!("codex-{}", layout.platform);
let source_candidates = [app_root, repo_root]
.into_iter()
.flat_map(|root| {
[
root.join(format!("node_modules/@openai/{package}/vendor/{target}")),
root.join(format!(
"node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}"
)),
]
})
.collect::<Vec<_>>();
let source_candidates =
package_layout::codex_source_candidates(app_root, repo_root, target)
.unwrap_or_else(|error| panic!("{error}"));
let source = source_candidates
.iter()
.find(|path| {
@@ -98,18 +74,23 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
)
});
let metadata: serde_json::Value = serde_json::from_slice(
&fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"),
&fs::read(source.join(package_layout::codex().package_metadata_file_name))
.expect("读取 Codex 原生包元数据失败"),
)
.expect("Codex 原生包元数据无效");
codex_package_metadata::validate_package_metadata(&metadata, target, layout)
.unwrap_or_else(|error| panic!("{error}"));
let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
let notice = target_dir.join("NOTICE.md");
if target.contains("apple-darwin") {
let source_notice =
manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md");
stage_plugin_file(&source_notice, &notice);
println!("cargo:rerun-if-changed={}", source_notice.display());
let target_dir = manifest_dir
.join(package_layout::codex().resource_directory)
.join(layout.directory);
let notice = target_dir.join(package_layout::codex().notice_file_name);
if let Some(notice_source) = package_layout::codex_notice_source(target) {
if !notice_source.preserve {
// 受版本控制的第三方声明由此处复制;`preserve` 的声明文件本身已在随包目录内。
let source_notice = manifest_dir.join(notice_source.source);
stage_plugin_file(&source_notice, &notice);
println!("cargo:rerun-if-changed={}", source_notice.display());
}
}
if !notice.is_file() {
panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display());
@@ -122,9 +103,10 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).expect("创建内置 Codex CLI 资源子目录失败");
}
let source_sha256 = sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败");
let source_sha256 =
package_layout::sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败");
let target_matches_source = target_path.is_file()
&& sha256_file(&target_path)
&& package_layout::sha256_file(&target_path)
.map(|target_sha256| target_sha256 == source_sha256)
.unwrap_or(false);
let source_permissions = fs::metadata(&source_path)
@@ -156,7 +138,7 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
"version": codex_bundle::CLI_VERSION,
"files": file_hashes,
});
let manifest_path = target_dir.join("manifest.json");
let manifest_path = target_dir.join(package_layout::codex().manifest_file_name);
let manifest_payload = format!(
"{}\n",
serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败")
@@ -217,16 +199,113 @@ fn validate_seed_task_catalog(compiled: &runtime_prompt_bundle::CompiledPromptBu
}
}
/// 只读校验:确认已经落盘的随包产物与声明一致。本函数不写任何文件。
fn validate_staged_resources(manifest_dir: &std::path::Path) {
let target = env::var("TARGET").expect("Cargo TARGET");
for staged_target in package_layout::staged_targets(&target) {
let Some(layout) = codex_bundle::for_target(staged_target) else {
continue;
};
let target_dir = manifest_dir
.join(package_layout::codex().resource_directory)
.join(layout.directory);
package_layout::validate_staged_codex_bundle(&target_dir, staged_target).unwrap_or_else(
|error| panic!("内置 Codex CLI 随包资源校验失败({staged_target}):{error}"),
);
}
validate_staged_plugin_workspace(manifest_dir, &target);
}
/// 只读校验插件随包工作区:插件清单与声明的必需子目录齐备、整树无符号链接、无越界路径。
///
/// 插件产物的逐文件摘要校验在准备步骤接管写入后启用——在那之前构建脚本仍会整体重建
/// `resources/plugins`,任何写在树内的准备步骤清单都会被清掉。
fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) {
if !package_layout::plugin_staging_applies(target) {
return;
}
let declared = package_layout::plugins();
let destination_root = manifest_dir.join(declared.destination_directory);
if !destination_root.is_dir() {
panic!("插件随包资源目录缺失:{}", destination_root.display());
}
let repo_root = manifest_dir
.parent()
.and_then(|app_root| app_root.parent())
.and_then(|apps_dir| apps_dir.parent())
.expect("AGC 应用必须位于仓库 apps 目录下");
if let Ok(entries) = std::fs::read_dir(repo_root.join(declared.source_directory)) {
for entry in entries.flatten() {
let plugin_root = entry.path();
if !plugin_root.is_dir() || !plugin_root.join(declared.manifest_file_name).is_file() {
continue;
}
let name = entry.file_name();
let staged = destination_root.join(&name);
if !staged.join(declared.manifest_file_name).is_file() {
panic!(
"随包插件缺少清单:{}",
staged.join(declared.manifest_file_name).display()
);
}
for subdirectory in declared.subdirectories {
if !package_layout::subdirectory_enabled(
subdirectory,
target,
package_layout::cargo_feature_enabled,
) {
continue;
}
let relative = package_layout::declared_relative_path(subdirectory.path);
if plugin_root.join(&relative).is_dir() && !staged.join(&relative).is_dir() {
panic!(
"随包插件缺少必需目录:{}(插件 {})",
staged.join(&relative).display(),
name.to_string_lossy()
);
}
}
for staging in declared.library_staging {
if name != staging.plugin
|| !package_layout::library_staging_enabled(
staging,
target,
package_layout::cargo_feature_enabled,
)
{
continue;
}
let relative = package_layout::declared_relative_path(staging.source_subdirectory);
if !staged.join(&relative).is_dir() {
panic!("随包库目录缺失:{}", staged.join(&relative).display());
}
}
}
}
package_layout::collect_tree_files(&destination_root)
.unwrap_or_else(|error| panic!("插件随包资源校验失败:{error}"));
}
fn main() {
let manifest_dir = PathBuf::from(
env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"),
);
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
stage_bundled_codex_cli(&manifest_dir);
prepare_unity_editor_helper(&manifest_dir);
prepare_godot_editor_extension(&manifest_dir);
stage_plugin_workspace(&manifest_dir);
stage_cocos_editor_payload(&manifest_dir);
// 运行期定位随包目录依赖该编译期常量,与是否跳过 staging 无关(见技术方案 §4.4)。
println!(
"cargo:rustc-env=AGC_BUILD_TARGET={}",
env::var("TARGET").expect("Cargo TARGET")
);
// AGC_SKIP_RESOURCE_STAGING=1 只做只读校验(要求随包资源已由准备步骤生成),
// 用于在既有产物上单独验证校验路径。
if env::var_os("AGC_SKIP_RESOURCE_STAGING").is_none() {
stage_bundled_codex_cli(&manifest_dir);
prepare_unity_editor_helper(&manifest_dir);
prepare_godot_editor_extension(&manifest_dir);
stage_plugin_workspace(&manifest_dir);
stage_cocos_editor_payload(&manifest_dir);
}
validate_staged_resources(&manifest_dir);
let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path)
.unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}"));
validate_seed_task_catalog(&compiled);
@@ -435,17 +514,18 @@ fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) {
/// Cargo target 目录或 node_modules。
fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
let target = env::var("TARGET").expect("Cargo TARGET");
if !target.contains("windows") && !target.contains("apple-darwin") {
if !package_layout::plugin_staging_applies(&target) {
return;
}
let declared = package_layout::plugins();
let repo_root = manifest_dir
.parent()
.and_then(|app_root| app_root.parent())
.and_then(|apps_dir| apps_dir.parent())
.expect("AGC 应用必须位于仓库 apps 目录下")
.to_path_buf();
let workspace = repo_root.join("plugins");
let destination_root = manifest_dir.join("resources/plugins");
let workspace = repo_root.join(declared.source_directory);
let destination_root = manifest_dir.join(declared.destination_directory);
// staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。
if destination_root.exists() {
std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败");
@@ -464,39 +544,47 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
.is_symlink(),
"插件工作区不允许符号链接"
);
if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() {
if !plugin_root.is_dir() || !plugin_root.join(declared.manifest_file_name).is_file() {
continue;
}
let name = entry.file_name();
let destination = destination_root.join(&name);
copy_plugin_file(
&plugin_root.join("plugin.json"),
&destination.join("plugin.json"),
&plugin_root.join(declared.manifest_file_name),
&destination.join(declared.manifest_file_name),
);
for relative in [
std::path::PathBuf::from("src"),
std::path::PathBuf::from("panels"),
std::path::PathBuf::from("skills"),
std::path::PathBuf::from("native/payload"),
std::path::PathBuf::from("dotnet/publish/win-x64"),
] {
if (relative == std::path::Path::new("native/payload") && !target.contains("windows"))
|| (relative == std::path::Path::new("dotnet/publish/win-x64")
&& (target != "x86_64-pc-windows-msvc"
|| env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none()))
for subdirectory in declared.subdirectories {
if !package_layout::subdirectory_enabled(
subdirectory,
&target,
package_layout::cargo_feature_enabled,
) {
continue;
}
let relative = package_layout::declared_relative_path(subdirectory.path);
copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
}
for staging in declared.library_staging {
if name != staging.plugin
|| !package_layout::library_staging_enabled(
staging,
&target,
package_layout::cargo_feature_enabled,
)
{
continue;
}
copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
}
if name == "agc-godot-editor" {
godot_bundle::stage(
&plugin_root.join("native/gdextension"),
&destination.join("native/gdextension"),
&target,
env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_some(),
)
.unwrap_or_else(|error| panic!("{error}"));
let relative = package_layout::declared_relative_path(staging.source_subdirectory);
match staging.layout {
"godot-bundle" => godot_bundle::stage(
&plugin_root.join(&relative),
&destination.join(&relative),
&target,
true,
)
.unwrap_or_else(|error| panic!("{error}")),
other => panic!("未实现的随包库 staging 布局:{other}"),
}
}
println!("cargo:rerun-if-changed={}", plugin_root.display());
}
@@ -531,20 +619,15 @@ fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) {
);
if path.is_dir() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") {
if package_layout::skip_directory(&name.to_string_lossy()) {
continue;
}
std::fs::create_dir_all(&target).expect("创建插件资源目录失败");
copy_plugin_tree(&path, &target);
} else {
// 测试文件不随包分发。
// 测试文件与隐藏文件不随包分发。
let name = entry.file_name();
let name = name.to_string_lossy();
if name.contains(".test.") {
continue;
}
if name.starts_with('.') {
if package_layout::skip_file_name(&name.to_string_lossy()) {
continue;
}
stage_plugin_file(&path, &target);