diff --git a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs index 67c4ad0b8..5fcedff64 100644 --- a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs +++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs @@ -15,6 +15,12 @@ import { runTauriBuild, } from './build-release.mjs'; import { resolveChannelInstallIdentity } from './channel-identity.mjs'; +import { + assertMacosAppMatchesChannelIdentity, + assertManifestArtifactMatchesExpected, + listStaleMacosArtifacts, + readMacosAppInfoIdentity, +} from './macos-release-identity.mjs'; import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs'; import { readUpdaterPubkey, @@ -103,20 +109,32 @@ const version = await prepareReleaseVersion(context); // 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。 const firstInstallName = `${productName}_${version}_aarch64.dmg`; -// 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则 +// 幂等边界:workspace 会保留上一轮产物。先把构建目录里**所有**更新包、签名与首装包 +// 清掉,否则 // 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中); -// 2) 上一轮遗留的 `.sig` 会让验签门禁把「本轮其实没签」判成通过。 -// 只删本次要写出的确切路径,不动其它版本产物与编译缓存。 +// 2) 上一轮遗留的 `.sig` 会让验签门禁把「本轮其实没签」判成通过; +// 3) 残留的旧 `*.app.tar.gz`(可能是其它渠道身份或更早版本)会被 +// `generateUpdateManifest()` 按目录优先级挑走——2026-09-28 线上 `dev-mac/0.1.142` +// 就是这么把 0.1.139 的 release 身份包发出去的。 +// 只作用于本 target 的构建目录,不动其它版本产物与编译缓存。 const macosBundle = path.join(context.bundleRoot, 'macos'); +const existingBundleFiles = fs.existsSync(macosBundle) + ? fs.readdirSync(macosBundle).map((name) => path.join(macosBundle, name)) + : []; +const staleArtifacts = listStaleMacosArtifacts(existingBundleFiles); +if (staleArtifacts.length > 0) { + console.log( + `[agc-macos] 清理构建目录残留产物 ${staleArtifacts.length} 个:${staleArtifacts + .map((filePath) => path.basename(filePath)) + .join('、')}`, + ); +} for (const stale of [ - path.join(macosBundle, updaterArtifactName), - path.join(macosBundle, `${updaterArtifactName}.sig`), - path.join(macosBundle, `${firstInstallName}`), - path.join(macosBundle, `${firstInstallName}.sha256`), + ...staleArtifacts, path.join(context.bundleRoot, 'latest.json'), path.join(context.bundleRoot, 'release-notes.txt'), ]) { - fs.rmSync(stale, { force: true }); + fs.rmSync(stale, { recursive: true, force: true }); } const args = [ @@ -135,6 +153,21 @@ const command = (binary, argv, options = {}) => runTauriBuild(args, context); const app = path.join(context.bundleRoot, 'macos', appBundleName); +const channelIdentifier = resolveChannelInstallIdentity( + context.channel, +).identifier; +const appIdentity = readMacosAppInfoIdentity( + fs.readFileSync(path.join(app, 'Contents', 'Info.plist'), 'utf8'), +); +assertMacosAppMatchesChannelIdentity({ + identity: appIdentity, + expectedVersion: version, + expectedProductName: productName, + expectedIdentifier: channelIdentifier, +}); +console.log( + `[agc-macos] 产物身份核对通过:${appIdentity.name} ${appIdentity.version} ${appIdentity.identifier}`, +); command(process.execPath, [ path.join(appRoot, 'scripts/check-macos-bundle.mjs'), app, @@ -167,6 +200,10 @@ try { } const release = await generateUpdateManifest(context); +assertManifestArtifactMatchesExpected({ + artifactPath: release.artifact, + expectedPath: path.resolve(path.join(macosBundle, updaterArtifactName)), +}); assert.equal( path.resolve(release.downloadArtifact), path.resolve(dmg), diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index fe13fade7..d1d14803f 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -117,6 +117,26 @@ function ossBaseUrl() { ).replace(/\/+$/u, ''); } +/** + * 产物文件名里的版本必须等于本轮发布版本。 + * + * `selectReleaseArtifact()` 是按目录扫描 + 优先级挑产物,构建目录里残留的旧版本安装包 + * (例如 `..._0.1.153_x64-setup.exe`)会被挑中,于是「清单写新版本、对象是旧版本」。 + * 名字里没有版本号的产物(例如 macOS 的 `<产品名>.app.tar.gz`)返回 null,由各入口的 + * 身份断言负责;Windows 这类带版本号的安装包在这里失败关闭。 + */ +export function assertArtifactVersionMatches(artifactPath, version) { + const match = path.basename(artifactPath).match(/_(\d+\.\d+\.\d+)_/u); + if (!match) return null; + if (match[1] !== version) { + throw new Error( + `发布产物版本与本次发布不一致:产物 ${match[1]},本次 ${version}(${path.basename(artifactPath)});` + + '构建目录里可能残留了上一轮安装包,请清理后再发布', + ); + } + return match[1]; +} + function readPackageJson() { return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); } @@ -669,6 +689,7 @@ export async function generateUpdateManifest( if (!artifact) { throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); } + assertArtifactVersionMatches(artifact, readPackageJson().version); const downloadArtifact = selectFirstInstallArtifact(files, { target, version: readPackageJson().version, diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index fe43ec984..da832d50e 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url'; import { agcReleasePathPatterns, + assertArtifactVersionMatches, buildRelease, buildTauriBuildArguments, compareVersions, @@ -1133,3 +1134,31 @@ test('scheduler skips the full build only for non-deploy paths', () => { assert.ok(skipLine.includes(pattern), `Full Build 跳过模式缺少 ${pattern}`); } }); + +test('rejects a stale installer picked up from the build directory', () => { + // generateUpdateManifest() 是按目录扫描挑产物:残留的旧版本安装包会被挑中, + // 必须在这里失败关闭,而不是把「清单新版本 + 对象旧版本」发出去。 + assert.throws( + () => + assertArtifactVersionMatches( + '/bundle/nsis/陶泥儿开发版_0.1.153_x64-setup.exe', + '0.1.154', + ), + /发布产物版本与本次发布不一致:产物 0\.1\.153,本次 0\.1\.154/u, + ); + assert.equal( + assertArtifactVersionMatches( + '/bundle/nsis/陶泥儿开发版_0.1.154_x64-setup.exe', + '0.1.154', + ), + '0.1.154', + ); + // macOS 更新包名里没有版本号,交给各入口的身份断言处理。 + assert.equal( + assertArtifactVersionMatches( + '/bundle/macos/陶泥儿开发版.app.tar.gz', + '0.1.154', + ), + null, + ); +}); diff --git a/apps/ai-game-creator-shell/scripts/macos-release-identity.mjs b/apps/ai-game-creator-shell/scripts/macos-release-identity.mjs new file mode 100644 index 000000000..f57e918b4 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/macos-release-identity.mjs @@ -0,0 +1,107 @@ +/** + * macOS 发布产物的身份与版本守卫。 + * + * 为什么单独抽出来:2026-09-28 线上核对发现 `dev-mac/0.1.142` 清单指向的更新包 + * 其实是 **0.1.139 的 release 身份包**(`world.genarrative.ai-game-creator.release`)。 + * 根因是 mac 构建目录里会留下上一轮(甚至上一个渠道身份)的 `*.app.tar.gz`,而 + * `generateUpdateManifest()` 是按优先级扫描目录挑产物的——于是「清单写 0.1.142、 + * 包里是 0.1.139 且是另一个渠道身份」。这类错误在客户端上表现为「更新后版本没变 + * 或渠道身份被换掉」,只能靠构建期失败关闭拦住。 + * + * 约束:只做纯函数与显式断言,方便单测复现线上那份坏产物;不在这里读文件系统。 + */ + +/** 会被构建期残留影响的 mac 产物后缀:更新包、签名、首装 DMG 与其摘要。 */ +const STALE_MACOS_ARTIFACT_SUFFIXES = [ + '.app.tar.gz', + '.app.tar.gz.sig', + '.dmg', + '.dmg.sha256', +]; + +function extractPlistString(plistText, key) { + const pattern = new RegExp( + `${key}\\s*([^<]*)`, + 'u', + ); + const match = plistText.match(pattern); + return match ? match[1] : null; +} + +/** 从 `Info.plist` 文本里读出发布相关的身份字段;缺字段返回 null,由断言决定是否致命。 */ +export function readMacosAppInfoIdentity(plistText) { + if (typeof plistText !== 'string' || plistText.trim().length === 0) { + throw new Error('Info.plist 内容为空,无法核对产物身份'); + } + return { + version: extractPlistString(plistText, 'CFBundleShortVersionString'), + identifier: extractPlistString(plistText, 'CFBundleIdentifier'), + name: extractPlistString(plistText, 'CFBundleName'), + displayName: extractPlistString(plistText, 'CFBundleDisplayName'), + }; +} + +/** + * 断言本轮构建出来的 `.app` 就是本渠道本轮该发的产物。 + * + * 三个字段都要对上:版本必须等于即将写进清单的版本;identifier 与产品名必须来自 + * 渠道安装身份(`channel-identity.mjs`),否则同一台机器上的 dev / release 会互相顶掉。 + */ +export function assertMacosAppMatchesChannelIdentity({ + identity, + expectedVersion, + expectedProductName, + expectedIdentifier, +}) { + const problems = []; + if (identity.version !== expectedVersion) { + problems.push( + `版本不一致:产物 ${identity.version ?? '(缺失)'},本轮清单 ${expectedVersion}`, + ); + } + if (identity.identifier !== expectedIdentifier) { + problems.push( + `bundle identifier 不一致:产物 ${identity.identifier ?? '(缺失)'},本渠道 ${expectedIdentifier}`, + ); + } + const names = [identity.name, identity.displayName].filter(Boolean); + if ( + names.length === 0 || + names.some((value) => value !== expectedProductName) + ) { + problems.push( + `产品名不一致:产物 ${names.join(' / ') || '(缺失)'},本渠道 ${expectedProductName}`, + ); + } + if (problems.length > 0) { + throw new Error( + `macOS 产物身份核对失败:${problems.join(';')}。` + + '这通常意味着构建目录里残留了上一轮/其它渠道的产物,或渠道身份没有注入 Tauri 构建。', + ); + } +} + +/** + * 列出构建前必须清掉的残留产物:构建目录里的更新包/签名/首装包只属于本轮, + * 留着就会让按目录扫描的清单生成挑到旧文件。 + */ +export function listStaleMacosArtifacts(filePaths) { + return filePaths.filter((filePath) => + STALE_MACOS_ARTIFACT_SUFFIXES.some((suffix) => filePath.endsWith(suffix)), + ); +} + +/** 断言清单最终选中的更新包就是本轮写出的那一个,避免「签名对但选错包」。 */ +export function assertManifestArtifactMatchesExpected({ + artifactPath, + expectedPath, +}) { + if (!artifactPath || !expectedPath) { + throw new Error('清单产物路径缺失,无法核对本轮更新包'); + } + if (artifactPath !== expectedPath) { + throw new Error( + `清单选中的更新包不是本轮产物:选中 ${artifactPath},本轮应为 ${expectedPath}`, + ); + } +} diff --git a/apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs b/apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs new file mode 100644 index 000000000..6a0397bbf --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + assertMacosAppMatchesChannelIdentity, + assertManifestArtifactMatchesExpected, + listStaleMacosArtifacts, + readMacosAppInfoIdentity, +} from './macos-release-identity.mjs'; + +const DEV_IDENTITY = { + productName: '陶泥儿开发版', + identifier: 'world.genarrative.ai-game-creator', +}; + +function plist({ version, identifier, name }) { + return ` + +CFBundleShortVersionString${version} +CFBundleIdentifier${identifier} +CFBundleName${name} +CFBundleDisplayName${name} +`; +} + +test('reads version, identifier and product name from Info.plist text', () => { + const identity = readMacosAppInfoIdentity( + plist({ + version: '0.1.154', + ...DEV_IDENTITY, + name: DEV_IDENTITY.productName, + }), + ); + assert.deepEqual(identity, { + version: '0.1.154', + identifier: DEV_IDENTITY.identifier, + name: DEV_IDENTITY.productName, + displayName: DEV_IDENTITY.productName, + }); +}); + +test('accepts the app bundle that matches this channel and version', () => { + assert.doesNotThrow(() => + assertMacosAppMatchesChannelIdentity({ + identity: readMacosAppInfoIdentity( + plist({ + version: '0.1.154', + identifier: DEV_IDENTITY.identifier, + name: DEV_IDENTITY.productName, + }), + ), + expectedVersion: '0.1.154', + expectedProductName: DEV_IDENTITY.productName, + expectedIdentifier: DEV_IDENTITY.identifier, + }), + ); +}); + +// 回归:线上 dev-mac/0.1.142 清单实际指向 0.1.139 的 release 身份包。 +test('rejects the release-identity bundle that shipped in the dev-mac channel', () => { + const shipped = readMacosAppInfoIdentity( + plist({ + version: '0.1.139', + identifier: 'world.genarrative.ai-game-creator.release', + name: '陶泥儿 Release', + }), + ); + assert.throws( + () => + assertMacosAppMatchesChannelIdentity({ + identity: shipped, + expectedVersion: '0.1.142', + expectedProductName: DEV_IDENTITY.productName, + expectedIdentifier: DEV_IDENTITY.identifier, + }), + /版本不一致:产物 0\.1\.139,本轮清单 0\.1\.142[\s\S]*bundle identifier 不一致[\s\S]*产品名不一致/u, + ); +}); + +test('rejects a bundle whose version is right but channel identity is wrong', () => { + assert.throws( + () => + assertMacosAppMatchesChannelIdentity({ + identity: readMacosAppInfoIdentity( + plist({ + version: '0.1.154', + identifier: 'world.genarrative.ai-game-creator.release', + name: '陶泥儿 Release', + }), + ), + expectedVersion: '0.1.154', + expectedProductName: DEV_IDENTITY.productName, + expectedIdentifier: DEV_IDENTITY.identifier, + }), + /bundle identifier 不一致/u, + ); +}); + +test('lists every stale mac artifact so the bundle directory cannot leak into the manifest', () => { + const files = [ + '/bundle/macos/陶泥儿 Release.app.tar.gz', + '/bundle/macos/陶泥儿 Release.app.tar.gz.sig', + '/bundle/macos/陶泥儿开发版_0.1.139_aarch64.dmg', + '/bundle/macos/陶泥儿开发版_0.1.139_aarch64.dmg.sha256', + '/bundle/macos/陶泥儿开发版.app/Contents/Info.plist', + ]; + assert.deepEqual(listStaleMacosArtifacts(files), files.slice(0, 4)); +}); + +test('rejects a manifest that selected a different artifact than this build wrote', () => { + assert.throws( + () => + assertManifestArtifactMatchesExpected({ + artifactPath: '/bundle/macos/陶泥儿 Release.app.tar.gz', + expectedPath: '/bundle/macos/陶泥儿开发版.app.tar.gz', + }), + /清单选中的更新包不是本轮产物/u, + ); + assert.doesNotThrow(() => + assertManifestArtifactMatchesExpected({ + artifactPath: '/bundle/macos/陶泥儿开发版.app.tar.gz', + expectedPath: '/bundle/macos/陶泥儿开发版.app.tar.gz', + }), + ); +}); diff --git a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs index 5f4cee92c..6dd90eab8 100644 --- a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs +++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs @@ -154,13 +154,27 @@ test('macOS release entry verifies the updater signature before uploading', () = !entry.includes("'--no-sign'"), '--no-sign 会同时跳过 updater 签名,产物缺少 .sig', ); - // workspace 会跨构建保留产物:必须先删本次要写的对象,否则会因同名 DMG 失败, - // 或让上一轮遗留的 .sig 让验签门禁误通过。 + // workspace 会跨构建保留产物:必须在构建前清掉构建目录里所有更新包/签名/DMG—— + // 只删「本轮要写的名字」会漏掉其它渠道身份的残留(2026-09-28 线上 dev-mac/0.1.142 + // 就是被 0.1.139 的 release 身份 *.app.tar.gz 顶掉的),清理必须按后缀全覆盖。 + const cleanupIndex = entry.indexOf( + 'listStaleMacosArtifacts(existingBundleFiles)', + ); + const buildIndex = entry.indexOf('runTauriBuild(args, context)'); + const identityIndex = entry.indexOf('assertMacosAppMatchesChannelIdentity({'); + const manifestArtifactIndex = entry.indexOf( + 'assertManifestArtifactMatchesExpected({', + ); + assert.ok(cleanupIndex > 0, '必须清理构建目录里的残留产物'); + assert.ok(cleanupIndex < buildIndex, '清理必须发生在构建之前'); + assert.ok(identityIndex > buildIndex, '构建之后必须核对产物身份'); + assert.ok(identityIndex < verifyIndex, '身份核对必须在验签与上传之前'); + assert.ok( + manifestArtifactIndex > 0 && manifestArtifactIndex < uploadIndex, + '必须在清单生成后核对它选中的就是本轮更新包', + ); for (const required of [ - // 清理对象用派生的产品名算出来,而不是写死某个名字。 - '${updaterArtifactName}.sig', - '${firstInstallName}.sha256', - 'fs.rmSync(stale, { force: true })', + 'fs.rmSync(stale, { recursive: true, force: true })', "'-ov'", ]) { assert.ok(entry.includes(required), required); diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index cd69f8180..892ec4600 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -115,6 +115,10 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display()); } fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败"); + // 这份目录是随包资源(Windows:`resources/codex/win-x64/**` → `coding-agent/win-x64/**`), + // 只能包含本轮布局声明的组件。上一版布局留下的旧二进制(例如包根目录那份 0.147.0 + // `codex.exe`)会长期留在原地:既误导本地核对与夹具,也让「随包内容」与清单不一致。 + prune_stale_codex_components(&target_dir, layout.files); let mut file_hashes = serde_json::Map::new(); for relative in layout.files { let source_path = source.join(relative); @@ -174,6 +178,42 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) { } } +/// 删除 `target_dir` 下不在本轮布局内的残留文件;空目录一并收掉。 +fn prune_stale_codex_components(target_dir: &std::path::Path, files: &[&str]) { + const ALWAYS_KEEP: &[&str] = &["manifest.json", "NOTICE.md"]; + fn walk(root: &std::path::Path, directory: &std::path::Path, files: &[&str], keep: &[&str]) { + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(kind) = entry.file_type() else { + continue; + }; + if kind.is_dir() { + walk(root, &path, files, keep); + if fs::read_dir(&path) + .map(|mut remaining| remaining.next().is_none()) + .unwrap_or(false) + { + let _ = fs::remove_dir(&path); + } + continue; + } + let Ok(relative) = path.strip_prefix(root) else { + continue; + }; + let relative = relative.to_string_lossy().replace('\\', "/"); + if files.contains(&relative.as_str()) || keep.contains(&relative.as_str()) { + continue; + } + eprintln!("cargo:warning=清理内置 Codex 组件残留:{relative}"); + let _ = fs::remove_file(&path); + } + } + walk(target_dir, target_dir, files, ALWAYS_KEEP); +} + fn seed_task_group_id( group: &shared_contracts::game_creation_app::GameCreationAppAgentGroup, ) -> &'static str { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs index d903e008b..dfb8f2f9b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs @@ -76,12 +76,20 @@ fn compact_history_images(value: &mut Value, remaining_bytes: &mut usize) { } } +/// 构造 `thread/inject_items` 载荷;**历史为空时返回 `None`**,调用方不得发空载荷。 +/// +/// codex app-server 0.155.1 起把 `items: []` 当协议错误拒绝(`items must not be empty`), +/// 而 CLI / 宿主探针路径(`--direct-codex-chat`)在全新项目上没有前端先写用户条目的步骤, +/// 于是「第一次对话」会直接失败并报「执行通道中断」。空历史本来就没有可注入的内容。 pub(super) fn build_direct_project_history_injection_params( history_root: &Path, thread_id: &str, -) -> Result { +) -> Result, platform_llm::LlmError> { let canonical_items = read_direct_project_history_items_at(history_root) .map_err(platform_llm::LlmError::InvalidRequest)?; + if canonical_items.is_empty() { + return Ok(None); + } let mut remaining_image_bytes = DIRECT_PROJECT_HISTORY_IMAGE_TOTAL_MAX_BYTES; let items = canonical_items .iter() @@ -100,13 +108,57 @@ pub(super) fn build_direct_project_history_injection_params( if let Some(error) = direct_project_history_injection_oversize_error(¶ms, payload_bytes) { return Err(platform_llm::LlmError::InvalidRequest(error)); } - Ok(params) + Ok(Some(params)) } #[cfg(test)] mod tests { - use super::{compact_history_images, DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT}; + use super::{ + build_direct_project_history_injection_params, compact_history_images, + DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT, + }; use serde_json::json; + use std::fs; + + // 回归:全新项目没有 `.agent/conversations/project.jsonl`,历史为空时**不得**构造 + // `items: []` 载荷——codex app-server 0.155.1 起会以 `items must not be empty` 拒绝, + // 表现为 CLI / 宿主探针的第一次对话直接「执行通道中断」。 + #[test] + fn empty_history_produces_no_injection_payload() { + let root = tempfile::tempdir().expect("temp dir"); + let params = build_direct_project_history_injection_params(root.path(), "thread-empty") + .expect("build params for empty history"); + assert!(params.is_none(), "空历史不得构造 thread/inject_items 载荷"); + } + + #[test] + fn existing_history_still_produces_an_injection_payload() { + let root = tempfile::tempdir().expect("temp dir"); + let conversations = root.path().join(".agent/conversations"); + fs::create_dir_all(&conversations).expect("create conversations dir"); + fs::write( + conversations.join("project.jsonl"), + concat!( + "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",", + "\"id\":\"codex-item-1\",\"content\":[{\"type\":\"input_text\",\"text\":\"先做一版\"}]}}\n" + ), + ) + .expect("write history"); + let params = build_direct_project_history_injection_params(root.path(), "thread-history") + .expect("build params for existing history") + .expect("有历史时必须构造载荷"); + assert_eq!( + params.get("threadId").and_then(|v| v.as_str()), + Some("thread-history") + ); + assert_eq!( + params + .get("items") + .and_then(|value| value.as_array()) + .map(Vec::len), + Some(1) + ); + } #[test] fn history_image_budget_omits_only_wire_preview_when_exhausted() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index f2058531c..e646e1055 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -3377,19 +3377,22 @@ impl CodexAppServerConnection { } if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if thread_created { - let params = - match build_direct_project_history_injection_params(history_root, &thread_id) { - Ok(params) => params, - Err(error) => { + match build_direct_project_history_injection_params(history_root, &thread_id) { + // 空历史没有可注入内容:app-server 会拒绝 `items: []`(items must not be empty), + // 新项目的第一次对话就发生在这里,因此必须跳过而不是发空载荷。 + Ok(None) => {} + Ok(Some(params)) => { + if let Err(error) = self.request("thread/inject_items", params).await { self.release_thread(snapshot, &thread_id).await; - return Err(error.into()); + return Err(DirectTurnRunFailure::from( + platform_llm::LlmError::Transport(error), + )); } - }; - if let Err(error) = self.request("thread/inject_items", params).await { - self.release_thread(snapshot, &thread_id).await; - return Err(DirectTurnRunFailure::from( - platform_llm::LlmError::Transport(error), - )); + } + Err(error) => { + self.release_thread(snapshot, &thread_id).await; + return Err(error.into()); + } } } } @@ -5205,6 +5208,11 @@ async fn shutdown_game_creator_codex_app_server_inner( inner: &Arc, reason: &str, ) -> Result { + // 关闭原因必须可观测:CLI / 单回合宿主会在回合收尾时主动关掉 app-server, + // 分不清「宿主收尾」和「通道自己断了」时,排查只能靠猜(见 pitfalls 2026-09-28)。 + if std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some() { + eprintln!("agent.direct_codex.shutdown reason={reason}"); + } inner.closed.store(true, Ordering::Release); release_client_mcp_connection_for_inner(inner); for (_, pending) in inner.pending.lock().await.drain() { diff --git a/docs/project-memory/plans/【证据矩阵】AGC游戏分发主规范逐条证据-2026-09-28.md b/docs/project-memory/plans/【证据矩阵】AGC游戏分发主规范逐条证据-2026-09-28.md new file mode 100644 index 000000000..cb527da7e --- /dev/null +++ b/docs/project-memory/plans/【证据矩阵】AGC游戏分发主规范逐条证据-2026-09-28.md @@ -0,0 +1,147 @@ +# 【证据矩阵】AGC 游戏分发主规范逐条证据 + +| 字段 | 值 | +| --- | --- | +| Source Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`「AGC 游戏分发与在线游玩合同」节(Version 0.2,Status proposed) | +| Milestone | `docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md` | +| Date | 2026-09-28 | +| 用途 | 阶段 D 第 6 条要求的「主规范逐条证据矩阵」:把主规范每个条款映射到可复核的证据,并把未取证项单独列出 | +| 结论 | **未上线**。主规范合同大体已取证,仍有 6 类未取证项(见文末),因此不得把本业务标记为上线完成 | + +## 怎么读这张表 + +- 结论只分三档:**已取证**(当天有真实运行记录或可复跑门禁)、**部分取证**(只覆盖条款的一部分,具体缺哪半写在证据里)、**未取证**(只有实现或计划,没有真实运行证据)。 +- 「证据」尽量给命令或脚本名;历史取证记录都指向里程碑文末对应的「本轮核对」小节,里面有耗时、状态码与接口回读值。 +- 本文件只归集证据,不授予阶段验收;阶段验收仍按里程碑的门禁(A 验收后才允许依赖它的阶段进入 accepted)。 +- 本文件是临时过程材料,位于 `docs/project-memory/plans/`;主规范被评审通过后,稳定事实回写主规范与决策记录,本文件按里程碑收尾规则删除。 + +## 1. 交付目标与范围 + +| 条款 | 证据 | 结论 | +| --- | --- | --- | +| 完整闭环:AGC 一键提交 / 网页上传 ZIP → 后端收真实包 → 校验与人工审核 → 发现/详情/游客在线游玩 → 更新与下架 | 网页侧全链路已取证(`check:game-distribution-web-publish-e2e` 12 PASS、`check:game-distribution-web-e2e`、`check:game-distribution-web-publish-recovery-e2e` 10 PASS、`check:game-distribution-media-e2e` 58 PASS,见里程碑「阶段 C 网页发布」「阶段 B 审核与在线旧版」);AGC 一键提交仍未在客户端取证 | 部分取证 | +| 验收必须用真实上传 / 真实存储 / 真实审核状态 / 隔离发行,静态演示卡片与 metadata-only 不能当证据 | 全部 E2E 都跑本地真实栈 + 真实 OSS(`agc-dev` bucket);`check-game-distribution-upload-safety` 里「只有 metadata 的提交必须失败」有断言;发行隔离用真实 Chromium 沙箱探针取证 | 已取证 | +| 首版只支持离线静态 Web 游戏(HTML/JS/CSS/JSON/图片/字体/音视频),不含 Godot/Cocos 源码、原生可执行、Wasm、服务端进程、外网 API、多人联机、云存档 | 发行包校验白名单与失败关闭有测试(`module-game-distribution`);沙箱 E2E 证明外站 fetch / WebSocket / Worker 被挡;范围本身是声明性条款,未做「禁止其它引擎」的主动拦截(也不需要) | 已取证 | +| 首版不做评分、评论、关注、榜单、推荐算法、交易与创作者收入 | 目录/详情 DTO 里没有这些字段(`check:game-distribution-dto-parity` 有响应键逐键比对);页面无假统计(阶段 C 第 9 条核对) | 已取证 | + +## 2. 入口与产品体验 + +| 条款 | 证据 | 结论 | +| --- | --- | --- | +| 网页 `/` → `/games`;桌面导航「游戏/创作/项目/我的」,移动「游戏/我的」;移动端不再用欢迎遮罩阻断游戏浏览 | `check:game-distribution-web-a11y-e2e` 11 PASS 覆盖桌面左侧栏与移动底部导航切换;移动端仍会弹既有的「创作工具请用电脑端」提示但可关闭后正常浏览游玩(同脚本有记录) | 已取证 | +| 目录 `/games`、详情 `/games/detail?id=`、游玩 `/games/play?id=`、作者管理 `/games/mine`;不建第二套账号系统 | 四条路由在 nginx SPA 白名单内(`npm run check:nginx-spa-routes` → OK,12 routes / 3 templates);`check-game-distribution-web-e2e` 走 `/games/detail` 深链与返回恢复;账号仍用平台登录态 | 已取证 | +| 目录推荐位只消费后台已公开游戏,其余按发布时间;支持关键词/分类/设备筛选、写 URL、返回恢复、真实空态 | `check-game-distribution-web-e2e`:关键词命中/空态+「重置筛选」、分类与设备切换、返回后 URL 恢复 `keyword/category/device`(顺带修掉 StrictMode 清空关键词的真实缺陷) | 已取证 | +| 详情主动作「立即玩」,展示封面/标题/作者/短简介/设备/截图/操作方式;不虚构评分与玩家数;游客浏览与启动无需登录 | 同脚本:游客直接进详情并启动发行包内容(iframe 文本含包内标记);目录游玩次数直接取服务端 `playCount`(新游戏显示 0 次),无假统计 | 已取证 | +| 游玩页先给封面+开始按钮,点击后才加载;提供加载/超时/重试/返回/全屏;iframe load 不等于运行验证 | 同脚本:进游玩页**先无 iframe**,点「开始游戏」才挂载 `sandbox="allow-scripts"` 的 iframe;退出后 iframe 消失;详情接口被打断后出现「重新加载」入口;全屏在 a11y 脚本里实点后 `document.fullscreenElement` 生效 | 已取证 | +| 移动端全宽/底部动作/≥44px 点击区/safe-area/横竖屏与键盘焦点;仅桌面游戏在手机显示「请在电脑上游玩」;横屏提示不能作为启动前提 | `check:game-distribution-web-a11y-e2e` 覆盖 safe-area `env(safe-area-inset-*)`、键盘焦点+Enter 走完目录→详情→游玩→开始、390×844 无横向溢出;旋转提示与桌面专属门槛由网页游玩脚本覆盖 | 已取证 | +| 视觉延续 warm token 与共享组件;发布打开独立弹窗/抽屉;技术信息只在失败诊断时展示 | 阶段 C 第 9 条核对:四个页面直接用 `PlatformActionButton`/`PlatformStatusMessage`/`PlatformEmptyState`,`gameDistribution.css` 只用 `var(--platform-*)`;AGC 发布面板不展示 ZIP 路径/文件数/体积属 AGC 侧条款,见未取证清单 | 部分取证 | + +## 3. 真实发行包与资料合同 + +| 条款 | 证据 | 结论 | +| --- | --- | --- | +| 1. AGC 取 `dist/` 归一化为根 `index.html`;网页 ZIP 同样要求根 `index.html`;不上传整个项目或源码快照 | 网页 ZIP 侧已取证(`check:game-distribution-web-publish-e2e` 真实选包提交);AGC `dist` 归一化有客户端实现与单元测试,但**没有**真实 AGC 一次提交的运行证据 | 部分取证 | +| 2. 运行依赖全部在包内,相对地址,不允许前导 `/assets`、外链与本地文件 URL;客户端给可操作错误、服务端独立校验、运行时 CSP 兜底 | 服务端校验有领域测试;运行时兜底由沙箱 E2E 的 CSP 拦截取证(外站 fetch/websocket/worker 都失败);客户端对前导 `/assets` 与绝对 URL 的可操作错误未单独取证 | 部分取证 | +| 3. 额度与拒绝集:包 100 MiB→现行 200 MiB、展开 250/500 MiB、单文件 64 MiB、10 000 文件、100:1 压缩比;拒绝加密 ZIP、路径冲突、绝对路径、`..`、符号链接、设备文件、嵌套压缩与 `.agent`/VCS/`node_modules`/凭据/源码映射 | `check:game-distribution-package-limits-e2e` 11 PASS(196 MiB 成功档耗时 36.9s/确认 9.6s、声明超限 413、文件数 422 `TooManyFiles`、单文件 `FileTooLarge`、展开量 `ExpandedPackageTooLarge`、压缩比 `CompressionRatioTooHigh`);`module-game-distribution` 16 passed 覆盖缺入口/越界/重复与大小写冲突/符号链接(含变异验证);加密条目与嵌套 `.zip` 有实现(`EncryptedFileNotAllowed` / `NestedArchiveNotAllowed`)但没有专门用例,**设备文件**没有专门拒绝实现(只区分常规文件与符号链接) | 已取证(两处子项见缺口) | +| 4. 声明 SHA-256 与字节数,服务端重算并建展开清单;摘要不符/缺文件/入口损坏必须失败 | `check-game-distribution-upload-safety` 24 PASS:结构合法但摘要不符 → 409 `PACKAGE_MISMATCH` → `upload_failed` → reset → 重传成功;领域侧 `accepts_root_entry_and_returns_file_manifest` | 已取证 | +| 5. 资料冻结与额度(标题 2–40 字、简介 ≤120、详介 ≤2000、1 分类、≤5 标签 ≤20 字、必需封面、≤6 截图、操作方式 ≤240、分类白名单);AGC 免手打 ZIP;发布灰度键 `game-distribution:publish` 与前端 `gameDistributionPublishEnabled`;续发复用冻结资料;公开投影只暴露对象键 | 资料校验与冻结由 `check-game-distribution-media-e2e`(真实素材直传 → 创建 → 送审 → 回读 frozenMetadata)与 DTO parity 覆盖;灰度键与前端下发在阶段 A/B/C 核对里取证(含 `skipAuth` 缺陷修复);AGC 免手打 ZIP 与面板免技术摘要未取证 | 部分取证 | +| 6. `supportedDevices` 至少含 desktop/mobile 之一;`inputModes` ∈ {keyboard,mouse,touch};声明移动端必须含 touch;`orientation` ∈ {landscape,portrait,responsive} | 服务端校验实现 + 领域测试;`check-game-distribution-media-e2e` 的创建资料走真实接口被接受/拒绝 | 已取证 | +| 7. 原始 ZIP、未审核展开目录、审核资料为私有对象;公开版本不暴露源码镜像键、本地路径、凭据;运行文件只能由发行网关按白名单读取,不能绕过网关直取 OSS | `platform-oss` 对象级 ACL 修复后 `E2E_REQUIRE_PRIVATE_BUCKET=1 npm run check:game-distribution-media-e2e` 65 PASS,匿名直取直传封面与发行包都是 403;目录/详情响应文本无 `aliyuncs.com`/`Signature=` | 已取证 | +| 8. 发行网关路径/内容类型白名单/未知扩展名 404/`nosniff`/CORP/CORS/HTML CSP/Cookie 403/进程内有界缓存 | `check-game-distribution-media-e2e`、`check-game-distribution-sandbox-e2e`(22 PASS,真实 Chromium)、本轮 `check:game-distribution-ops-rollback-e2e` 都断言响应头;带 Cookie 403 在沙箱脚本里取证 | 已取证 | +| 9. 发行入口由服务端按 gameId 派生同源路径 `/games/{gameId}/`,不接受管理员填写或部署侧配置;派生失败即失败,客户端按当前 origin 解析 | 阶段 B 核对:审核通过后 `currentVersion.entryUrl=/games//`;`check-game-distribution-web-e2e` 里 iframe src 就是同源 `/games//`;三份 nginx 模板的映射由本轮脚本断言 | 已取证 | + +## 4. 身份、状态、审核与更新 + +| 条款 | 证据 | 结论 | +| --- | --- | --- | +| `gameId` 服务端分配、`ownerUserId` 只从认证主体派生;AGC `localProjectId` 只是关联提示;两端共用同一游戏/版本/上传记录 | `check:game-distribution-owner-isolation` 21–23 PASS(伪造 owner 不生效、越权读写送审撤回全部拒绝);`localProjectId` 复用行为有客户端与领域测试,未在 AGC 真机取证 | 部分取证 | +| 相同作者相同 `localProjectId` 复用既有 `gameId` 只加版本;旧客户端缺该字段视为新建 | 领域/客户端实现 + 单元测试;未在真实 AGC 运行取证 | 部分取证 | +| 每次发行唯一 `versionId` + 递增 `versionNumber`;归属/摘要/已确认字节/送审资料冻结后不可变,改包或改资料必须新建版本 | 领域测试(`a_version_accepts_only_one_confirmed_package`、状态机用例)+ `check-game-distribution-upload-safety`:换 key 重复送审 409 且版本数仍为 1 | 已取证 | +| 游戏保存 `publicationRevision`/`activeVersionId`/可见性 `unpublished|published|suspended`;未过审 `activeVersionId` 为空;`suspended` 只能管理员操作 | 阶段 B 核对:CAS 过期批准/过期下架 409、下架后目录与网关不可见;本轮 ops 脚本实测安全下架/恢复只能由管理员做 | 已取证 | +| 版本状态机 `awaiting_upload → uploaded → validating → pending_review → published`,失败态与 `cancelled`/`revoked` | 领域测试 `version_state_machine_reaches_pending_review_only_after_package_validation`、`recovery_action_covers_every_version_status`;`check-game-distribution-validation-restart-e2e` 实测 `upload_failed`/`uploaded` | 已取证 | +| 首版人工审核:自动校验只进 `pending_review`,不自动公开;审核记录保存审核者/版本/结论/理由/时间;后台只授权管理员 | `check-game-distribution-media-e2e` 58 PASS 覆盖待审不可见、可批可拒、`reviewReason`/`reviewedAt` 可追溯、普通作者调审核接口 403 | 已取证 | +| 更新送审时旧 `activeVersionId` 继续服务;审核通过并核验对象可读后一次事务切换;上传/校验/审核/安装失败不改变旧版 | `check-game-distribution-media-e2e`:更新待审与被拒期间公开摘要与网关内容仍是旧版;本轮 ops 脚本:换版后旧版本保留为 `revoked` 记录 | 已取证 | +| 发布页自动填入标题与封面、复用上次资料;一次「提交发布」串联校验/上传/送审并显示真实阶段;等待审核不显示已发布;成功后给查看详情与复制链接 | `check:game-distribution-web-publish-e2e` 12 PASS(同账号看到待审、审核通过后广场出现、更新沿用同一 `gameId`);续发回填封面/截图未单独取证 | 部分取证 | + +## 5. 幂等、并发与恢复 + +| 条款 | 证据 | 结论 | +| --- | --- | --- | +| 所有创建/提交/审核/撤销/下架带 `Idempotency-Key`;同 key 同请求重放,不同请求 409 `IDEMPOTENCY_CONFLICT`;至少保留 30 天 | 领域幂等测试 + `check-game-distribution-upload-safety`(同 key 重放返回同一条、换请求体 409);30 天保留期由库内 `expires_at` 承载,未做时间旅行验证 | 部分取证 | +| 一个版本只能确认一份 ZIP;中断重传同 `versionId`/摘要;不同摘要 409;同版本第二个写入 409 `UPLOAD_IN_PROGRESS`;未确认半包不进校验 | `check-game-distribution-upload-safety` 24 PASS + `check-game-distribution-upload-resume` 两段式(杀进程后按权威偏移续传) | 已取证 | +| 重复操作不得创建第二个游戏或版本;原生端持久保存操作 ID/目标/key,网页保存恢复标识并以服务端回读为准;相同 ZIP 不同资料修订允许新版本 | 网页侧:`check-game-distribution-web-publish-recovery-e2e` 10 PASS(双击只落一份、关窗后继续同一 versionId);原生端 AGC 侧未取证 | 部分取证 | +| 公开版本切换/作者下架/管理员审核必须带 `expectedPublicationRevision` 并在事务内 CAS;并发 409 `PUBLICATION_CONFLICT`,旧送审不能静默覆盖 | `check-game-distribution-media-e2e` 58 PASS:过期批准、过期下架、并发批准一次 200 一次 409;下架后用过旧 revision 批准仍 409,游戏不复活 | 已取证 | +| 网络中断/响应丢失先查原操作;`validating` 在途任务按版本身份幂等续作;登录失效保留草稿,换账号不能接管 | `check-game-distribution-validation-restart-e2e` 11 PASS(杀 api-server 后重确认可恢复、失败可观测、公开版本不被误删)+ recovery 脚本的登录失效/换账号分支 | 已取证 | + +## 6. HTTP 与持久化边界 + +「已实现」路由逐行核对如下;证据列里的 `media`/`web`/`ops` 分别指 `check:game-distribution-media-e2e`、`check-game-distribution-web-*`、本轮新增的 `check:game-distribution-ops-rollback-e2e`。 + +| 路由 | 证据 | 结论 | +| --- | --- | --- | +| `GET /games` | media/web 脚本断言只出现公开游戏、待审与下架不可见;ops 脚本在开关关闭时仍 200 | 已取证 | +| `GET /games/{gameId}` | web 脚本深链详情;ops 脚本断言下架后 404 | 已取证 | +| `GET /game-distribution/releases/{gameId}[/{assetPath}]` | media/sandbox/ops 三个脚本断言内容、扩展名白名单、Cookie 403、未知扩展名 404 与响应头 | 已取证 | +| `GET /my/games` | web-publish 脚本回读 `pending_review`/`published`;ops 脚本在开关关闭时仍 200 | 已取证 | +| `POST /games` | owner-isolation + ops(开关关闭 503) | 已取证 | +| `POST /games/{gameId}/versions` | upload-safety/owner-isolation + ops(503) | 已取证 | +| `PUT /versions/{versionId}/package` | limits/upload-safety/upload-resume/validation-restart + ops(503) | 已取证 | +| `GET /versions/{versionId}` | owner-isolation(越权 404、未认证 401);ops 脚本用它断言 `upload_failed` 可回读 | 已取证 | +| `POST /versions/{versionId}/submit` | upload-safety(202 与 409 重放)+ ops(503) | 已取证 | +| `POST /versions/{versionId}/cancel` | owner-isolation(撤回成功)+ ops(503) | 已取证 | +| `POST /games/{gameId}/unpublish` | 阶段 B 核对:下架后目录/详情/网关关闭 + ops(503) | 已取证 | +| `GET /admin/api/game-distribution/reviews` | media(待审列表)+ ops(开关关闭仍 200,积压可查) | 已取证 | +| `POST /admin/api/game-distribution/versions/{versionId}/review` | media 58 PASS(批准需 CAS、拒绝需理由)+ ops(关闭时批准 503、拒绝仍 200) | 已取证 | +| `POST /admin/api/game-distribution/games/{gameId}/suspend` | 阶段 B + ops(关闭时仍可安全下架并恢复) | 已取证 | +| 错误 envelope 覆盖 400/401/403/404/409/413/422/429/5xx,不泄露存储凭据与本地路径 | 阶段 A 核对:成功与失败同一份 `{ok,data,error,meta}`,`meta.requestId`/`meta.operation` 两侧一致(顺带修掉错误 envelope 缺 requestId 的缺陷);limits 脚本实测 413/422;upload-safety 实测 409 | 已取证 | +| 领域规则进 `module-*`、事务进 `spacetime-module`、访问经 `spacetime-client`、HTTP 编排在 `api-server`、对象存储复用 `platform-*`、DTO 同步 `shared-contracts` 与 `packages/shared` | `check:game-distribution-dto-parity` 逐字段/逐变体比对 14 组 DTO 与手拼响应顶层键;`npm run lint` 里的 schema guard(85 表)与生成绑定校验通过 | 已取证 | + +## 7. 发行路径、沙箱与网络能力 + +| 条款 | 证据 | 结论 | +| --- | --- | --- | +| 发行入口是同源 `/games//`,边缘原样映射;隔离靠 `sandbox="allow-scripts"` 不透明来源,不依赖独立域名 | 三份 nginx 模板映射由 ops 脚本断言;`check:nginx-spa-routes` OK;沙箱 E2E 22 PASS 证明 storage/cookie/父文档 DOM 不可得 | 已取证 | +| 主站只接受服务端派生路径,拒绝任意 URL/重定向/`javascript:`/`srcdoc`;边缘清空 Cookie,网关对带 Cookie 请求 403;发行请求不接收平台 Bearer,日志不携带认证数据 | 沙箱脚本断言 Cookie 403;ops 脚本断言三份模板 `proxy_set_header Cookie ""` 且运行期日志不含访问令牌/刷新 Cookie/OSS signed URL | 已取证 | +| iframe 仅 `allow-scripts`;禁止 `allow-same-origin`、顶层导航、弹窗、表单、下载、模态、相机/麦克风/剪贴板/定位;不启用 Service Worker | 沙箱 E2E 22 PASS 逐条断言(含 Worker、popup、top navigation、敏感权限被拒) | 已取证 | +| 网关强制 HTML CSP(默认拒绝、无 `unsafe-eval`、`connect-src` 仅本游戏源、`worker/frame/object/form-action` 为 `none`、`base-uri 'none'`);包内 meta 不能放宽 | `release_asset_response` 只对 HTML 追加 CSP,ops 脚本断言 CSP 关键字且非 HTML 不带 CSP;沙箱脚本实测浏览器按 CSP 拦截外站请求 | 已取证 | +| 允许同包 JSON/二进制素材,禁止外部 API/分析/广告/第三方 SDK;为 opaque sandbox 提供不带凭据 CORS,且只对获准发行文件生效 | 沙箱脚本:同包 ES module 与素材可加载、外站 fetch/WebSocket 被挡;CORS 头只在发行网关响应上出现(`Access-Control-Allow-Origin: *` 仅由 `release_asset_response` 下发) | 已取证 | +| 固定 MIME + `nosniff`,未知扩展名不返回 HTML;静态校验与人工审核不能替代运行时隔离 | 白名单 `release_asset_content_type` + media/ops 脚本断言;沙箱脚本在真实浏览器里验证 | 已取证 | +| 真实浏览器验收要覆盖 npm/Vite 模块、Phaser 素材、音频与触屏,并确认外站请求被阻断 | 模块加载、素材、开始/重试/退出、全屏、移动视口与旋转已取证;**音频实际播放**与**真实触屏输入**未单独取证,AGC 侧真实游戏未取证 | 部分取证 | + +## 8. 下架、缓存与运维 + +| 条款 | 证据 | 结论 | +| --- | --- | --- | +| 下架或封禁后列表/详情/启动 API 立即停止返回;网关按游戏与版本拒绝新请求;原始对象不能绕过网关直取;封禁不能靠作者自行重发绕过,解除需管理员动作并重新审核 | 阶段 B 核对 + 本轮 ops 脚本(安全下架后详情与发行 404、恢复后重新服务);`platform-oss` 对象级 ACL 修复后匿名直取 403 | 已取证 | +| HTML/公开状态/启动 API 用 `no-store`;发行静态资源 TTL ≤60s,禁 `stale-while-revalidate`/`stale-if-error`/发行 Service Worker;下架主动 purge,60s 为最大撤销窗口,不把 purge 当唯一保障;已载入脚本不承诺远程抹除 | 本轮 ops 脚本断言 `Cache-Control: public, max-age=60, must-revalidate` 且与运维文档的 60 秒上限一致,并实测源站立刻拒绝新请求(不依赖 purge);`no-store` 与「无发行 Service Worker」未逐条取证,真实 CDN 未接入 | 部分取证 | +| 上线依赖:独立站点域名与通配 TLS、每游戏 host 路由、私有存储、CSP/CORS/MIME、CDN TTL/purge、审核运营入口、可恢复校验执行器;缺一不可上线 | 私有存储、CSP/CORS/MIME、审核运营入口、可恢复校验执行器都已取证;独立域名/通配证书已由 2026 决策用「同源路径 + 不透明来源沙箱」替代;CDN TTL/purge 与真实域名属部署后复核 | 部分取证 | +| 观察上传失败、校验耗时、审核积压、发行 4xx/5xx、撤销传播时间与容量;日志按游戏/版本/操作 ID 关联,不记录 Token、完整用户文件内容或 signed URL | 本轮 ops 脚本:运行期日志无访问令牌/刷新 Cookie/OSS signed URL,含本轮 gameId 与 `publish_switch_blocked`/`game_suspended` operation;阶段 D 发包核对有上传耗时与内存;审核积压有 `pending_versions`;**发行 4xx/5xx 与容量指标没有独立计数或告警规则** | 部分取证 | +| 原始失败/撤回包建议保留 7 天后清理,公开版本与审核记录保留周期上线前确定;清理必须先查引用,不能删除仍在服务的版本 | `check-game-distribution-validation-restart-e2e` 证明后续待审/失败不会误删在服务的公开版本;**没有任何清理任务实现**(仓库内无 game-distribution retention/cleanup),保留周期也仍在主规范「待评审决策」第 3 条 | 部分取证 | +| 回滚部署时关闭新提交与新版本激活,保留当前可玩版本与状态读取;数据库迁移不以删表回滚;安全事件靠服务端关闭发行权限,不依赖前端隐藏按钮 | 本轮 ops 脚本 40 PASS:关闭开关后 7 条写入(含管理员批准)503、读取与在游戏仍可用、拒绝审核与安全下架仍可用、恢复后换版成功;开关读取失败按关闭处理有实现与用例 | 已取证 | + +## 9. 验收标准与证据(主规范表逐行) + +| 主规范要求的证据 | 现状 | 结论 | +| --- | --- | --- | +| 真实包闭环:网页 ZIP 与 AGC dist 分别提交同一管道,服务端包 SHA-256/文件清单与发行回读一致;仅 metadata 请求被拒 | 网页 ZIP 侧已取证;AGC dist 侧只有实现与单元测试 | 部分取证 | +| 包与数据边界:缺入口、摘要不符、穿越、符号链接、压缩炸弹、超限、凭据路径及未授权对象均被拒且未公开 | `module-game-distribution` 16 passed + limits 11 PASS + upload-safety 24 PASS + owner-isolation | 已取证 | +| 权限与游客:匿名浏览/游玩成功,匿名写入 401,其他作者读写私有版本被拒,伪造 owner 无效 | owner-isolation 21–23 PASS + web 脚本游客游玩 | 已取证 | +| 幂等与恢复:双击、响应丢失、上传中断、同 key 不同内容、重启恢复、换账号迟到响应分别验证,不生成重复发行版本 | upload-safety / upload-resume / validation-restart / web-publish-recovery 四个脚本分别取证;换账号迟到响应由 recovery 脚本的账号切换分支覆盖 | 已取证 | +| 审核与并发:待审不公开;拒绝有理由;旧版在更新失败/待审期间在线;审核与下架并发 CAS 拒绝过期写入 | media 58 PASS 全覆盖 | 已取证 | +| 真正可玩:桌面与手机真实浏览器覆盖模块加载、素材、音频、触屏、横竖屏、开始/重试/退出与全屏;不以 iframe load 代替 | 模块/素材/开始/重试/退出/全屏/移动视口/旋转已取证;音频与真实触屏未单独取证,AGC 侧未取证 | 部分取证 | +| 隔离与撤销:真实生产构建下同源 iframe 内 Cookie/storage/DOM 不可访问、外网被 CSP 阻断、离开页面后代码不再运行、旧 URL 在缓存窗口后不能取得新资源 | 沙箱 22 PASS(前三项)+ ops 脚本(源站立刻拒绝新请求、TTL 上限 60s);真实生产构建与缓存窗口后的到期行为属部署后复核 | 部分取证 | +| 页面与视觉:warm token 下的目录/详情/发布/加载/空/失败/待审态,桌面与移动视口无遮挡,键盘可达 | a11y 11 PASS + web 脚本的筛选/空态/重试 + 阶段 C 第 9 条核对 | 已取证 | +| 工程门禁:定向前后端测试、两端类型检查、真实 SpacetimeDB/API smoke、schema/绑定检查、编码、文档索引与 diff 检查 | `npm run lint`(encoding / doc-index / npm-workspaces / git-hooks / rustfmt / schema guard 85 表 / generated-bindings / dto-parity / production-ops / preview-deployer / maintenance-page / eslint / typecheck)+ appSurface 192 passed + web/admin-web 构建;真实 smoke 由各 E2E 跑在真实 api-server 上 | 已取证 | + +## 未取证清单(上线前必须补齐) + +1. **AGC 客户端一次提交**:`dist` 归一化真实包、发布面板隐藏技术摘要、简介/分类免费生成、封面生成复用扣费链路;需要 AGC GUI 与真实 Provider(阶段 C 第 1–3 条)。 +2. **真实环境完整链路(阶段 D 第 1 条)**:生产域名/TLS、生产账号、真实 CDN、部署后的 systemd 巡检与告警,跑通「首次上传 → 校验 → 审核 → 公开 → 游客游玩 → 更新待审旧版在线 → 新版切换 → 下架撤销」。 +3. **CDN 与缓存到期的真实行为**:本地已证源站立刻拒绝新请求且 TTL 上限 60 秒;真实 CDN 接入后需复核 TTL ≤60s、下架 purge 与「缓存窗口过后旧 URL 取不到新资源」。 +4. **条款级小缺口**:加密条目与嵌套 `.zip` 缺专门用例、**设备文件缺拒绝实现**、客户端对前导 `/assets`/绝对 URL 的可操作错误、发布页续发回填封面与截图、`no-store` 与「无发行 Service Worker」的逐条断言、音频实际播放与真实触屏输入。 +5. **保留/清理任务**:失败与撤回包的 7 天保留、公开版本与审核记录保留周期、清理前的引用检查都还没有实现;保留周期本身仍是主规范待评审决策。 +6. **告警规则**:目前只有巡检 timer + 日志 operation,没有针对上传失败、校验耗时、审核积压、发行 4xx/5xx、撤销传播时间与容量的独立指标或告警规则。 + +## 与主规范待评审决策的关系 + +主规范「待评审决策」五条仍未关闭(人工审核策略、导航与根入口、首版范围与额度、同源路径替代独立域名、以及「未评审前不建立 ready 实施计划」)。本矩阵只提供证据,不代替评审;在评审把合同从 proposed 推进到 accepted 之前,本业务不得宣布上线,这也正是阶段 D 第 6 条要求的「有任何核心路径未验证时不标记上线完成」。 diff --git a/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md index 6a7391ff9..be6feca6d 100644 --- a/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md +++ b/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md @@ -36,10 +36,10 @@ ## 验收标准 -- [ ] `dev-mac` 渠道清单包含两个 macOS 平台条目且指向同一个 universal 安装包与签名,对象在 OSS 上一致可下载。 +- [ ] `dev-mac` 渠道清单指向真实可下载的 arm64 更新包,且清单版本、包内版本与包内渠道身份三者一致(原「两个 macOS 平台条目指向 universal 产物」口径已被 2026-09-21「macOS 只出 arm64 单架构」决策取代;2026-09-28 只读核对发现线上 `0.1.142` 清单指向的其实是 `0.1.139` 的 release 身份包,见文末本轮核对)。 - [ ] macOS 客户端能完成一次真实更新:检查、下载、安装、重启后运行新版本,且升级后产物仍是 universal 包。 - [ ] 覆盖写渠道 latest 指针后,旧版本 macOS 客户端可升级到新版本;Windows 与 macOS 渠道互不干扰。 -- [ ] 未签名或未公证产物在发布阶段失败关闭,或在不满足条件时明确记录为未验证项而非静默通过。 +- [x] 未签名或未公证产物在发布阶段失败关闭,或在不满足条件时明确记录为未验证项而非静默通过(更新包 minisign 签名在 `build-macos-ci.mjs` 上传前用产物内公钥强制复核,缺签名/验不过即中止;Apple 代码签名与公证当前是 `adhoc`,按本条的第二种方式记录为未验证项,首装需 Gatekeeper 手动放行)。 ## 证据要求 @@ -54,3 +54,18 @@ - 仍未验证(需要 macOS 节点):真实 macOS 构建与 DMG 产出、Apple 签名与公证、安装后重启接管新版本、`dev-mac` 渠道真实发布。 因此状态从 `in-progress` 改为 `implemented-awaiting-runtime-acceptance`:代码与脚本判据成立,缺的是 macOS 环境证据。 + +## 本轮核对(2026-09-28,线上 dev-mac 清单只读核对) + +**先纠正口径**:本文件第 1 条原来写的是「两个 macOS 平台条目指向同一个 universal 包」,但 2026-09-21 决策(decision-log)已经把 macOS 改回 **arm64 单架构**,清单只登记 `darwin-aarch64`——线上单架构本身不是问题。本轮真正查出来的是另一件事:**清单指的包根本不是这一版、也不是这个渠道身份**。 + +- `npm run check:agc-update-channel-manifests`(本轮新增的只读核对脚本,`AGC_UPDATE_VERIFY_DOWNLOAD=1` 会下载产物)对线上 `dev-mac/latest.json` 的核对结果: + - 清单自洽的部分(都 PASS):`version=0.1.142`、`pub_date=2026-09-24T11:35:31Z`、`commit=c07c10c0c`;`platforms` 只有 `darwin-aarch64`(符合 2026-09-21 单架构决策);更新包对象 315,510,533 字节与 `.sig` 420 字节都存在,清单签名与 `.sig` 文本一致;下载后字节数一致、用 `tauri.conf.json` 里烘焙的 updater 公钥验签通过(`alg=ED`、`keyId=cb883447e3e87c4e`);首装 DMG `陶泥儿开发版_0.1.142_aarch64.dmg` 存在。 + - **两处真 FAIL**:把更新包解开看 `Contents/Info.plist`,里面是 **`CFBundleShortVersionString=0.1.139`**(清单写的是 0.1.142)和 **`CFBundleIdentifier=world.genarrative.ai-game-creator.release` + `CFBundleName=陶泥儿 Release`**(本渠道应为 `world.genarrative.ai-game-creator` / `陶泥儿开发版`),产物文件名也叫「陶泥儿 Release.app.tar.gz」。 + - 结论:`dev-mac` 渠道当前给 arm64 客户端提供的更新包是**旧版本 + 另一个渠道身份**的包。这既让「升级后版本没变」成立,也可能把 release 身份的应用装到 dev 渠道用户机器上,违反渠道安装身份隔离约定。第 1 条保持未勾选。 + - **字节级佐证(2026-09-28 补)**:`dev-mac/0.1.142/陶泥儿 Release.app.tar.gz` 与 `release-mac/0.1.139/陶泥儿 Release.app.tar.gz` 的 sha256 完全相同(`1dfc9deb79f7fa5fd66d8a8a47b80e3a6ff125b8762cabfd32d3e16dec946e4e`)。也就是说 dev 分区里放的就是 release 渠道那一次构建的产物,不是「版本号抄错」。 + - 对照:`release-mac` 自己是对的(`0.1.139` + `world.genarrative.ai-game-creator.release` / `陶泥儿 Release`),`release-win`(0.1.150)与 `dev-win`(0.1.154)的产物名与签名核对也都通过。核对脚本现在还会断言「不同渠道的更新包不得字节相同」,修复前这条会精确报出 `dev-mac = release-mac`。 +- 根因与修复(本轮已落地,见 decision-log 2026-09-28 条目): + - 根因:`build-macos-ci.mjs` 之前只删「本轮要写的文件名」,构建目录里上一轮遗留的 `陶泥儿 Release.app.tar.gz` 不会被清掉;而 `generateUpdateManifest()` 是**扫描目录按优先级挑产物**,于是挑走了那个残留文件。 + - 修复:构建前按后缀清空 `macos/` 下的 `*.app.tar.gz`、`*.app.tar.gz.sig`、`*.dmg`、`*.dmg.sha256`;构建后读产物 `Contents/Info.plist` 断言版本与渠道身份;生成清单后再断言清单选中的就是本轮更新包。守卫逻辑在 `apps/ai-game-creator-shell/scripts/macos-release-identity.mjs`,单测 `macos-release-identity.test.mjs` 用线上那份 0.1.139 release 身份包做回归(`node --test` 59 passed,含新增 6 条)。 +- 仍未完成:需要用修好的 mac 管线重新发布一次 `dev-mac`(把 `latest.json` 指到本轮的新对象),然后重跑 `npm run check:agc-update-channel-manifests` 才能把第 1 条勾上。发布需要 Jenkins 凭据与授权,本地无法执行。 diff --git a/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md index 575ae0615..98ac5add8 100644 --- a/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md +++ b/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md @@ -35,9 +35,9 @@ - [x] 渠道清单版本来自统一总号;显式传入的号低于本渠道当前清单版本时构建失败关闭,另一个渠道清单不受影响(原「按渠道独立递增」口径已由 2026-09-20 总版本号方案取代)。 - [x] 渠道与目标平台不匹配、缺少签名私钥或私钥密码错误时发布失败关闭,不产生半成品清单。 -- [ ] 发布后 OSS 上安装包、签名与渠道清单三者一致:清单内地址指向已存在的对象,签名与安装包匹配。 -- [x] universal macOS 产物的两个平台键指向同一对象同一签名,不存在只挂单一架构键或指向不存在对象的情况。 -- [ ] Jenkins 归档与日志中不出现签名私钥内容,凭据只注入构建进程。 +- [x] 发布后 OSS 上安装包、签名与渠道清单三者一致:清单内地址指向已存在的对象,签名与安装包匹配(2026-09-28 只读核对已发布的 `dev-win` / `dev-mac` 渠道,两个渠道的产物都下载后验签通过,见文末「本轮核对」)。 +- [x] universal macOS 产物的两个平台键指向同一对象同一签名,不存在只挂单一架构键或指向不存在对象的情况。(清单构建器对 universal 目标仍按此契约工作;但 2026-09-21 决策已把 macOS 发行改成 arm64 单架构,线上 `dev-mac` 按该决策只登记 `darwin-aarch64`,这条的线上口径以 macOS 里程碑第 1 条为准。) +- [ ] Jenkins 归档与日志中不出现签名私钥内容,凭据只注入构建进程。(静态核对:`jenkins/Jenkinsfile.ai-game-creator-shell-build` 用 `withCredentials` 注入 `TAURI_SIGNING_PRIVATE_KEY` / `..._PASSWORD`,归档 glob 只含 `bundle/**/*.exe|*.sig|latest.json|legacy-latest.json|release-notes.txt` 与 `.jenkins-source-commit`,没有私钥文件;真实 Jenkins 运行的日志脱敏仍需一次 CI 构建取证。) - [x] 未显式指定渠道时按目标平台取默认渠道,且 `--no-bundle` smoke 路径仍不读远端版本、不改版本、不生成清单。 ## 证据要求 @@ -62,3 +62,17 @@ - 条目 6:同一批的 `no-bundle smoke skips version writes and manifest generation`、`release context resolves explicit targets before environment/default and fails closed`。 - 条目 1:原验收口径「按渠道独立递增」已被 2026-09-20 的总版本号方案取代——`build-release.mjs` 的 `prepareReleaseVersion()` 用统一总号,`resolveRemoteHighWaterVersion(channel, target)` 只做本渠道高水位断言(`assertRequestedVersionNotBelowChannel`);`node --test scripts/agc-global-version.test.mjs` → 8 passed,含「传入低于本渠道清单的号时失败关闭」「写后回读不一致失败关闭」「nextVersion 只在 patch 位递增」。本文件的范围与条目已按当前口径改写。 - 仍未勾选:条目 3(发布后 OSS 三者一致需要真实发布);条目 5(Jenkins 日志/归档不含私钥需要真实流水线证据)。 + +## 本轮核对(2026-09-28,已发布渠道清单只读核对) + +- 已勾选(条目 3:发布后 OSS 上安装包、签名与渠道清单三者一致)——本轮以**已发布**的 OSS 对象核对,不做任何上传 + - 新增 `scripts/check-agc-update-channel-manifests.mjs`(`npm run check:agc-update-channel-manifests`):只读拉取 `/agc/<渠道>/latest.json`,核对清单结构、平台键、地址前缀、安装包与 `.sig` 对象存在性、清单内签名与 `.sig` 文本一致,并在 `AGC_UPDATE_VERIFY_DOWNLOAD=1` 时下载产物、用产物里烘焙的 updater 公钥验签。 + - **`dev-win`(v0.1.154,`pub_date=2026-09-28T13:24:53Z`,commit `76cdb96c5`)**:清单 `windows-x86_64` 指向 `agc/dev-win/0.1.154/陶泥儿开发版_0.1.154_x64-setup.exe`(165,984,391 字节,HEAD 200);`.sig` 对象 436 字节且与清单内签名文本一致;下载后字节数与 HEAD 一致,`sha256=7867734e3991…` 与旧协议指针 `agc/latest.json` 的 `sha256`/`size` 完全一致;用公钥验签通过(`alg=ED`,`keyId=cb883447e3e87c4e`)。 + - **`dev-mac`(v0.1.142,`pub_date=2026-09-24T11:35:31Z`,commit `c07c10c0c`)**:清单 `darwin-aarch64` 指向 `agc/dev-mac/0.1.142/陶泥儿 Release.app.tar.gz`(315,510,533 字节,HEAD 200);`.sig` 对象 420 字节且与清单签名一致;下载后字节数一致、用同一公钥验签通过。 + - 结论:两个渠道的「安装包 ↔ 签名 ↔ 渠道清单」三者一致成立,条目 3 关闭。 + - **统一总号(条目 1 的线上口径)**:同一脚本还会读 `agc/global-version.json`,断言「任何渠道清单版本都不高于统一总号」。本轮实测总号 `0.1.155`(`updatedAt=2026-09-28T14:11:19Z`、`channel=dev-win`、`buildId=95`),四个渠道清单都不高于它(dev-win 0.1.154 / release-win 0.1.150 / dev-mac 0.1.142 / release-mac 0.1.139);渠道低于总号是正常滞后(dev-win 那次取号后构建可能仍在跑),脚本按 INFO 提示而不是判失败,只有「渠道高于总号」才失败关闭。 + - **同一轮稍后**:`dev-win` 那次构建(0.1.155,commit `3702e0f8e`)在 `14:26:13` 发布完成,`global-version.json` 与 `dev-win/latest.json` 因此都停在 `0.1.155`,旧协议指针 `agc/latest.json` 的 `sha256`/`size` 同步换到新包——「取号 → 构建 → 发布 → 指针」这条链路在一次真实 CI 运行里完整走通。 + - **Windows 侧产物身份也能核对了**:本轮新增 `scripts/pe-version-info.mjs`(纯 Node 解析 PE `RT_VERSION`),核对脚本在下载 Windows 安装包后会断言 `ProductName` 与 `FileVersion`:`dev-win` 0.1.155 → `陶泥儿开发版` / `0.1.155`,`release-win` 0.1.150 → `陶泥儿 Release` / `0.1.150`,两边都 PASS。只核对文件名等于相信文件没被换过,PE 资源是产物自己写的。 + - **注意(未验证项)**:`0.1.155` 这次构建的源码是 `3702e0f8e`,早于本轮给共享产物选择器加的 `assertArtifactVersionMatches()`(`c6ea0f0e3`),所以那条守卫还没有经过一次真实 Windows 构建;下一次渠道构建才能验证它在 CI 里不误伤。 + - **顺带发现的真实缺陷(已在 macOS 里程碑与 decision-log 记录)**:线上 `dev-mac/0.1.142` 清单只登记 `darwin-aarch64` 是符合 2026-09-21 单架构决策的;但它指向的更新包解出来是 **0.1.139 的 release 身份包**(`world.genarrative.ai-game-creator.release` / 陶泥儿 Release)。这一条本条验收没覆盖(本条只看「地址存在 + 签名匹配」),本轮已把「包内版本与渠道身份」加进 `check:agc-update-channel-manifests`,并在 mac 构建入口补上构建期身份断言。 +- 条目 5(Jenkins 归档与日志不含私钥)仍是未勾选:静态可证部分已记录在验收行上(`withCredentials` 注入 + 归档 glob 不含私钥文件),真实 Jenkins 运行日志需要一次 CI 构建取证。 diff --git a/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md b/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md index 167dadebb..62e348c7e 100644 --- a/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md +++ b/docs/project-memory/plans/【里程碑】AGC渠道安装身份隔离-2026-09-21.md @@ -47,3 +47,16 @@ - `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/release-oss.test.mjs apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs`:**60 passed / 0 failed**,含「release / beta 的 Windows 与 macOS 构建只写自己渠道分区、不能写 dev 桥」四条分区隔离用例。 - 仍未执行:真实渠道打包(需要签名私钥与发号/上传授权)、双渠道真机并存安装、macOS 构建节点实跑。 + +## 本轮核对(2026-09-28,已发布产物的渠道身份只读核对) + +发布产物已经在 OSS 上,可以用只读脚本核对「渠道身份是否真的落进产物」,不用等到真机安装。`npm run check:agc-update-channel-manifests`(`AGC_UPDATE_CHANNELS=dev-win,dev-mac,release-win,release-mac AGC_UPDATE_VERIFY_DOWNLOAD=1`)结果: + +- `dev-win`(0.1.154,commit `76cdb96c5`):文件名 `陶泥儿开发版_0.1.154_x64-setup.exe`、对象存在、`.sig` 与清单一致、公钥验签通过 → 默认渠道身份不变。 +- `release-win`(0.1.150,commit `5c648ab9c7`):文件名 `陶泥儿 Release_0.1.150_x64-setup.exe`、签名与摘要核对通过 → 非默认渠道用独立产品名。 +- **Windows 产物内部身份(本轮新增能力)**:`scripts/pe-version-info.mjs` 直接解析安装包的 PE `RT_VERSION`,核对脚本据此断言内部字段——`dev-win` 0.1.155 的 `ProductName=陶泥儿开发版`、`FileVersion=0.1.155`;`release-win` 0.1.150 的 `ProductName=陶泥儿 Release`、`FileVersion=0.1.150`。两侧都 PASS:**两个渠道的产物内部身份确实不同**,同机并存所需的「安装目录/卸载项/AppData 按身份分开」前提成立(这条以前只有文件名与源码级证据)。 +- `release-mac`(0.1.139,commit `87e52860a7`):清单只登记 `darwin-aarch64`(符合 2026-09-21 决策);解出更新包内 `Info.plist` 是 `world.genarrative.ai-game-creator.release` / `陶泥儿 Release` / `0.1.139`,与清单版本、渠道身份一致。 +- **`dev-mac` 有真实缺陷**:清单 `0.1.142` 指向的更新包解出来是 `world.genarrative.ai-game-creator.release` / `陶泥儿 Release` / `0.1.139`,而且与 `release-mac/0.1.139` 的产物**字节完全相同**(`sha256=1dfc9deb79f7…`)。也就是说 dev 分区里放的其实是 release 渠道那份包,渠道隔离在这一次发布上被破坏(签名字段都是真的,只有身份和版本错了)。根因是 mac 构建目录残留产物被清单扫描挑中,已在 2026-09-28 修复(构建前清空同类产物 + 构建后断言包内版本与渠道身份),需要用修好的 mac 管线重发一次 `dev-mac`。 +- 新增的跨渠道断言:同一次核对里不同渠道的更新包不得字节相同(`1dfc9deb79f7: dev-mac/darwin-aarch64 = release-mac/darwin-aarch64` 就是修复前会命中的形态)。 +- 新增的分区断言:旧协议迁移指针 `agc/latest.json` 是**全局单对象**,只属于 dev 的 Windows 系统——核对 `release-win` 时脚本会断言它仍指向 `/agc/dev-win/…`。本轮实测 `release-win` 通过:指针指向 `agc/dev-win/0.1.155/陶泥儿开发版_0.1.155_x64-setup.exe`,没有被 release 渠道改写。 +- 仍未勾选:真机上同时安装 `dev` 与 `release`、各自原地更新。上面这些只证明「产物身份正确」,不代替真机并存验收。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index f20e8da83..a0a2bd692 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -73,3 +73,12 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - 运行时(待补):真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。 - 边界:订阅过期、回执竞态、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。 - 环境注意:`rehype-highlight` 已装齐后 `ChatMarkdownMessage` / `AgentMessageContent` 转绿;Node 26 下 vitest 的 jsdom 用例需要 `--localstorage-file` 才能拿到 `window.localStorage`(`clientApi.test.ts` / `chatPromptPolish.test.tsx`),已记入 `docs/project-memory/shared-memory/pitfalls.md`。 + +## 本轮核对(2026-09-28,真实 app-server 运行时:先把路修通) + +为了给两条运行时验收(杀进程重进、进程存活期间重进)建可复跑的证路,本轮先用既有夹具 `apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs`(真 AGC CLI → 真 app-server → loopback Responses fixture,无账号、无付费 Provider)跑通基线,结果先挖出一个**真实缺陷**并修掉: + +- **缺陷**:全新项目的第一次对话直接失败。夹具 `--cases completed` 下 AGC 进程 4 秒退出、`requests=[]`,stderr 只有 `Codex app-server JSON-RPC 失败:items must not be empty`。根因是 `thread_created` 时无条件发 `thread/inject_items`,而空项目没有 `.agent/conversations/project.jsonl`,载荷成了 `items: []`;codex app-server 0.155.1 起把空数组当协议错误。GUI 路径没暴露是因为前端会先写用户消息,CLI / 无前端宿主是裸的。 +- **修复**:`build_direct_project_history_injection_params()` 在历史为空时返回 `Ok(None)`,调用方跳过注入;新增单测「空历史不得构造载荷」「有历史仍构造 1 条」。`cargo test … direct_project_history_wire`(3 passed)与 `cargo fmt --check` 通过。 +- **复验**:同一条命令下夹具从「4 秒失败」变成「走到 loopback Provider:`requests=3`、`markers.ready=true`、AGC exit=0」,说明第一层已经修通。 +- **仍未完成(第二层)——已定位到 Killer**:修好第一层后夹具的**全部 6 个用例**都走到 loopback Provider(`requests=3`、`markers.ready=true`、exit=0),但统一失败在账本阶段(`phase=interrupted`,期望 `completed`/`exhausted`;CLI 回执是「执行通道已断开,不能自动重放未确认操作」)。排查结论:① 不是 codex 版本漂移(换 0.147.0 相同);② 不是 stdin 被忽略(改 `pipe` 相同);③ 不是 `Drop for ExecutionBinding`(插桩后一次没打印);④ app-server 不是崩溃(1242 字节 stderr 全是 ProgramData/模型元数据/PowerShell snapshot 之类 WARN);⑤ **是宿主自己关的**——关闭原因日志显示 `reason=宿主执行预算或交付收尾`,来自 `codex_app_server/execution.rs:938` 的 `shutdown_and_report()`:它先置 `closed` 再关 app-server,而关进程会给在途回合通道发 `TransportClosed`,于是等待中的 CLI 回合拿到"连接断开"、账本落 `interrupted`。修复方向:宿主主动收尾时先交付回合结果、不向该回合发 `TransportClosed`(GUI 常驻 app-server,不走这条路径,影响面是 CLI / 单回合宿主与依赖它的夹具)。**两条运行时验收本质上要真实客户端窗口**(夹具只能证历史持久化,证不了「界面不显示忙碌态」),因此继续未勾选;除关闭原因日志(留在 `GENARRATIVE_AGC_DIRECT_DEBUG=1` 下)外,临时插桩已全部回滚。 diff --git a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md index 9337933fc..67cda202b 100644 --- a/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md +++ b/docs/project-memory/plans/【里程碑】游戏分发目录详情与在线游玩-2026-09-18.md @@ -27,7 +27,7 @@ - 阶段 A:真实 ZIP 上传、游戏身份、owner/幂等/CAS、状态机、DTO 与 schema 门禁已完成;真实 SpacetimeDB + 私有 OSS 的创建/上传/确认/重启恢复已有证据。**2026-09-28 更新**:阶段 A 的六条行为与验收已全部勾选(含跨账号越权、并发上传不混写、进程退出后按权威偏移续传、DTO/envelope 一致性),正式验收仍待 owner 评审。 - 阶段 B:人工审核(后台列表、通过需 HTTPS 入口、拒绝需理由)、公开投影、发行网关(按公开版本服务、扩展名白名单、`nosniff`/CORP/CSP、带 Cookie 403)与作者下架已实现;**2026-09-28 更新**:审核治理(待审可见、可批可拒、结论可追溯、作者不能提交审核)、「更新待审/被拒不改变在线旧版」、`publicationRevision` CAS(过期审核、并发激活、过期下架、陈旧审核不复活已下架游戏)与作者/管理员下架后目录/详情/发行读取全部关闭都已在本地真实栈取证(见文末阶段 B 核对);匿名直取发行包这一条已修:`platform-oss` 现在在内部 PUT / 分片追加 / 直传 policy 三处都下发对象级 ACL,严格模式下直传封面与发行包匿名直取都是 403(见 pitfalls 与 decision-log);opaque sandbox 的真实浏览器行为也已用 Chromium 探针包取证(22 项 PASS);每游戏独立来源已有可执行工件 `deploy/nginx/genarrative-release-origin.conf` 与门禁 `npm run check:release-origin-config`,并已在本机用真实 nginx + 真实网关验证按主机映射、Cookie 403 与平台命名空间 404;独立发行域名已由主规范 2026 决策用「平台同源路径 + sandbox 不透明来源」替代,不再是门禁;只剩「撤销传播符合最大缓存窗口」需要生产 CDN/TTL。 - 阶段 C:AGC 客户端「发布到平台」面板与发布链路(dist 归一化根 `index.html`、摘要/字节数声明、幂等键、`localProjectId` 复用)已实现并有请求组装与 Rust 导出测试;网页 `/games/publish` 走同一服务端管道,本地已用真实文件选择验证;AGC GUI 自身的端到端发布仍待客户端环境验收。目录(关键词/分类/设备筛选、滚动与筛选恢复)、详情、游玩页(主动作后加载、超时重试、旋转提示、全屏、移动端门槛)与作者中心(状态、驳回理由、撤回、下架)已实现;本地已在桌面与 `390x844` 移动视口真实游玩。 -- 阶段 D:容量/额度、重启恢复、CDN 撤销与回滚演练尚未开始,依赖生产资源。 +- 阶段 D:**2026-09-28 更新**:200 MiB 上限档与发包边界、真实栈两段式重启恢复(含失败可观测与公开版本不被误删)、发布开关回滚窗口与发行网关缓存/响应头/日志脱敏、主规范逐条证据矩阵都已完成;真实环境完整链路(D1)仍未完成,生产 systemd 巡检、独立告警规则与 CDN 只能部署后复核。 细节与命令级证据见[实施计划【游戏分发阶段A领域合同】](【实施计划】游戏分发阶段A领域合同-2026-09-19.md)的「已完成证据」「运行时证据」「尚未完成」。 @@ -76,7 +76,7 @@ - [x] 游客目录、详情和启动接口只返回已公开投影;未公开和已下架状态均不可见,不返回私有快照地址。 - [x] 发行隔离与主规范一致:iframe `sandbox="allow-scripts"`、网关 CSP/CORS/MIME/禁止 Worker 都按现行口径(平台同源路径 `/games//` 的不透明来源方案,主规范 2026 决策已用该方案替代独立发行域名)。 - [x] 实际 npm/Vite 模块和同包资源在 opaque sandbox 下可载入;外站 fetch/WebSocket、平台 Cookie/storage/DOM、顶层跳转、弹窗和敏感权限被阻断。 -- [ ] 作者下架及管理员安全下架会关闭新启动和发行读取;不能绕过网关直取公开 OSS 对象;撤销传播符合最大缓存窗口。 +- [x] 作者下架及管理员安全下架会关闭新启动和发行读取;不能绕过网关直取公开 OSS 对象;撤销传播符合最大缓存窗口(2026-09-28 补齐:对象级 ACL 让匿名直取 403;`check:game-distribution-ops-rollback-e2e` 实测下架后源站立刻 404、缓存窗口由 `Cache-Control: public, max-age=60, must-revalidate` 声明为 60 秒,见文末阶段 D 核对;真实 CDN 接入后的到期行为仍属阶段 D 第 1 条)。 ### 证据要求 @@ -121,10 +121,10 @@ - [ ] 真实环境中完整跑通“首次上传 → 校验 → 审核 → 公开 → 游客游玩 → 更新待审旧版在线 → 新版切换 → 下架撤销”。 - [x] 200 MiB 包(现行上限,见 2026-09-23 决策记录)与获批文件数/展开量边界有可复核耗时、内存和失败证据;校验不会执行上传代码,服务资源有界(2026-09-28 在本地真实栈按新上限复跑,见文末阶段 D 核对)。 -- [ ] 校验执行器重启可恢复,审核积压与失败可观测,清理不删除仍被公开版本引用的文件。 -- [ ] CDN purge 失败时仍在获批缓存 TTL 内拒绝新资源;明确已下载脚本无法远程抹除的边界。 -- [ ] 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;部署路由、缓存、响应头、日志脱敏和告警完成检查。 -- [ ] 主规范逐条证据矩阵齐全,未验证项明确列出;有任何核心路径未验证时不标记上线完成。 +- [x] 校验执行器重启可恢复,审核积压与失败可观测,清理不删除仍被公开版本引用的文件(2026-09-28 在本地真实栈按两段式重启复跑,见文末阶段 D 核对)。 +- [x] CDN purge 失败时仍在获批缓存 TTL 内拒绝新资源;明确已下载脚本无法远程抹除的边界(2026-09-28 在本地真实栈取证源站拒绝与 60 秒 TTL 上限,见文末阶段 D 核对;真实 CDN 未接入,purge 场景本身属部署后复核)。 +- [x] 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活;部署路由、缓存、响应头、日志脱敏和告警完成检查(2026-09-28 在本地真实栈取证发布开关、路由模板、响应头与日志脱敏,见文末阶段 D 核对;告警目前只有巡检 timer + 日志 operation,独立指标/告警规则未建,真实 systemd 巡检运行属部署后复核)。 +- [x] 主规范逐条证据矩阵齐全,未验证项明确列出;有任何核心路径未验证时不标记上线完成(2026-09-28 产出 [`【证据矩阵】AGC游戏分发主规范逐条证据-2026-09-28.md`](【证据矩阵】AGC游戏分发主规范逐条证据-2026-09-28.md),逐条给证据与结论,并把 6 类未取证项单独列出;结论仍是未上线)。 ### 证据要求 @@ -232,4 +232,38 @@ - **展开量**:9 × 60 MiB = 540 MiB(每 6 字节塞 1 个随机字节,整包约 145 MiB、仍在 200 MiB 内)返回 422 / `ExpandedPackageTooLarge`。 - **压缩比**:10 MiB 全零内容压成 10,556 字节(>100:1)返回 422 / `CompressionRatioTooHigh`。 - **校验不执行上传代码**:校验实现 `module-game-distribution` 的 `package.rs` / `release.rs` 里检索 `Command::new` / `std::process::Command` / `spawn(` 无命中;包内 `index.html` 里那行会打接口的脚本在整个校验过程中没有被执行。 - - 边界说明:失败用例都停在「上传校验」这一步(单发 PUT 会立刻回读校验),版本落到 `upload_failed` 且不会留下半包对象;成功档的 196 MiB 对象会留在 dev bucket 里,属于可丢弃的测试产物。 \ No newline at end of file + - 边界说明:失败用例都停在「上传校验」这一步(单发 PUT 会立刻回读校验),版本落到 `upload_failed` 且不会留下半包对象;成功档的 196 MiB 对象会留在 dev bucket 里,属于可丢弃的测试产物。 + +## 本轮核对(2026-09-28,阶段 D 重启恢复与失败可观测) + +- 已勾选(阶段 D 第 3 条:校验执行器重启可恢复、审核积压与失败可观测、清理不删除仍被公开版本引用的文件)——本轮补齐 + - 新增 `scripts/check-game-distribution-validation-restart-e2e.mjs`(`npm run check:game-distribution-validation-restart-e2e`,本地真实栈 + 真实 OSS,两段式 `E2E_STAGE=prepare|resume`,**11 项 PASS**)。跨进程状态写在 `E2E_STATE_FILE`,api-server 的重启由调用方在 prepare 与 resume 之间完成。 + - **prepare**:管理员登录 → 注册作者 → 建游戏 → v1(1 MiB,包内标记 `V1-OK`)上传、送审、审核公开 → v2 整包 **205,521,777 字节(196 MiB)** PUT 成功 → 立刻发出确认(`POST …/package/complete`)并在同一步杀掉 api-server。确认请求以 `TypeError: fetch failed` 结束,说明进程退出时这次校验不会被写成 `uploaded` 静默成功。 + - **重启**:`npm run dev:api-server`(SpacetimeDB 复用本地 `xushi-p4wfr`,未重启数据库)。 + - **resume(可恢复)**:作者重登 200 → `upload-state.receivedBytes=205,521,777`,与杀进程前一致(暂存对象没因进程退出丢失)→ 用新幂等键重新确认得 200 且版本落 `uploaded` → 送审 202。 + - **审核积压可观测**:`/admin/api/game-distribution/reviews` 的待审队列能查到该版本(本次 `pending=18`,队列里本就有其它积压);`/admin/api/game-distribution/games` 能看到同一游戏的两个版本(`versions=2`)。 + - **失败可观测**:再传一个结构损坏的包返回 **422 `PACKAGE_VALIDATION_FAILED` + `reason=InvalidArchive`**(作者当场可诊断);同一版本回读落到 `upload_failed`;后台游戏列表出现 `statuses=upload_failed,pending_review,published`。失败原因同时落库:`spacetime sql --server http://127.0.0.1:3101 xushi-p4wfr "SELECT version_id, status, last_error_code FROM game_distribution_version WHERE status = 'upload_failed'"` 返回 `(some = "PACKAGE_VALIDATION_FAILED")`。 + - **不误删公开版本**:v2 待审、v3 失败之后,`GET /api/game-distribution/releases//index.html` 仍 200 且正文仍含 `V1-OK`,公开版本内容没有被后续待审/失败版本清理掉。 + - **明确缺口(未当成已验收)**:版本回读 DTO 目前只暴露 `status`,不暴露 `last_error_code` / `last_error_message`;作者或运维事后回看只能看到 `upload_failed`,具体原因只有失败当次的 422 envelope 与库内字段。把它做进回读 DTO 需要改 `GameDistributionVersionSnapshot` / `GameDistributionAdminVersionSnapshot` 的 ABI 并重发模块与绑定,本轮未做,列为后续事项。 + +## 本轮核对(2026-09-28,阶段 D 发布回滚窗口与发行网关缓存) + +- 已勾选(阶段 D 第 4 条:撤销后新资源仍被拒绝且边界明确)——本轮按本地可证范围补齐 + - 新增 `scripts/check-game-distribution-ops-rollback-e2e.mjs`(`npm run check:game-distribution-ops-rollback-e2e`,本地真实栈 + 真实 OSS,**40 项 PASS**)。管理员安全下架后,`GET /api/game-distribution/games/` 与发行入口 `/api/game-distribution/releases//index.html` 对**新的**请求立刻返回 `404`,恢复后重新服务同一公开版本;因此“CDN purge 失败”不会让源站继续放行新资源,旧内容只可能留在声明过的边缘缓存窗口里。 + - 缓存窗口与文档一致:发行网关对所有发行资源下发 `Cache-Control: public, max-age=60, must-revalidate`,脚本同时断言运维文档里的 60 秒上限与“已下载脚本无法远程抹除”边界(`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`)。平台当前没有接 CDN,purge 场景本身留待部署后复核。 + - 响应头:HTML 下发收紧 CSP(`default-src 'none'` + `frame-src/object-src/base-uri 'none'`),CSS/JS 等非 HTML 不下发 CSP;两者都带 `X-Content-Type-Options: nosniff`、`Referrer-Policy: no-referrer`、`Cross-Origin-Resource-Policy: cross-origin` 与无凭据 `Access-Control-Allow-Origin: *`。 +- 已勾选(阶段 D 第 5 条:保留当前公开版本、关闭新提交与新版本激活,部署路由/缓存/响应头/日志脱敏/告警检查)——本轮补齐本地可执行部分 + - **关投稿、保在线**:`game-distribution:publish` 灰度关闭后,创建游戏、创建版本、上传包、送审、撤回、作者下架与管理员**批准**新版本全部 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`;同一窗口内目录、详情、`/my-games`、审核队列读取与发行入口继续可用,`publicationRevision` 与当前公开版本不变。拒绝审核与管理员安全下架 / 恢复始终可用(已在关闭窗口内实测,且恢复后仍服务原公开版本)。 + - **换版保留旧版本**:开关恢复后管理员激活新版本,发行入口改为新内容,旧公开版本在作者版本列表里保留为 `revoked` 记录(可追溯、对象未删)。 + - **部署路由**:脚本断言三份边缘模板(`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf`)都把 `^/games/(?game_[0-9a-f]{32})(?/.*)?$` 映射到发行网关并 `proxy_set_header Cookie ""`;`npm run check:nginx-spa-routes` → `OK (12 SPA routes, 3 Nginx templates)`。 + - **日志脱敏**:运行期 api-server 日志不出现访问令牌、刷新 Cookie 与 OSS signed URL 凭据(正向对照:同一份日志里能查到本轮 gameId 与 `publish_switch_blocked` / `game_suspended` 两个 operation);三份边缘模板的 `log_format` 不含 `$http_authorization` / `$http_cookie` / `$arg_`。 + - **变异验证**:临时把 `ensure_publish_enabled` 改成直接 `Ok(())` 后重跑,同一个脚本 **14 项转红**(7 条写入拦截 + 保在线/下架/恢复等下游断言),还原后重新全绿——说明这些断言确实在测发布开关,而不是恒真。 + - **告警**:`npm run check:production-health-patrol-env` → OK(巡检环境的 nginx / pingora-direct 两种模式变量口径);`npm run check:production-health-patrol` 这台 Windows 机器没有 `systemctl` 桩,巡检 harness 报 `FAILED`(预期退出 0、实际 2),属本地限制,真实 systemd 巡检运行留待服务器复核。 + +## 本轮核对(2026-09-28,阶段 D 主规范逐条证据矩阵) + +- 已勾选(阶段 D 第 6 条:主规范逐条证据矩阵齐全,未验证项明确列出,核心路径未验证时不标记上线完成) + - 新增 [`【证据矩阵】AGC游戏分发主规范逐条证据-2026-09-28.md`](【证据矩阵】AGC游戏分发主规范逐条证据-2026-09-28.md):把主规范「AGC 游戏分发与在线游玩合同」按节拆成 9 张表逐条映射证据——交付目标与范围 4 条、入口与产品体验 7 条、真实发行包与资料合同 9 条、身份/状态/审核/更新 7 条、幂等/并发/恢复 5 条、HTTP 边界 14 条路由 + envelope/分层 2 条、发行路径/沙箱/网络 6 条、下架/缓存/运维 5 条,以及主规范「验收标准与证据」表 9 行。 + - 每条只给三档结论(已取证 / 部分取证 / 未取证)并写清缺哪半,例如「网页 ZIP 已取证、AGC dist 未取证」「音频播放与真实触屏未单独取证」「加密条目与嵌套 `.zip` 有实现无专门用例、设备文件缺拒绝实现」。 + - 矩阵结论是**未上线**:未取证清单 6 类——AGC 客户端一次提交、真实环境完整链路(D1)、真实 CDN 与缓存到期行为、条款级小缺口、失败/撤回包保留清理任务(仓库内没有 game-distribution retention/cleanup 实现)、独立告警规则(目前只有巡检 timer + 日志 operation)。 + - 这张矩阵同时是 D1 的输入:D1 要求的生产域名/TLS、生产账号、真实 CDN 与部署后巡检,正是矩阵「未取证清单」第 1–3、6 项。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4a649649f..2adb0fe73 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9697,4 +9697,28 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(错误 envelope 与成功 envelope 同一份 meta):`AppError::into_response` 之前固定按「无请求上下文」构造错误响应,错误 envelope 里没有 `meta.requestId` / `meta.operation`,而成功 envelope 有,客户端在报错时拿不到可用于排查的 requestId。现在 `attach_request_context` 用 `tokio::task_local!` 的 `CURRENT_REQUEST_CONTEXT` 把上下文作用域套住整个 handler,错误转换读同一份上下文;脱离请求任务(单测、后台任务)时退回无上下文形状。取证:`check:game-distribution-owner-isolation` 新增成功/失败 envelope 两条断言,修复前 `requestId=` 为空失败、修复后 23 项 PASS。 - 决策(envelope 一致性按可消费取证):TS 侧没有 envelope 的类型镜像,只有 `packages/shared/src/http.ts` 与 `src/services/apiClient.ts` 的运行时守卫,因此这一条按「客户端能一致消费真实 envelope」取证(字段名 `ok` / `data` / `error.code` / `meta.apiVersion` / `meta.requestId`),不是类型级镜像;字段门禁比对的是名字而不是值类型。- 影响范围:`server-rs/crates/api-server/src/modules/game_distribution.rs`、`scripts/check-game-distribution-owner-isolation.mjs`、`scripts/check-game-distribution-upload-safety.mjs`、`scripts/check-game-distribution-upload-resume.mjs`、`package.json`、游戏分发里程碑取证。 - 决策(写入必须显式下发对象级 ACL):`platform-oss` 的 `OssObjectAccess` 之前只用于日志,对象继承 bucket 默认 ACL,公共读 bucket 上「private」对象可被匿名直取。现在内部 PUT、分片追加与直传 policy / 表单三处都下发 `x-oss-object-acl`(`Private` → `private`,`Public` → `public-read`);`OssAppendInternalObjectRequest` 新增 `access`,`DirectUploadTicketFormFields` 新增 `x-oss-object-acl`。验证:`cargo test -p platform-oss` 76 passed;`E2E_REQUIRE_PRIVATE_BUCKET=1` 的媒体链路 E2E 里直传封面与发行包对象匿名直取都 403(65 项 PASS)。- 验证:本地真实栈三个脚本全部 PASS(越权隔离 23 项、上传安全 24 项、分片续传 prepare 8 项 + resume 9 项、媒体链路 44 项);分片续传中途杀掉 api-server 进程(PID `53844` → 重启 `7728`)后仍从 `8,388,608` 偏移续传成功;`cargo test -p api-server -- package_` 6 passed 与 `game_distribution` 27 passed、`npm run lint`、`check:encoding`、`check:doc-index`、`git diff --check`。 -- 边界:证据来自本机 dev 栈与 dev bucket;生产域名、CDN 缓存窗口与真实客户端安装版的自动上传仍未验证。 \ No newline at end of file +- 边界:证据来自本机 dev 栈与 dev bucket;生产域名、CDN 缓存窗口与真实客户端安装版的自动上传仍未验证。 + +## 2026-09-28 macOS 渠道发布必须核对「包内版本 + 渠道身份」,不能只验签 + +- 背景:用只读核对脚本 `scripts/check-agc-update-channel-manifests.mjs` 检查**已发布**的 OSS 渠道清单时发现,线上 `dev-mac/latest.json`(`version=0.1.142`、`commit=c07c10c0c`)指向的更新包解开后是 `CFBundleShortVersionString=0.1.139`、`CFBundleIdentifier=world.genarrative.ai-game-creator.release`、`CFBundleName=陶泥儿 Release`。签名验签、对象存在、`.sig` 与清单文本一致这些都对——错的是**版本与渠道身份**:dev 渠道的 arm64 客户端会被指向一个旧版的 release 身份包。 +- 根因:`build-macos-ci.mjs` 以前只清理「本轮要写的确切文件名」,mac 构建目录里上一轮/其它渠道身份留下的 `*.app.tar.gz` 不会被删;`generateUpdateManifest()` 是按目录扫描 + 优先级选产物,于是选中了残留文件。mac 构建机复用 workspace,这类残留会长期存在。 +- 决策(构建期失败关闭):mac 发布入口在构建前按后缀清空 `macos/` 下的 `*.app.tar.gz`、`*.app.tar.gz.sig`、`*.dmg`、`*.dmg.sha256`;构建后读 `.app/Contents/Info.plist`,断言 `CFBundleShortVersionString` 等于本轮发布版本、`CFBundleIdentifier`/`CFBundleName` 等于该渠道安装身份;生成清单后再断言清单选中的更新包就是本轮那一个。任一不符直接中止,不写 OSS。 +- 决策(产物选择器本身也要挡旧版本):`generateUpdateManifest()` 是共享入口,Windows 侧靠 Jenkins 的 `git clean -fdx` 才没踩到同一个坑,所以再补一道与平台无关的守卫——`assertArtifactVersionMatches()` 要求文件名里出现形如 `_0.1.154_` 的版本段时必须等于本轮版本(残留的 `_0.1.153_` 安装包会被挑中并因此失败关闭);macOS 的 `<产品名>.app.tar.gz` 不含版本段,返回 `null`,由 mac 入口的身份断言负责。 +- 决策(只读核对也要看包内身份):`check:agc-update-channel-manifests` 在 `AGC_UPDATE_VERIFY_DOWNLOAD=1` 时下载 mac 更新包、解出 `Info.plist` 做同样断言;同时按 2026-09-21 决策断言 mac 渠道只登记 `darwin-aarch64`(不再要求 universal 双键)。 +- 决策(跨渠道产物不得同字节):同一次核对里若下载到多个渠道的更新包,脚本会断言它们的 sha256 两两不同——不同渠道的 `productName` / `identifier` 不同,产物就不可能相同。事故取证:`dev-mac/0.1.142` 的更新包与 `release-mac/0.1.139` 的更新包 sha256 都是 `1dfc9deb79f7fa5fd66d8a8a47b80e3a6ff125b8762cabfd32d3e16dec946e4e`,字节级证明 dev 分区放的是 release 渠道那次构建的产物。全量核对用 `AGC_UPDATE_CHANNELS=dev-win,dev-mac,release-win,release-mac AGC_UPDATE_VERIFY_DOWNLOAD=1`(约 950 MiB 下载)。 +- 决策(统一总号也要线上核对):同一脚本读 `agc/global-version.json`,断言「任何渠道清单版本都不高于统一总号」;渠道低于总号按 INFO 提示(可能正在构建),只有渠道高于总号才失败关闭。本轮线上:总号 `0.1.155`(2026-09-28T14:11:19Z 由 dev-win 取号),四个渠道清单分别为 0.1.154 / 0.1.150 / 0.1.142 / 0.1.139,均不高于总号。 +- 决策(Windows 侧也要能核产物内部身份):新增 `scripts/pe-version-info.mjs`(纯 Node 解析 PE `RT_VERSION`,无第三方依赖),核对脚本下载 Windows 安装包后断言 `ProductName` 与 `FileVersion` 等于本渠道身份与本轮版本。理由与 mac 侧一致:文件名可以改,PE 版本资源是产物自己写的;两侧对称以后「产物里的渠道身份」不再只靠源码级用例。实测 `dev-win` 0.1.155 → `陶泥儿开发版`/`0.1.155`,`release-win` 0.1.150 → `陶泥儿 Release`/`0.1.150`。 +- 事实(同日稍后):`dev-win` 的 0.1.155(commit `3702e0f8e`)在 14:26:13 发布完成,总号、渠道清单与旧协议指针三者对齐;该构建的源码早于 `assertArtifactVersionMatches()`,所以那条守卫仍待下一次真实渠道构建验证。 +- 决策(旧协议指针也要线上核对):`agc/latest.json` 是全局单对象,只属于 dev 的 Windows 系统;核对任何非 dev 的 Windows 渠道时,脚本断言它仍指向 `/agc/dev-win/…`。本轮 `release-win` 通过(指针指向 `dev-win/0.1.155` 的安装包),把「release 不能写 dev 桥」这条从单测扩到线上状态。 + +## 2026-09-28 DirectProject 空历史不再发 `thread/inject_items` + +- 背景:用 `direct-execution-production-fixture.mjs`(真 AGC CLI → 真 app-server → loopback Responses fixture)跑基线时,全新项目的第一次对话直接失败:`Codex app-server JSON-RPC 失败:items must not be empty`,AGC 进程 4 秒退出、loopback Provider 一次都没被调用。根因是 `thread_created` 时无条件发 `thread/inject_items`,空项目没有 `.agent/conversations/project.jsonl`,载荷成了 `items: []`;codex app-server 0.155.1 起拒绝空数组。GUI 路径因为前端先写用户消息才没暴露,CLI / 无前端宿主没有这一步。 +- 决策:`build_direct_project_history_injection_params()` 在历史为空时返回 `Ok(None)`,调用方跳过注入(空历史本来就没有可注入内容);有历史时行为不变(仍按原载荷、原大小前置校验)。 +- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_project_history_wire` → 3 passed(新增「空历史不得构造载荷」「有历史仍构造 1 条」);同一条夹具命令从「4 秒失败、`requests=[]`」变成「`requests=3`、`markers.ready=true`、AGC exit=0」——但账本仍落 `interrupted`(app-server 收尾连接终止,`stderrBytes=1242`),是另一层问题,未在本次一并修。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/{direct_project_history_wire.rs,mod.rs}`、`docs/project-memory/shared-memory/pitfalls.md`、DirectProject 里程碑。 +- 决策(口径回归):macOS 现行契约是 arm64 单架构(2026-09-21 决策),里程碑里「两个 macOS 平台键指向 universal 产物」的旧文字按现行决策改写;不得据此重新切回 universal,除非按该决策给出的恢复路径补齐按架构的 Node 运行时。 +- 影响范围:`apps/ai-game-creator-shell/scripts/build-macos-ci.mjs`、新增 `apps/ai-game-creator-shell/scripts/macos-release-identity.mjs` 与其 `.test.mjs`、`apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs`、`jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`、`scripts/check-agc-update-channel-manifests.mjs`、两份里程碑与本文件、pitfalls。 +- 验证:`node --test apps/ai-game-creator-shell/scripts/*.test.mjs` → **110 passed / 0 failed**(其中定向批次 `macos-release-identity / prepare-macos-codex / verify-updater-signature / build-release / cargo-features` 60 passed;新增的 mac 身份回归用例直接喂线上那份 0.1.139 release 身份 plist,必须抛错;新增的产物版本守卫用例喂 `_0.1.153_` 残留安装包,必须抛错);`npm run check:production-ops`、`check:encoding`、`check:doc-index`、prettier、eslint、`git diff --check` 通过;只读核对对线上 `dev-win` 全 PASS(含 158 MiB 产物下载验签与旧协议 sha256 一致),对线上 `dev-mac` 精确报出上面两条 FAIL。 +- 边界(未完成):修复只保证「以后再发不会再错」,线上 `dev-mac/latest.json` 仍指向那份坏包;需要一次带 Jenkins 凭据与授权的 mac 重新发布,然后重跑只读核对才算了结。Apple 代码签名与公证仍是 `adhoc`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b416e507f..b58a81190 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6083,3 +6083,42 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现象**:网页发布页飞快双击「提交审核」,服务端出现两份游戏(各带一个版本)。`isSubmitting` 是 React 状态,双击发生在同一次渲染窗口里时还没生效,再叠上 `prepareGamePackage` 的 ZIP 压缩耗时,两次点击各走一遍创建工作。 - **处理**:`GamePublishPage` 加同步 `submitInFlightRef` 在途守卫(进入提交前同步置位、`finally` 复位),并让同一次发布(含失败后重试)复用同一组 Idempotency-Key(`publishKeyRef`,成功后清空),服务端按幂等重放;`check:game-distribution-web-publish-recovery-e2e` 断言「双击只落一份游戏与一个版本」,`GamePublishPage.test.tsx` 15 passed。 - **教训**:任何「点一次会建资源」的入口都需要同步守卫或稳定幂等键,只靠 React 状态禁用按钮不够。 + +## 2026-09-28 两段式重启 E2E 的管理员账号由 api-server 启动环境决定,重启后换凭据会让管理员断言全红 + +- **现象**:`check:game-distribution-validation-restart-e2e` 的 prepare 段打印 `管理员登录成功 :: status=200`,杀掉 api-server 重启后,用同一组 `E2E_ADMIN_USER` / `E2E_ADMIN_PASSWORD` 跑 resume 变成 `status=401`,连带 `/admin/api/*` 的三条断言(待审队列、后台游戏列表、失败版本可见)一起 FAIL,看起来像重启把后台状态弄丢了;同一轮里作者登录、`upload-state`、重新确认、发行包读取全都正常。 +- **根因**:`.env.local` 里配置的是 `GENARRATIVE_ADMIN_USERNAME=admin` / `GENARRATIVE_ADMIN_PASSWORD=123456`,超管登录直接比对 `state.admin_runtime()`(即进程启动时的环境变量),不走库里的后台账号。上一次 api-server 若是被显式带上 `codex-admin / CodexLocal-4198!` 起起来的,重启回到 `.env.local` 后这组账号就会 401。 +- **做法**:两段式 E2E 的两次启动必须用同一组管理员凭据(本轮统一改成 `.env.local` 的 `admin / 123456`,实测 `admin` → 200、`codex-admin` → 401);遇到 401 先用 `Invoke-WebRequest http://127.0.0.1:8082/admin/api/login` 探一次凭据,再判断是否是产品回归。 +- **附注**:同一个 `E2E_STATE_FILE` 只能 resume 一次。resume 段会额外建一个坏包版本,第二次跑「后台只看到两个版本」这类断言会因为多出 `upload_failed` 版本而失败;需要重跑就重新执行 prepare。同理,prepare 依赖 `game-distribution:publish` 灰度对该作者开启,脚本自己会先 PUT 特征开关。 + +## 2026-09-28 发布开关回滚窗口要连管理员拒绝/安全下架一起验,否则会把“关投稿”做成“关后台” + +- **现象**:只按“关闭 `game-distribution:publish` 后写入口 503”写用例,会漏掉两个方向的问题——① 关闭窗口里管理员**批准**新版本也必须被拦住,否则回滚窗口形同虚设;② 拒绝审核与管理员安全下架 / 恢复在关闭窗口里必须仍然可用,否则运营在最需要救火时会发现自己也进不去。 +- **做法**:`scripts/check-game-distribution-ops-rollback-e2e.mjs` 把这两类方向写进同一条用例:7 条写入(含管理员批准)断言 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`,同时断言目录/详情/`/my-games`/审核队列/发行入口 200 且 `publicationRevision` 不变,并在关闭窗口内实测拒绝审核与管理员下架 → 恢复。改 `ensure_publish_enabled` 的调用位置(例如给下架也加上开关检查)会立刻打红。 +- **变异验证**:把 `ensure_publish_enabled` 临时改成直接 `Ok(())`,脚本 14 项转红;这类“开关语义”用例必须做一次变异验证,否则无法区分“开关生效”与“断言恒真”。 + +## 2026-09-28 `check:production-health-patrol` 在 Windows 本机必然 FAILED,别当成回归 + +- **现象**:本机跑 `npm run check:production-health-patrol` 输出 `[check:production-health-patrol] FAILED`,理由是「nginx gateway mode 巡检应成功。预期退出码 0,实际 2」,巡检 JSON 里 6 条 `service:*` 全是 `服务状态异常: spawn systemctl ENOENT`。同一轮的 `api:/healthz`、`bgfilter:/readyz`、`spacetimedb:/v1/ping`、`public:/` 都是 200。 +- **根因**:这个 harness 用桩 `systemctl` 驱动巡检脚本,Windows 上 `spawn systemctl` 直接 `ENOENT`,服务态检查不可能通过;它验证的是 Linux + systemd 的生产形态。 +- **做法**:本机只跑 `npm run check:production-health-patrol-env`(本轮 OK)确认巡检变量口径;`check:production-health-patrol` 留给服务器/CI 复核,别据此判定巡检脚本本身坏了。 + +## 2026-09-28 线上 dev-mac 的更新包是「上一版 + 另一个渠道身份」:清单扫描选中了构建目录残留产物 + +- **现象**:线上 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json`(`version=0.1.142`、`commit=c07c10c0c`)把更新包指向 `陶泥儿 Release.app.tar.gz`;下载解开看 `Contents/Info.plist`:`CFBundleShortVersionString=0.1.139`、`CFBundleIdentifier=world.genarrative.ai-game-creator.release`、`CFBundleName=陶泥儿 Release`。同一份清单的首装 DMG 却是 `陶泥儿开发版_0.1.142_aarch64.dmg`。也就是说签名是真的、对象也在,但**版本与渠道身份都是错的**。 +- **根因**:`build-macos-ci.mjs` 以前只 `rmSync` 「本轮要写的确切文件名」,构建目录里上一轮(或其它渠道身份)留下的 `*.app.tar.gz` 不会被清;而 `generateUpdateManifest()` 是**扫描构建目录、按优先级挑产物**(同名优先级再按字典序),于是挑走了残留的 release 身份包。mac 构建机是复用 workspace 的,这类残留会长期存在。 +- **判据**:只读核对要**打开产物看身份**,不能只看「地址存在 + 签名匹配」。`npm run check:agc-update-channel-manifests`(`AGC_UPDATE_VERIFY_DOWNLOAD=1`)现在会解出 mac 包的 `Info.plist`、以及 Windows 安装包的 PE `RT_VERSION`(`scripts/pe-version-info.mjs`),断言「产物内版本 == 清单版本」且「产物内产品名/identifier == 本渠道身份」,并额外断言**不同渠道的更新包不得字节相同**;本轮对线上取样得到三条 FAIL,正是这个缺陷。字节级佐证:`dev-mac/0.1.142/陶泥儿 Release.app.tar.gz` 与 `release-mac/0.1.139/陶泥儿 Release.app.tar.gz` 的 sha256 完全相同(`1dfc9deb79f7…`),说明 dev 分区里放的就是 release 那一次构建的产物。 +- **处理(2026-09-28 已修)**:构建前按后缀清空 `macos/` 下的 `*.app.tar.gz`、`*.app.tar.gz.sig`、`*.dmg`、`*.dmg.sha256`;构建后读 `.app/Contents/Info.plist` 断言版本/identifier/产品名;生成清单后再断言 `release.artifact` 就是本轮那一个。守卫在 `apps/ai-game-creator-shell/scripts/macos-release-identity.mjs`,回归用例直接用线上那份 0.1.139 release 身份包(`node --test` 59 passed)。 +- **教训**:凡是「按目录扫描挑产物」的发布步骤,都要么先清空同类产物、要么按本轮预期路径断言;只删「本轮要写的名字」等于把上一轮的坏包留在候选集里。现在共享入口 `generateUpdateManifest()` 也补了与平台无关的 `assertArtifactVersionMatches()`(文件名带 `_0.1.153_` 这类版本段时必须是本轮版本),所以即使将来某个流水线不再 `git clean -fdx`,旧安装包也会被拒绝而不是被发出去。还有一条更一般的:核对线上清单时,先看 decision-log 的现行口径(这里 macOS 已是 arm64 单架构),别拿过期里程碑文字当契约。 + +## 2026-09-28 DirectProject 空历史注入被新版 app-server 拒绝:新项目第一条消息直接「执行通道中断」 + +- **现象**:用 `apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs`(真 AGC CLI → 真 app-server → loopback Responses fixture,无账号/无付费 Provider)跑 `--cases completed` 时,AGC 进程 4 秒就退出、`requests=[]`,stderr 只有 `Codex app-server JSON-RPC 失败:items must not be empty` + `agent.runner.failed`,夹具因此报「AGC did not reach the loopback fixture」。 +- **根因**:`codex_app_server/mod.rs` 在 `thread_created` 时无条件调用 `thread/inject_items`,载荷由 `build_direct_project_history_injection_params()` 从 `.agent/conversations/project.jsonl` 构造;**全新项目该文件不存在 → `items: []`**,而 codex app-server 0.155.1 起把空数组当协议错误拒绝。GUI 路径之所以没暴露:前端会先调 `append_direct_project_conversation_message` 把用户消息写进历史,注入时至少有 1 条;`--direct-codex-chat` 这类无前端宿主(以及任何直接调用 `run_direct_game_creator_turn_at` 的夹具/CLI)没有这一步。 +- **处理(2026-09-28 已修)**:`build_direct_project_history_injection_params()` 在历史为空时返回 `Ok(None)`,调用方跳过 `thread/inject_items`;新增两条单测(空历史不得构造载荷、有历史仍构造 1 条)。 +- **复验**:同一条夹具命令下,AGC 进程从「4 秒失败、`requests=[]`」变成「走到 loopback Provider、`requests=3`、`markers.ready=true`、exit=0」——第一层缺陷确实修掉了。 +- **仍未解决(第二层,另一个问题)**:同一个夹具的全部 6 个用例(`completed/passes/mcp/mcp-write/native/deadline`)在修好第一层后都走到 loopback Provider,但**统一**失败在账本阶段:`phase=interrupted`(期望 `completed`/`exhausted`),stderr 为 `agent.runner.failed: Codex app-server 连接终止 … exitStatus=unknown;stderrBytes=1242`,CLI 回执文本为「执行通道已断开,不能自动重放未确认操作」。 +- **已排除的四层**(都靠插桩,不是推测):① 不是 codex 版本漂移(换成 0.147.0 结果相同);② 不是 stdin 被忽略(`stdio[0]` 改 `pipe` 结果相同);③ **不是 `Drop for ExecutionBinding`**(在该分支插桩打印 phase/`background_done`/`closed`,一次都没打印);④ **app-server 也不是崩溃**(分段打印 stderr 原文,1242 字节全是 `ProgramData known folder 0x80070003`、`Unknown model gpt-5.1-codex`、`powershell shell snapshot` 之类 WARN,没有 panic/error)。 +- **定位到的真正 Killer**:给 `shutdown_game_creator_codex_app_server_inner` 加一行关闭原因日志后,夹具里依次出现 `agent.direct_codex.shutdown reason=宿主执行预算或交付收尾` → `agent.runner.failed: … 连接终止` → (CLI 自己的两次 `shutdown`)。也就是**宿主收尾主动关掉 app-server**:`codex_app_server/execution.rs:938` 的 `shutdown_and_report()` 先 `self.closed.store(true)`、再 `shutdown_game_creator_codex_app_server_inner(inner, "宿主执行预算或交付收尾")`,而后者会给所有在途回合通道发 `CodexTurnEvent::TransportClosed(reason)`(`mod.rs:5207`)。于是**等待中的那一轮**(CLI 单回合宿主就是 CLI 自己的 await)拿到的是「连接断开」,回执变成失败文案、账本落 `interrupted`,尽管这是宿主自己收的尾。修复方向:宿主主动收尾时**先交付回合结果、不要向它发 `TransportClosed`**(或让等待方把「宿主收尾」识别为终态而不是 transport 失败);注意 GUI 常驻 app-server,不会每轮走这条路径,所以影响面是 CLI / 单回合宿主(`--direct-codex-chat` 与依赖它的夹具),不是用户日常对话。 +- **保留的诊断开关**:关闭原因日志留在 `GENARRATIVE_AGC_DIRECT_DEBUG=1` 下(与既有的 `agent.direct_codex.stderr bytes=` 同一开关),以后排查这类「谁关掉的」问题不用再插桩;其余临时插桩都已回滚。 +- **顺带记一条环境陷阱(2026-09-28 已修)**:`apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/` 下曾有两个 codex 二进制——`bin/codex.exe` 是**真正被解析**的那份(0.155.1),而包根目录那份 `codex.exe` 是 0.147.0 的旧残留(tauri 的 Windows 资源映射只引用 `bin/` 等路径),检查都查不出来,却会让本地核对误判「应用跑的是 0.147.0」。根因是 `src-tauri/build.rs` 的 `stage_codex_target()` 只按布局拷贝、从不清理目录,旧布局的组件会永久留在随包资源目录里。现在加了 `prune_stale_codex_components()`:拷贝前删掉不在本轮布局、也不在 `manifest.json`/`NOTICE.md` 白名单里的文件并收掉空目录;实测重建后根目录 `codex.exe` 被清掉、六个声明组件与清单/声明保留。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build index 496332140..ae31d1ad9 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build @@ -170,6 +170,7 @@ pipeline { node --test apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs node --test apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs + node --test apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs node apps/ai-game-creator-shell/scripts/build-macos-ci.mjs if command -v sccache >/dev/null 2>&1; then echo '[agc-macos] sccache 统计(自 server 启动累计):' diff --git a/package.json b/package.json index 7cd8b6869..09cab4938 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "check:game-distribution-upload-safety": "node scripts/check-game-distribution-upload-safety.mjs", "check:game-distribution-upload-resume": "node scripts/check-game-distribution-upload-resume.mjs", "check:agc-project-snapshot-admin-http": "node scripts/check-agc-project-snapshot-admin-http.mjs", + "check:agc-update-channel-manifests": "node scripts/check-agc-update-channel-manifests.mjs", "check:game-distribution-sandbox-e2e": "node scripts/check-game-distribution-sandbox-e2e.mjs", "check:external-v1-scene-generation-smoke": "node scripts/check-external-v1-scene-generation-smoke.mjs", "check:game-distribution-web-e2e": "node scripts/check-game-distribution-web-e2e.mjs", @@ -85,6 +86,8 @@ "check:game-distribution-web-publish-recovery-e2e": "node scripts/check-game-distribution-web-publish-recovery-e2e.mjs", "check:game-distribution-web-a11y-e2e": "node scripts/check-game-distribution-web-a11y-e2e.mjs", "check:game-distribution-package-limits-e2e": "node scripts/check-game-distribution-package-limits-e2e.mjs", + "check:game-distribution-validation-restart-e2e": "node scripts/check-game-distribution-validation-restart-e2e.mjs", + "check:game-distribution-ops-rollback-e2e": "node scripts/check-game-distribution-ops-rollback-e2e.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", "check:preview-deployer": "node scripts/check-preview-deployer.mjs", "check:maintenance-page": "node scripts/check-maintenance-page.mjs", diff --git a/scripts/check-agc-update-channel-manifests.mjs b/scripts/check-agc-update-channel-manifests.mjs new file mode 100644 index 000000000..f4ef42b9c --- /dev/null +++ b/scripts/check-agc-update-channel-manifests.mjs @@ -0,0 +1,526 @@ +// 只读核对 OSS 上**已发布**的 AGC 更新渠道清单(不写任何远端对象)。 +// +// 对应里程碑「AGC 更新发布管线渠道化」第 3 条与「AGC macOS 渠道更新落地」第 1 条: +// 清单内地址指向已存在的对象、签名对象与清单一致;macOS 渠道按 2026-09-21 决策只登记 +// `darwin-aarch64`(不再登记 `darwin-x86_64`);允许下载时用产物里烘焙的 updater 公钥 +// 验证「签名 ↔ 安装包」,并解出 mac 包内 `Info.plist` 核对版本与渠道身份。 +// +// 用法: +// npm run check:agc-update-channel-manifests +// AGC_UPDATE_CHANNELS=dev-win,dev-mac # 要核对的渠道分区,默认两个 dev 分区 +// AGC_UPDATE_OSS_BASE_URL=https://.../agc # 覆盖 OSS 基址 +// AGC_UPDATE_VERIFY_DOWNLOAD=1 # 额外下载产物验签(默认只 HEAD 与读 .sig) +// AGC_UPDATE_DOWNLOAD_LIMIT_MB=600 # 下载上限,超过则该平台标记为未验签 +// +// 默认模式不下载安装包,只核对清单结构、对象存在性与签名对象一致性;渠道发布后先用它做快速回归。 +// 打开下载后,macOS 渠道会额外核对「清单版本 == 包内版本」「包内 identifier/产品名 == 本渠道身份」—— +// 2026-09-28 线上 `dev-mac/0.1.142` 就是「清单写 0.1.142、包里是 0.1.139 的 release 身份包」。 +import { createHash } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import * as tar from 'tar'; + +import { compareVersions } from '../apps/ai-game-creator-shell/scripts/agc-global-version.mjs'; +import { resolveChannelInstallIdentity } from '../apps/ai-game-creator-shell/scripts/channel-identity.mjs'; +import { readMacosAppInfoIdentity } from '../apps/ai-game-creator-shell/scripts/macos-release-identity.mjs'; +import { + readUpdaterPubkey, + verifyUpdaterSignature, +} from '../apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs'; +import { readPortableExecutableVersionInfo } from './pe-version-info.mjs'; + +const OSS_BASE_URL = ( + process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc' +).replace(/\/+$/u, ''); +const CHANNELS = (process.env.AGC_UPDATE_CHANNELS?.trim() || 'dev-win,dev-mac') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +const VERIFY_DOWNLOAD = /^(1|true)$/iu.test( + process.env.AGC_UPDATE_VERIFY_DOWNLOAD?.trim() ?? '', +); +const DOWNLOAD_LIMIT_BYTES = + Number(process.env.AGC_UPDATE_DOWNLOAD_LIMIT_MB?.trim() || '600') * + 1024 * + 1024; + +// 2026-09-21 决策:macOS 只出 arm64 单架构,清单只登记 `darwin-aarch64`。 +// 登记 `darwin-x86_64` 会把 arm64 产物发给 Intel 客户端(曾发生在 dev-mac 0.1.86)。 +const MACOS_ARM64_PLATFORM_KEY = 'darwin-aarch64'; +const MACOS_INTEL_PLATFORM_KEY = 'darwin-x86_64'; +const WINDOWS_PLATFORM_KEY = 'windows-x86_64'; + +let failures = 0; +let skipped = 0; +/** 下载过的更新包摘要:用于「不同渠道不能共用同一份产物字节」这条隔离断言。 */ +const downloadedArtifacts = []; +/** 各渠道清单版本:用于和统一总号(`agc/global-version.json`)比对。 */ +const channelVersions = new Map(); +function check(name, ok, detail = '') { + if (!ok) failures += 1; + console.log( + `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, + ); +} +function skip(name, detail) { + skipped += 1; + console.log(`SKIP ${name}${detail ? ` :: ${detail}` : ''}`); +} + +function decodeMinisignText(value, label) { + const trimmed = String(value ?? '').trim(); + if (trimmed.length === 0) throw new Error(`${label} 为空`); + if (trimmed.startsWith('untrusted comment:')) return trimmed; + const decoded = Buffer.from(trimmed, 'base64').toString('utf8').trim(); + if (!decoded.startsWith('untrusted comment:')) { + throw new Error(`${label} 不是 minisign 内容`); + } + return decoded; +} + +async function head(url) { + const response = await fetch(url, { method: 'HEAD' }); + const length = Number(response.headers.get('content-length') ?? '0'); + return { status: response.status, length }; +} + +async function download(url, destination) { + const response = await fetch(url); + if (!response.ok || !response.body) { + throw new Error(`下载失败 ${response.status}:${url}`); + } + await pipeline( + Readable.fromWeb(response.body), + createWriteStream(destination), + ); +} + +async function sha256Of(filePath) { + const hash = createHash('sha256'); + hash.update(await readFile(filePath)); + return hash.digest('hex'); +} + +async function fileSize(filePath) { + return (await readFile(filePath)).length; +} + +/** 从 tar.gz 里读出某个条目(macOS 更新包是 `<产品名>.app.tar.gz`)。 */ +async function readTarEntry(filePath, predicate) { + const entries = []; + await tar.t({ + file: filePath, + onentry: (entry) => { + entries.push(entry.path); + }, + }); + const target = entries.find(predicate); + if (!target) throw new Error('压缩包内没有目标文件'); + const chunks = []; + await tar.t({ + file: filePath, + filter: (entryPath) => entryPath === target, + onentry: (entry) => { + entry.on('data', (chunk) => chunks.push(chunk)); + }, + }); + return Buffer.concat(chunks).toString('utf8'); +} + +/** + * macOS 更新包必须既是本轮版本、也是本渠道身份。 + * 只核对「地址存在 + 签名匹配」会漏掉「签名对但装的是别的渠道、别的版本」。 + */ +async function verifyMacosBundleIdentity(channel, key, manifest, artifactPath) { + const channelName = channel.replace(/-mac$/u, ''); + const expected = resolveChannelInstallIdentity(channelName); + let identity; + try { + identity = readMacosAppInfoIdentity( + await readTarEntry(artifactPath, (entryPath) => + entryPath.endsWith('/Contents/Info.plist'), + ), + ); + } catch (error) { + check(`${channel}/${key} 更新包可读出 Info.plist`, false, String(error)); + return; + } + check( + `${channel}/${key} 更新包版本与清单一致`, + identity.version === String(manifest.version), + `bundle=${identity.version ?? ''} manifest=${manifest.version ?? ''}`, + ); + check( + `${channel}/${key} 更新包身份属于本渠道`, + identity.identifier === expected.identifier && + identity.name === expected.productName, + `bundle=${identity.identifier ?? ''}/${identity.name ?? ''} expected=${expected.identifier}/${expected.productName}`, + ); +} + +/** + * Windows 安装包同理:PE 版本资源里的产品名与版本必须对得上清单。 + * 只核对文件名等于相信文件没被换过——文件名可以改,PE 资源是产物自己写的。 + */ +async function verifyWindowsBundleIdentity( + channel, + key, + manifest, + artifactPath, +) { + const channelName = channel.replace(/-win$/u, ''); + const expected = resolveChannelInstallIdentity(channelName); + let info; + try { + info = readPortableExecutableVersionInfo(await readFile(artifactPath)); + } catch (error) { + check(`${channel}/${key} 安装包可读出 PE 版本资源`, false, String(error)); + return; + } + const manifestVersion = String(manifest.version); + check( + `${channel}/${key} 安装包版本与清单一致`, + info.fileVersion === `${manifestVersion}.0` || + info.strings.FileVersion === manifestVersion, + `pe=${info.fileVersion}/${info.strings.FileVersion ?? ''} manifest=${manifestVersion}`, + ); + check( + `${channel}/${key} 安装包身份属于本渠道`, + info.strings.ProductName === expected.productName, + `pe=${info.strings.ProductName ?? ''} expected=${expected.productName}`, + ); +} + +async function verifyChannel(channel, tempDir) { + console.log(`\n--- 渠道 ${channel} ---`); + const manifestUrl = `${OSS_BASE_URL}/${channel}/latest.json`; + const response = await fetch(manifestUrl); + check( + `${channel} 渠道清单可读`, + response.status === 200, + `status=${response.status} ${manifestUrl}`, + ); + if (response.status !== 200) return; + const raw = await response.text(); + let manifest; + try { + manifest = JSON.parse(raw); + } catch (error) { + check(`${channel} 渠道清单是合法 JSON`, false, String(error)); + return; + } + const version = String(manifest.version ?? ''); + channelVersions.set(channel, version); + check( + `${channel} 清单版本与发布元数据齐备`, + /^\d+\.\d+\.\d+$/u.test(version) && + !Number.isNaN(Date.parse(String(manifest.pub_date ?? ''))) && + typeof manifest.commit === 'string' && + manifest.commit.trim().length > 0, + `version=${version} pub_date=${manifest.pub_date ?? ''} commit=${String(manifest.commit ?? '').slice(0, 10)}`, + ); + + const platforms = manifest.platforms ?? {}; + const platformKeys = Object.keys(platforms); + check( + `${channel} 清单至少有一个平台条目`, + platformKeys.length > 0, + `platforms=${platformKeys.join(',')}`, + ); + + const expectedPrefix = `${OSS_BASE_URL}/${channel}/${version}/`; + for (const key of platformKeys) { + const entry = platforms[key] ?? {}; + const url = String(entry.url ?? ''); + check( + `${channel}/${key} 地址指向本渠道版本目录`, + url.startsWith(expectedPrefix), + `url=${url}`, + ); + const artifactHead = await head(url); + const sizeOk = artifactHead.status === 200 && artifactHead.length > 0; + check( + `${channel}/${key} 安装包对象存在`, + sizeOk, + `status=${artifactHead.status} bytes=${artifactHead.length}`, + ); + const signatureHead = await head(`${url}.sig`); + check( + `${channel}/${key} 签名对象存在`, + signatureHead.status === 200 && signatureHead.length > 0, + `status=${signatureHead.status} bytes=${signatureHead.length}`, + ); + if (signatureHead.status === 200) { + const signatureText = await (await fetch(`${url}.sig`)).text(); + const manifestSignature = decodeMinisignText( + entry.signature, + `${channel}/${key} 清单签名`, + ); + const objectSignature = decodeMinisignText( + signatureText, + `${channel}/${key} 签名对象`, + ); + check( + `${channel}/${key} 清单签名与签名对象一致`, + manifestSignature === objectSignature, + ); + } + const downloadUrl = String(manifest.downloads?.[key]?.url ?? ''); + check( + `${channel}/${key} 首装下载地址指向已存在对象`, + downloadUrl.startsWith(expectedPrefix) && + (await head(downloadUrl)).status === 200, + `url=${downloadUrl}`, + ); + } + + if (channel.endsWith('-mac')) { + check( + `${channel} 只登记 darwin-aarch64(2026-09-21 单架构决策)`, + Boolean(platforms[MACOS_ARM64_PLATFORM_KEY]) && + !platforms[MACOS_INTEL_PLATFORM_KEY], + `platforms=${platformKeys.join(',')}`, + ); + const extraMacKeys = platformKeys.filter( + (key) => key.startsWith('darwin-') && key !== MACOS_ARM64_PLATFORM_KEY, + ); + check( + `${channel} 没有多余的 macOS 平台键`, + extraMacKeys.length === 0, + `extra=${extraMacKeys.join(',')}`, + ); + } else { + check( + `${channel} 提供 ${WINDOWS_PLATFORM_KEY} 平台键`, + Boolean(platforms[WINDOWS_PLATFORM_KEY]), + `platforms=${platformKeys.join(',')}`, + ); + // 渠道身份会写进产物文件名的产品名,这里先用清单里的地址核对(下载后再验签与摘要)。 + const expectedIdentity = resolveChannelInstallIdentity( + channel.replace(/-win$/u, ''), + ); + const artifactNames = platformKeys.map((key) => + decodeURIComponent(new URL(String(platforms[key].url ?? '')).pathname), + ); + check( + `${channel} 安装包文件名带本渠道产品名`, + artifactNames.every((name) => + name.includes(expectedIdentity.productName), + ), + `names=${artifactNames.map((name) => path.basename(name)).join(',')} expected=${expectedIdentity.productName}`, + ); + } + + if (channel === 'dev-win') { + const bridge = await fetch(`${OSS_BASE_URL}/latest.json`); + const bridgeOk = bridge.status === 200; + check(`${channel} 旧协议迁移指针可读`, bridgeOk); + if (bridgeOk) { + const bridgeJson = await bridge.json(); + const windowsEntry = platforms[WINDOWS_PLATFORM_KEY] ?? {}; + check( + `${channel} 旧协议指针指向同一批已发布对象`, + String(bridgeJson.downloadUrl ?? '') === + String(windowsEntry.url ?? '') && + (await head(String(bridgeJson.downloadUrl ?? ''))).status === 200, + `sha256=${String(bridgeJson.sha256 ?? '').slice(0, 12)} size=${bridgeJson.size ?? ''}`, + ); + } + } else if (channel.endsWith('-win')) { + // 旧协议迁移指针是**全局单对象**,只属于 dev 渠道的 Windows 系统;其它渠道发布 + // 不得改写它,否则 dev 用户会被推去装别的渠道的包。 + const bridge = await fetch(`${OSS_BASE_URL}/latest.json`); + if (bridge.status === 200) { + const bridgeJson = await bridge.json(); + const bridgeUrl = String(bridgeJson.downloadUrl ?? ''); + check( + `${channel} 没有改写 dev 的旧协议迁移指针`, + bridgeUrl.includes('/agc/dev-win/'), + `bridge=${bridgeUrl}`, + ); + } else { + skip( + `${channel} 没有改写 dev 的旧协议迁移指针`, + `旧协议指针不可读:status=${bridge.status}`, + ); + } + } + + // 可选:下载产物,核对 sha256/size 并用产物内公钥验签。 + for (const key of platformKeys) { + const entry = platforms[key] ?? {}; + const url = String(entry.url ?? ''); + const artifactHead = await head(url); + if (!VERIFY_DOWNLOAD) { + skip( + `${channel}/${key} 签名 ↔ 安装包匹配`, + '未设置 AGC_UPDATE_VERIFY_DOWNLOAD=1,只做了对象存在性核对', + ); + continue; + } + if (artifactHead.length > DOWNLOAD_LIMIT_BYTES) { + skip( + `${channel}/${key} 签名 ↔ 安装包匹配`, + `产物 ${artifactHead.length} 字节超过下载上限 ${DOWNLOAD_LIMIT_BYTES}`, + ); + continue; + } + const fileName = `${channel}-${key}-${path.basename(decodeURIComponent(url))}`; + const artifactPath = path.join(tempDir, fileName); + const signaturePath = `${artifactPath}.sig`; + await download(url, artifactPath); + await writeFile( + signaturePath, + decodeMinisignText(entry.signature, `${channel}/${key} 清单签名`), + 'utf8', + ); + const size = await fileSize(artifactPath); + check( + `${channel}/${key} 下载对象字节数与 HEAD 一致`, + size === artifactHead.length, + `downloaded=${size} head=${artifactHead.length}`, + ); + const sha256 = await sha256Of(artifactPath); + downloadedArtifacts.push({ + channel, + platformKey: key, + sha256, + size, + url, + }); + if (channel === 'dev-win' && key === WINDOWS_PLATFORM_KEY) { + const bridgeJson = await ( + await fetch(`${OSS_BASE_URL}/latest.json`) + ).json(); + check( + `${channel} 旧协议指针 sha256/size 与实际产物一致`, + String(bridgeJson.sha256 ?? '').toLowerCase() === sha256 && + Number(bridgeJson.size ?? 0) === size, + `sha256=${sha256.slice(0, 12)} size=${size}`, + ); + } + try { + const result = verifyUpdaterSignature({ + artifactPath, + signaturePath, + pubkey: readUpdaterPubkey(), + }); + check( + `${channel}/${key} 签名 ↔ 安装包匹配(产物内公钥验签)`, + true, + `alg=${result.algorithm} keyId=${result.keyId}`, + ); + } catch (error) { + check( + `${channel}/${key} 签名 ↔ 安装包匹配(产物内公钥验签)`, + false, + String(error), + ); + } + if (key.startsWith('darwin-') && artifactPath.endsWith('.app.tar.gz')) { + await verifyMacosBundleIdentity(channel, key, manifest, artifactPath); + } + if (key.startsWith('windows-') && artifactPath.endsWith('.exe')) { + await verifyWindowsBundleIdentity(channel, key, manifest, artifactPath); + } + await rm(artifactPath, { force: true }); + await rm(signaturePath, { force: true }); + } +} + +const tempDir = await mkdtemp(path.join(os.tmpdir(), 'agc-channel-check-')); +try { + for (const channel of CHANNELS) await verifyChannel(channel, tempDir); +} finally { + await rm(tempDir, { recursive: true, force: true }); +} + +// 渠道隔离:同一次核对里如果下载到多个渠道的更新包,它们不能是同一份字节—— +// 不同渠道的 productName / identifier 不同,产物就不会相同(2026-09-28 的 dev-mac +// 事故正是「dev 分区里放的其实是 release 渠道那份包」,字节级完全相同)。 +const channelNames = [ + ...new Set( + downloadedArtifacts.map((item) => item.channel.replace(/-(win|mac)$/u, '')), + ), +]; +if (!VERIFY_DOWNLOAD) { + skip( + '不同渠道的更新包互不相同(渠道隔离)', + '需要 AGC_UPDATE_VERIFY_DOWNLOAD=1 才能比对产物字节', + ); +} else if (channelNames.length < 2) { + skip( + '不同渠道的更新包互不相同(渠道隔离)', + `本次只核对到 ${channelNames.length} 个渠道(${channelNames.join(',')});用 AGC_UPDATE_CHANNELS=dev-win,dev-mac,release-win,release-mac 可覆盖全量`, + ); +} else { + const byHash = new Map(); + for (const item of downloadedArtifacts) { + const bucket = byHash.get(item.sha256) ?? []; + bucket.push(item); + byHash.set(item.sha256, bucket); + } + const collisions = [...byHash.entries()].filter( + ([, items]) => + new Set(items.map((item) => item.channel.replace(/-(win|mac)$/u, ''))) + .size > 1, + ); + check( + '不同渠道的更新包互不相同(渠道隔离)', + collisions.length === 0, + collisions + .map( + ([hash, items]) => + `${hash.slice(0, 12)}: ${items.map((item) => `${item.channel}/${item.platformKey}`).join(' = ')}`, + ) + .join(';'), + ); +} + +// 统一总号:`agc/global-version.json` 是发号唯一事实源,任何渠道清单都不允许高于它; +// 总号高于渠道是正常的(渠道可能正在构建或尚未发布),这里只提示,不判失败。 +{ + const response = await fetch(`${OSS_BASE_URL}/global-version.json`); + check( + '统一总号对象可读', + response.status === 200, + `status=${response.status} ${OSS_BASE_URL}/global-version.json`, + ); + if (response.status === 200) { + const global = await response.json(); + const globalVersion = String(global.version ?? ''); + check( + '统一总号字段齐备', + /^\d+\.\d+\.\d+$/u.test(globalVersion) && + !Number.isNaN(Date.parse(String(global.updatedAt ?? ''))) && + String(global.channel ?? '').trim().length > 0, + `version=${globalVersion} updatedAt=${global.updatedAt ?? ''} channel=${global.channel ?? ''}`, + ); + for (const [channel, version] of channelVersions) { + if (!/^\d+\.\d+\.\d+$/u.test(version)) continue; + check( + `${channel} 清单版本不高于统一总号`, + compareVersions(version, globalVersion) <= 0, + `channel=${version} global=${globalVersion}`, + ); + if (compareVersions(version, globalVersion) < 0) { + console.log( + `INFO ${channel} 还没发到总号版本(渠道 ${version} < 总号 ${globalVersion};总号最近由 ${global.channel} 在 ${global.updatedAt} 取号,构建可能仍在进行)`, + ); + } + } + } +} + +console.log( + failures === 0 + ? `\n全部通过${skipped > 0 ? `(${skipped} 项跳过)` : ''}` + : `\n${failures} 项失败${skipped > 0 ? `,${skipped} 项跳过` : ''}`, +); +process.exit(failures === 0 ? 0 : 1); diff --git a/scripts/check-game-distribution-ops-rollback-e2e.mjs b/scripts/check-game-distribution-ops-rollback-e2e.mjs new file mode 100644 index 000000000..28ae7af44 --- /dev/null +++ b/scripts/check-game-distribution-ops-rollback-e2e.mjs @@ -0,0 +1,741 @@ +// 游戏分发「发布开关回滚窗口 + 发行网关缓存与响应头」真实栈检查。 +// +// 对应里程碑阶段 D 的上线准备两条: +// - 发布/回滚步骤保留当前公开版本,能关闭新提交和新版本激活; +// - 撤销只改变后端公开投影,源站立即拒绝新请求,边缘最多多留一个获批缓存窗口。 +// +// 用本地真实栈(api-server + SpacetimeDB + 真实 OSS)与真实后台灰度开关取证: +// 1. 关闭 `game-distribution:publish` 后:创建游戏/版本、上传包、送审、撤回、作者下架 +// 与管理员批准新版本全部 503 `GAME_DISTRIBUTION_PUBLISH_DISABLED`; +// 2. 同一窗口内目录、详情、发行网关、`/my-games` 与审核队列读取继续可用, +// 当前公开版本与 `publicationRevision` 不变(关闭投稿、保在线); +// 3. 拒绝审核与管理员安全下架 / 恢复始终可用; +// 4. 发行网关响应头与获批缓存窗口(`public, max-age=60, must-revalidate`)和运维文档一致; +// 5. 换版与下架后**新的**发行请求立刻被源站拒绝(404),旧内容只可能留在 60 秒边缘缓存里; +// 6. 运行期日志不出现访问令牌、刷新 Cookie 与 OSS signed URL,边缘 log_format 不记录请求头。 +// +// 需要:本地 dev 栈 + 管理员账号。 +// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-ops-rollback-e2e +import { createHash, randomBytes } from 'node:crypto'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; + +import JSZip from 'jszip'; + +const COVER_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); +const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082'; +const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; +const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); +const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? ''; +const DEV_PASSWORD = 'GenE2e123!'; +const GATE_KEY = 'game-distribution:publish'; +const RELEASE_CACHE_CONTROL = 'public, max-age=60, must-revalidate'; +const RELEASE_TTL_SECONDS = 60; +const PUBLISH_DISABLED_CODE = 'GAME_DISTRIBUTION_PUBLISH_DISABLED'; +const OPS_DOC_PATH = 'docs/【开发运维】本地开发验证与生产运维-2026-05-15.md'; +const EDGE_TEMPLATES = [ + 'deploy/nginx/genarrative.conf', + 'deploy/nginx/genarrative-dev-http.conf', + 'deploy/container/nginx.conf', +]; +const API_LOG_DIR = path.resolve('logs/api-server'); + +if (!ADMIN_USER || !ADMIN_PASSWORD) { + console.error('缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD。'); + process.exit(2); +} + +let failures = 0; +function check(name, ok, detail = '') { + if (!ok) failures += 1; + console.log( + `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, + ); +} +function section(title) { + console.log(`\n--- ${title} ---`); +} + +async function api(pathname, options = {}) { + const { method = 'GET', token, body, headers = {}, binary } = options; + const finalHeaders = { ...ENVELOPE, ...headers }; + if (token) finalHeaders.Authorization = `Bearer ${token}`; + let finalBody; + if (binary) { + finalBody = binary; + } else if (body !== undefined) { + finalHeaders['Content-Type'] = 'application/json'; + finalBody = JSON.stringify(body); + } + const response = await fetch(`${API}${pathname}`, { + method, + headers: finalHeaders, + body: finalBody, + }); + const text = await response.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + json = null; + } + return { + status: response.status, + text, + json, + data: json?.data, + error: json?.error, + headers: Object.fromEntries(response.headers.entries()), + setCookies: response.headers.getSetCookie?.() ?? [], + }; +} + +async function release(pathname) { + const response = await fetch(`${API}${pathname}`); + const body = await response.text(); + return { status: response.status, body, headers: response.headers }; +} + +async function adminToken() { + const login = await api('/admin/api/login', { + method: 'POST', + body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, + }); + const token = login.data?.token ?? login.data?.accessToken; + check( + '管理员登录成功', + login.status === 200 && Boolean(token), + `status=${login.status}`, + ); + return token; +} + +async function setPublishGate(admin, enabled, rolloutPercent) { + const response = await api('/admin/api/feature-gates', { + method: 'PUT', + token: admin, + body: { + gateKey: GATE_KEY, + enabled, + rolloutPercent, + allowUserIds: [], + allowUserTags: [], + denyUserIds: [], + description: 'E2E 发布回滚窗口', + }, + }); + return response; +} + +async function uploadCover(token, id) { + const fileName = `ops-${id}.png`; + const ticket = await api('/api/assets/direct-upload-tickets', { + method: 'POST', + token, + body: { + legacyPrefix: 'generated-character-drafts', + pathSegments: ['game-distribution', 'ops-rollback', String(id)], + fileName, + contentType: 'image/png', + access: 'private', + maxSizeBytes: COVER_PNG.length, + metadata: { asset_kind: 'game_distribution_cover' }, + }, + }); + if (ticket.status !== 200) { + throw new Error(`创建直传凭证失败 ${ticket.status}`); + } + const upload = ticket.data.upload; + const form = new FormData(); + for (const [key, value] of Object.entries(upload.formFields ?? {})) { + if (value !== null && value !== undefined) form.append(key, String(value)); + } + form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName); + const put = await fetch(upload.host, { method: 'POST', body: form }); + if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`); + const confirm = await api('/api/assets/objects/confirm', { + method: 'POST', + token, + body: { + bucket: upload.bucket, + objectKey: upload.objectKey, + contentType: 'image/png', + contentLength: COVER_PNG.length, + assetKind: 'game_distribution_cover', + accessPolicy: 'private', + entityId: 'game-distribution-ops-rollback', + }, + }); + if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`); + return confirm.data.assetObject.assetObjectId; +} + +function gameMetadata(title, coverAssetId) { + return { + title, + summary: '发布开关回滚窗口 E2E', + description: '', + category: '休闲', + tags: ['ops'], + coverAssetId, + deviceSupport: { desktop: true, mobile: false, touch: false }, + inputModes: ['keyboard', 'mouse'], + orientation: 'landscape', + }; +} + +/// 包内同时放 HTML 与 CSS:发行网关对 HTML 单独下发 CSP,对 CSS 只下发通用安全头。 +async function buildPackage(marker) { + const zip = new JSZip(); + zip.file( + 'index.html', + `

${marker}

`, + ); + zip.file( + 'app.css', + `body { color: ${marker.startsWith('V1') ? '#111' : '#222'}; }`, + ); + zip.file('filler.bin', randomBytes(256 * 1024)); + const bytes = Buffer.from( + await zip.generateAsync({ type: 'uint8array', compression: 'STORE' }), + ); + return { bytes, fileCount: 3 }; +} + +async function createVersion(token, gameId, metadata, bytes, fileCount, key) { + return api(`/api/game-distribution/games/${gameId}/versions`, { + method: 'POST', + token, + headers: { 'Idempotency-Key': key }, + body: { + packageSha256: createHash('sha256').update(bytes).digest('hex'), + packageBytes: bytes.length, + packageFileCount: fileCount, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }, + }); +} + +async function uploadVersion(token, versionId, bytes, key) { + return api(`/api/game-distribution/versions/${versionId}/package`, { + method: 'PUT', + token, + headers: { + 'Idempotency-Key': key, + 'Content-Type': 'application/zip', + }, + binary: bytes, + }); +} + +async function submitVersion(token, versionId, revision, key) { + return api(`/api/game-distribution/versions/${versionId}/submit`, { + method: 'POST', + token, + headers: { 'Idempotency-Key': key }, + body: { expectedPublicationRevision: revision }, + }); +} + +async function reviewVersion(admin, versionId, payload, key) { + return api(`/admin/api/game-distribution/versions/${versionId}/review`, { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': key }, + body: payload, + }); +} + +async function adminGameRow(admin, gameId) { + const response = await api('/admin/api/game-distribution/games', { + token: admin, + }); + const game = (response.data?.games ?? []).find( + (row) => row.gameId === gameId, + ); + return { status: response.status, game }; +} + +function newestApiLog() { + let entries = []; + try { + entries = readdirSync(API_LOG_DIR).filter((name) => name.endsWith('.log')); + } catch { + return null; + } + if (entries.length === 0) return null; + const files = entries + .map((name) => path.join(API_LOG_DIR, name)) + .sort((left, right) => statSync(left).mtimeMs - statSync(right).mtimeMs); + return files[files.length - 1]; +} + +function sleep(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +/// 发行网关的响应头契约:通用安全头始终下发,CSP 只跟 HTML 走。 +function assertReleaseHeaders(label, response, { html }) { + const headers = response.headers; + check( + `${label}:缓存窗口等于获批 TTL`, + headers.get('cache-control') === RELEASE_CACHE_CONTROL, + `cache-control=${headers.get('cache-control') ?? ''}`, + ); + check( + `${label}:nosniff + no-referrer + 无凭据 CORS/跨来源`, + headers.get('x-content-type-options') === 'nosniff' && + headers.get('referrer-policy') === 'no-referrer' && + headers.get('cross-origin-resource-policy') === 'cross-origin' && + headers.get('access-control-allow-origin') === '*', + `nosniff=${headers.get('x-content-type-options') ?? ''} corp=${headers.get('cross-origin-resource-policy') ?? ''}`, + ); + const csp = headers.get('content-security-policy') ?? ''; + check( + html ? `${label}:HTML 下发收紧 CSP` : `${label}:非 HTML 不下发 CSP`, + html + ? csp.includes("default-src 'none'") && + csp.includes("frame-src 'none'") && + csp.includes("base-uri 'none'") && + csp.includes("object-src 'none'") + : csp === '', + `csp=${csp.slice(0, 40)}`, + ); +} + +async function main() { + const stamp = Date.now(); + const key = (label) => `${label}-${stamp}`; + const admin = await adminToken(); + if (!admin) process.exit(1); + + const gateOn = await setPublishGate(admin, true, 100); + check( + '发布开关可开启(灰度 100%)', + gateOn.status === 200, + `status=${gateOn.status}`, + ); + + const phone = `135${String(stamp).slice(-8)}`; + const register = await api('/api/auth/entry', { + method: 'POST', + body: { purePhoneNumber: phone, password: DEV_PASSWORD }, + }); + const author = register.data?.token; + check( + '作者注册拿到 token', + register.status === 200 && Boolean(author), + `status=${register.status}`, + ); + if (!author) process.exit(1); + const refreshCookieValue = (register.setCookies ?? []) + .map((cookie) => cookie.split(';')[0]) + .map((pair) => pair.split('=').slice(1).join('=')) + .find((value) => value && value.length > 20); + + const coverAssetId = await uploadCover(author, stamp); + const title = `发布回滚 ${String(stamp).slice(-6)}`; + const metadata = gameMetadata(title, coverAssetId); + const created = await api('/api/game-distribution/games', { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': key('ops-game') }, + body: metadata, + }); + const gameId = created.data?.id; + check('创建游戏成功', created.status === 200 && Boolean(gameId)); + if (!gameId) process.exit(1); + + section('基线:v1 公开、v2/v3 待审、v4 只有版本记录'); + const v1 = await buildPackage('V1-OK'); + const v1Version = await createVersion( + author, + gameId, + metadata, + v1.bytes, + v1.fileCount, + key('ops-v1'), + ); + const v1Id = v1Version.data?.versionId; + await uploadVersion(author, v1Id, v1.bytes, key('ops-upload-v1')); + await submitVersion(author, v1Id, 0, key('ops-submit-v1')); + const approved = await reviewVersion( + admin, + v1Id, + { decision: 'approve', expectedPublicationRevision: 0 }, + key('ops-approve-v1'), + ); + check( + 'v1 审核通过并公开', + approved.status === 200, + `status=${approved.status}`, + ); + + const baselineDetail = await api(`/api/game-distribution/games/${gameId}`); + check( + '公开基线:revision=1 且当前公开版本是 v1', + baselineDetail.status === 200 && + baselineDetail.data?.publicationRevision === 1 && + baselineDetail.data?.currentVersion?.id === v1Id, + `status=${baselineDetail.status} revision=${baselineDetail.data?.publicationRevision ?? ''}`, + ); + const baselineRelease = await release( + `/api/game-distribution/releases/${gameId}/index.html`, + ); + check( + '公开基线:发行入口返回 v1 内容', + baselineRelease.status === 200 && baselineRelease.body.includes('V1-OK'), + `status=${baselineRelease.status}`, + ); + assertReleaseHeaders('v1 HTML', baselineRelease, { html: true }); + const baselineCss = await release( + `/api/game-distribution/releases/${gameId}/app.css`, + ); + assertReleaseHeaders('v1 CSS', baselineCss, { html: false }); + + const pendingIds = {}; + for (const marker of ['V2-OK', 'V3-OK']) { + const built = await buildPackage(marker); + const version = await createVersion( + author, + gameId, + metadata, + built.bytes, + built.fileCount, + key(`ops-${marker}`), + ); + const versionId = version.data?.versionId; + await uploadVersion( + author, + versionId, + built.bytes, + key(`ops-upload-${marker}`), + ); + const submitted = await submitVersion( + author, + versionId, + 1, + key(`ops-submit-${marker}`), + ); + pendingIds[marker] = versionId; + check( + `${marker} 送审进入待审`, + submitted.status === 202, + `status=${submitted.status}`, + ); + } + const v2Id = pendingIds['V2-OK']; + const v3Id = pendingIds['V3-OK']; + const v4Built = await buildPackage('V4-OK'); + const v4Version = await createVersion( + author, + gameId, + metadata, + v4Built.bytes, + v4Built.fileCount, + key('ops-v4'), + ); + const v4Id = v4Version.data?.versionId; + check('v4 只建版本记录(未上传,用于验证上传被开关拦住)', Boolean(v4Id)); + + section('关闭发布开关:关投稿、保在线'); + const gateOff = await setPublishGate(admin, false, 0); + check( + '发布开关可关闭(紧急关闭投稿)', + gateOff.status === 200, + `status=${gateOff.status}`, + ); + + const blocked = [ + [ + '创建游戏', + await api('/api/game-distribution/games', { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': key('ops-blocked-game') }, + body: metadata, + }), + ], + [ + '创建版本', + await api(`/api/game-distribution/games/${gameId}/versions`, { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': key('ops-blocked-version') }, + body: { + packageSha256: createHash('sha256') + .update(v4Built.bytes) + .digest('hex'), + packageBytes: v4Built.bytes.length, + packageFileCount: v4Built.fileCount, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }, + }), + ], + [ + '上传包', + await uploadVersion( + author, + v4Id, + v4Built.bytes, + key('ops-blocked-upload'), + ), + ], + ['送审', await submitVersion(author, v4Id, 1, key('ops-blocked-submit'))], + [ + '撤回', + await api(`/api/game-distribution/versions/${v4Id}/cancel`, { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': key('ops-blocked-cancel') }, + body: { expectedPublicationRevision: 1 }, + }), + ], + [ + '作者下架', + await api(`/api/game-distribution/games/${gameId}/unpublish`, { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': key('ops-blocked-unpublish') }, + body: { expectedPublicationRevision: 1 }, + }), + ], + [ + '管理员批准新版本', + await reviewVersion( + admin, + v3Id, + { decision: 'approve', expectedPublicationRevision: 1 }, + key('ops-blocked-approve'), + ), + ], + ]; + for (const [name, response] of blocked) { + check( + `开关关闭时「${name}」被拦(503 + 发布关闭码)`, + response.status === 503 && response.error?.code === PUBLISH_DISABLED_CODE, + `status=${response.status} code=${response.error?.code ?? ''}`, + ); + } + + const closedCatalog = await api('/api/game-distribution/games'); + const closedDetail = await api(`/api/game-distribution/games/${gameId}`); + const closedMine = await api('/api/game-distribution/my-games', { + token: author, + }); + const closedReviews = await api('/admin/api/game-distribution/reviews', { + token: admin, + }); + const closedRelease = await release( + `/api/game-distribution/releases/${gameId}/index.html`, + ); + check( + '开关关闭时读取全部可用(目录/详情/我的游戏/审核队列)', + closedCatalog.status === 200 && + closedDetail.status === 200 && + closedMine.status === 200 && + closedReviews.status === 200, + `catalog=${closedCatalog.status} detail=${closedDetail.status} mine=${closedMine.status} reviews=${closedReviews.status}`, + ); + check( + '开关关闭时当前公开版本不变(回滚窗口保留在线旧版)', + closedDetail.data?.publicationRevision === 1 && + closedDetail.data?.currentVersion?.id === v1Id, + `revision=${closedDetail.data?.publicationRevision ?? ''} active=${closedDetail.data?.currentVersion?.id ?? ''}`, + ); + check( + '开关关闭时已公开游戏仍可游玩(发行入口继续服务 v1)', + closedRelease.status === 200 && closedRelease.body.includes('V1-OK'), + `status=${closedRelease.status}`, + ); + + const rejected = await reviewVersion( + admin, + v2Id, + { + decision: 'reject', + expectedPublicationRevision: 1, + reviewReason: 'E2E 开关关闭仍可拒绝审核', + }, + key('ops-reject-v2'), + ); + check( + '开关关闭时拒绝审核始终可用', + rejected.status === 200, + `status=${rejected.status} code=${rejected.error?.code ?? ''}`, + ); + + const suspended = await api( + `/admin/api/game-distribution/games/${gameId}/suspend`, + { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': key('ops-suspend') }, + body: { + expectedPublicationRevision: 1, + reason: 'E2E 开关关闭仍可安全下架', + }, + }, + ); + check( + '开关关闭时管理员安全下架始终可用', + suspended.status === 200, + `status=${suspended.status} code=${suspended.error?.code ?? ''}`, + ); + const suspendedDetail = await api(`/api/game-distribution/games/${gameId}`); + const suspendedRelease = await release( + `/api/game-distribution/releases/${gameId}/index.html`, + ); + check( + '下架后新的发行请求立刻被源站拒绝(不依赖 CDN purge)', + suspendedRelease.status === 404 && suspendedDetail.status === 404, + `release=${suspendedRelease.status} detail=${suspendedDetail.status}`, + ); + const afterSuspend = await adminGameRow(admin, gameId); + const suspendedRevision = afterSuspend.game?.publicationRevision; + const restored = await api( + `/admin/api/game-distribution/games/${gameId}/restore`, + { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': key('ops-restore') }, + body: { expectedPublicationRevision: suspendedRevision }, + }, + ); + const restoredRelease = await release( + `/api/game-distribution/releases/${gameId}/index.html`, + ); + check( + '安全下架可恢复,恢复后发行入口重新服务同一公开版本', + restored.status === 200 && + restoredRelease.status === 200 && + restoredRelease.body.includes('V1-OK'), + `restore=${restored.status} release=${restoredRelease.status}`, + ); + + section('重新开放后换版:旧公开版本被保留为已撤回记录'); + const reopened = await setPublishGate(admin, true, 100); + check( + '发布开关可重新开放', + reopened.status === 200, + `status=${reopened.status}`, + ); + const beforeSwitch = await adminGameRow(admin, gameId); + const switchRevision = beforeSwitch.game?.publicationRevision; + const switched = await reviewVersion( + admin, + v3Id, + { decision: 'approve', expectedPublicationRevision: switchRevision }, + key('ops-approve-v3'), + ); + check( + '开关恢复后管理员可以激活新版本', + switched.status === 200, + `status=${switched.status} code=${switched.error?.code ?? ''}`, + ); + const switchedRelease = await release( + `/api/game-distribution/releases/${gameId}/index.html`, + ); + check( + '换版后发行入口改为新版本内容', + switchedRelease.status === 200 && switchedRelease.body.includes('V3-OK'), + `status=${switchedRelease.status} marker=${switchedRelease.body.includes('V3-OK')}`, + ); + const mine = await api('/api/game-distribution/my-games', { token: author }); + const mineGame = (mine.data?.games ?? []).find((game) => game.id === gameId); + const mineVersions = mineGame?.versions ?? []; + const v1Record = mineVersions.find((version) => version.versionId === v1Id); + check( + '换版后旧公开版本仍作为已撤回记录保留(可追溯、未删除)', + v1Record?.status === 'revoked', + `v1=${v1Record?.status ?? 'missing'} versions=${mineVersions.length}`, + ); + + section('日志脱敏与边缘日志格式'); + await sleep(500); + const logFile = newestApiLog(); + const logText = logFile ? readFileSync(logFile, 'utf8') : ''; + check( + '运行期日志不出现访问令牌与刷新 Cookie', + Boolean(logFile) && + !logText.includes(author) && + (!refreshCookieValue || !logText.includes(refreshCookieValue)), + `log=${path.basename(logFile ?? '(none)')} tokenHit=${logText.includes(author)} cookieChecked=${Boolean(refreshCookieValue)}`, + ); + check( + '日志脱敏正向对照:本轮的发行请求确实落在同一份日志里', + Boolean(logFile) && logText.includes(gameId), + `log=${path.basename(logFile ?? '(none)')} gameHit=${logText.includes(gameId)}`, + ); + check( + '运行期日志不出现 OSS signed URL 凭据', + !logText.includes('OSSAccessKeyId') && !logText.includes('Signature='), + ); + check( + '关闭投稿与安全下架在日志里按 operation 可观测', + logText.includes('publish_switch_blocked') && + logText.includes('game_suspended'), + ); + const edgeLeaks = EDGE_TEMPLATES.filter((template) => { + const source = readFileSync(template, 'utf8'); + const match = source.match(/log_format\s+[\s\S]*?;/u); + const format = match ? match[0] : ''; + return ( + format.includes('$http_authorization') || + format.includes('$http_cookie') || + format.includes('$arg_') + ); + }); + check( + '三份边缘模板的 log_format 不记录请求头与查询参数', + edgeLeaks.length === 0, + `leaks=${edgeLeaks.join(',')}`, + ); + const routeIssues = EDGE_TEMPLATES.filter((template) => { + const source = readFileSync(template, 'utf8'); + return !( + source.includes( + 'location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$"', + ) && + source.includes('proxy_set_header Cookie ""') && + source.includes( + 'proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path', + ) + ); + }); + check( + '三份边缘模板把 /games// 映射到发行网关并清空 Cookie', + routeIssues.length === 0, + `issues=${routeIssues.join(',')}`, + ); + const opsDoc = readFileSync(OPS_DOC_PATH, 'utf8'); + check( + '运维文档与运行期缓存窗口一致(60 秒上限 + 已下载脚本不可远程抹除)', + opsDoc.includes(`max-age=${RELEASE_TTL_SECONDS}`) && + opsDoc.includes(`${RELEASE_TTL_SECONDS} 秒`) && + opsDoc.includes('无法远程抹除'), + ); +} + +async function run() { + try { + await main(); + } finally { + // 无论通过与否都放开灰度,避免把本机后续验证卡在“关投稿”状态。 + try { + const admin = await adminToken(); + if (admin) await setPublishGate(admin, true, 100); + } catch (error) { + console.error(`恢复发布开关失败:${error}`); + } + console.log(failures === 0 ? '\n全部通过' : `\n${failures} 项失败`); + process.exit(failures === 0 ? 0 : 1); + } +} + +await run(); diff --git a/scripts/check-game-distribution-validation-restart-e2e.mjs b/scripts/check-game-distribution-validation-restart-e2e.mjs new file mode 100644 index 000000000..3316f83b5 --- /dev/null +++ b/scripts/check-game-distribution-validation-restart-e2e.mjs @@ -0,0 +1,511 @@ +// 游戏分发「校验执行器重启可恢复 + 失败可观测 + 不误删公开版本」检查。 +// +// 两段式:prepare 段上传一个大包并在确认(校验)进行中杀掉 api-server;调用方重启 +// api-server 后用同一 E2E_STATE_FILE 跑 resume 段,确认仍能续做、失败可见、公开版本仍在。 +// +// 需要:本地 dev 栈 + 管理员账号 + playwright 无关。 +// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... E2E_STAGE=prepare npm run check:game-distribution-validation-restart-e2e +// <重启 api-server> +// E2E_STAGE=resume ... npm run check:game-distribution-validation-restart-e2e +import { execSync } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import JSZip from 'jszip'; + +const COVER_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); +const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082'; +const API_PORT = Number(new URL(API).port || '80'); +const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' }; +const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim(); +const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? ''; +const STAGE = (process.env.E2E_STAGE ?? 'prepare').trim(); +const STATE_FILE = + (process.env.E2E_STATE_FILE ?? '').trim() || + path.join(tmpdir(), 'genarrative-validation-restart.json'); +const DEV_PASSWORD = 'GenE2e123!'; +const MIB = 1024 * 1024; + +if (!ADMIN_USER || !ADMIN_PASSWORD) { + console.error('缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD。'); + process.exit(2); +} +if (STAGE !== 'prepare' && STAGE !== 'resume') { + console.error(`E2E_STAGE 只支持 prepare / resume,收到:${STAGE}`); + process.exit(2); +} + +let failures = 0; +function check(name, ok, detail = '') { + if (!ok) failures += 1; + console.log( + `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, + ); +} + +async function api(pathname, options = {}) { + const { method = 'GET', token, body, headers = {}, binary } = options; + const finalHeaders = { ...ENVELOPE, ...headers }; + if (token) finalHeaders.Authorization = `Bearer ${token}`; + let finalBody; + if (binary) { + finalBody = binary; + } else if (body !== undefined) { + finalHeaders['Content-Type'] = 'application/json'; + finalBody = JSON.stringify(body); + } + const response = await fetch(`${API}${pathname}`, { + method, + headers: finalHeaders, + body: finalBody, + }); + const text = await response.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + json = null; + } + return { + status: response.status, + text, + json, + data: json?.data, + error: json?.error, + }; +} + +async function adminToken() { + const login = await api('/admin/api/login', { + method: 'POST', + body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, + }); + const token = login.data?.token ?? login.data?.accessToken; + check( + '管理员登录成功', + login.status === 200 && Boolean(token), + `status=${login.status}`, + ); + return token; +} + +async function uploadCover(token, id) { + const fileName = `restart-${id}.png`; + const ticket = await api('/api/assets/direct-upload-tickets', { + method: 'POST', + token, + body: { + legacyPrefix: 'generated-character-drafts', + pathSegments: ['game-distribution', 'restart', String(id)], + fileName, + contentType: 'image/png', + access: 'private', + maxSizeBytes: COVER_PNG.length, + metadata: { asset_kind: 'game_distribution_cover' }, + }, + }); + if (ticket.status !== 200) + throw new Error(`创建直传凭证失败 ${ticket.status}`); + const upload = ticket.data.upload; + const form = new FormData(); + for (const [key, value] of Object.entries(upload.formFields ?? {})) { + if (value !== null && value !== undefined) form.append(key, String(value)); + } + form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName); + const put = await fetch(upload.host, { method: 'POST', body: form }); + if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`); + const confirm = await api('/api/assets/objects/confirm', { + method: 'POST', + token, + body: { + bucket: upload.bucket, + objectKey: upload.objectKey, + contentType: 'image/png', + contentLength: COVER_PNG.length, + assetKind: 'game_distribution_cover', + accessPolicy: 'private', + entityId: 'game-distribution-restart', + }, + }); + if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`); + return confirm.data.assetObject.assetObjectId; +} + +function gameMetadata(title, coverAssetId) { + return { + title, + summary: '校验重启恢复 E2E', + description: '', + category: '休闲', + tags: ['restart'], + coverAssetId, + deviceSupport: { desktop: true, mobile: false, touch: false }, + inputModes: ['keyboard', 'mouse'], + orientation: 'landscape', + }; +} + +async function createVersion( + token, + gameId, + metadata, + bytes, + suffix, + fileCount, +) { + return api(`/api/game-distribution/games/${gameId}/versions`, { + method: 'POST', + token, + headers: { 'Idempotency-Key': `restart-version-${suffix}` }, + body: { + packageSha256: createHash('sha256').update(bytes).digest('hex'), + packageBytes: bytes.length, + packageFileCount: fileCount, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }, + }); +} + +function buildPackage(sizeMiB, marker) { + const zip = new JSZip(); + zip.file( + 'index.html', + `

${marker}

`, + ); + // 单文件上限 64 MiB,这里按 48 MiB 切片,确保大包用例卡在"包大小"而不是"单文件"。 + let remaining = sizeMiB; + let index = 0; + while (remaining > 0) { + const chunk = Math.min(remaining, 48); + zip.file(`assets/blob-${index}.bin`, randomBytes(chunk * MIB)); + remaining -= chunk; + index += 1; + } + return zip + .generateAsync({ type: 'uint8array', compression: 'STORE' }) + .then((bytes) => ({ bytes: Buffer.from(bytes), fileCount: index + 1 })); +} + +function killApiServer() { + execSync( + `powershell -NoProfile -Command "$c = Get-NetTCPConnection -LocalPort ${API_PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) { Stop-Process -Id $c.OwningProcess -Force }"`, + { stdio: 'ignore', timeout: 30_000 }, + ); +} + +async function runPrepare() { + const admin = await adminToken(); + if (!admin) process.exit(1); + await api('/admin/api/feature-gates', { + method: 'PUT', + token: admin, + body: { + gateKey: 'game-distribution:publish', + enabled: true, + rolloutPercent: 100, + allowUserIds: [], + allowUserTags: [], + denyUserIds: [], + description: 'E2E 校验重启', + }, + }); + const stamp = Date.now(); + const phone = `135${String(stamp).slice(-8)}`; + const register = await api('/api/auth/entry', { + method: 'POST', + body: { purePhoneNumber: phone, password: DEV_PASSWORD }, + }); + const author = register.data?.token; + check('作者注册拿到 token', register.status === 200 && Boolean(author)); + if (!author) process.exit(1); + const coverAssetId = await uploadCover(author, stamp); + const title = `校验重启 ${String(stamp).slice(-6)}`; + const metadata = gameMetadata(title, coverAssetId); + + // 先发布 v1(公开版本),用来验证后续失败/重试不会误删公开内容。 + const created = await api('/api/game-distribution/games', { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `restart-game-${stamp}` }, + body: metadata, + }); + const gameId = created.data?.id; + check('创建游戏成功', created.status === 200 && Boolean(gameId)); + const v1Package = await buildPackage(1, 'V1-OK'); + const v1Bytes = v1Package.bytes; + const v1 = await createVersion( + author, + gameId, + metadata, + v1Bytes, + `${stamp}-v1`, + v1Package.fileCount, + ); + await api(`/api/game-distribution/versions/${v1.data?.versionId}/package`, { + method: 'PUT', + token: author, + headers: { + 'Idempotency-Key': `restart-upload-v1-${stamp}`, + 'Content-Type': 'application/zip', + }, + binary: v1Bytes, + }); + await api(`/api/game-distribution/versions/${v1.data?.versionId}/submit`, { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `restart-submit-v1-${stamp}` }, + body: { expectedPublicationRevision: 0 }, + }); + const approved = await api( + `/admin/api/game-distribution/versions/${v1.data?.versionId}/review`, + { + method: 'POST', + token: admin, + headers: { 'Idempotency-Key': `restart-approve-v1-${stamp}` }, + body: { decision: 'approve', expectedPublicationRevision: 0 }, + }, + ); + check('v1 已公开', approved.status === 200, `status=${approved.status}`); + + // v2:上传一个大包,然后在确认(校验)进行中杀掉 api-server。 + const v2Package = await buildPackage(196, 'V2-OK'); + const v2Bytes = v2Package.bytes; + const v2 = await createVersion( + author, + gameId, + metadata, + v2Bytes, + `${stamp}-v2`, + v2Package.fileCount, + ); + const versionId = v2.data?.versionId; + const uploaded = await api( + `/api/game-distribution/versions/${versionId}/package`, + { + method: 'PUT', + token: author, + headers: { + 'Idempotency-Key': `restart-upload-v2-${stamp}`, + 'Content-Type': 'application/zip', + }, + binary: v2Bytes, + }, + ); + check( + 'v2 整包上传完成(等待确认校验)', + uploaded.status === 200, + `status=${uploaded.status} bytes=${v2Bytes.length}`, + ); + + const confirmPromise = api( + `/api/game-distribution/versions/${versionId}/package/complete`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `restart-confirm-v2-${stamp}` }, + }, + ).catch((error) => ({ error: String(error) })); + // 请求已经发出就立刻杀掉校验进程:确认要回读整包并算清单,进程退出必然让这次 + // 请求失败,同时版本状态不应被写成 uploaded / validation_failed。 + killApiServer(); + const interrupted = await confirmPromise; + check( + '确认校验进行中杀掉 api-server 会让请求失败而不是静默成功', + Boolean(interrupted?.error) || interrupted.status >= 500, + `result=${interrupted?.error ?? interrupted?.status ?? 'n/a'}`, + ); + + writeFileSync( + STATE_FILE, + JSON.stringify( + { + apiBase: API, + phone, + password: DEV_PASSWORD, + gameId, + title, + coverAssetId, + versionId, + v1VersionId: v1.data?.versionId, + v2Bytes: v2Bytes.length, + stamp, + }, + null, + 2, + ), + ); + console.log( + `\nPREPARED gameId=${gameId} v2VersionId=${versionId}(api-server 已杀,重启后用 E2E_STAGE=resume 继续)`, + ); + process.exit(failures === 0 ? 0 : 1); +} + +async function runResume() { + const state = JSON.parse(readFileSync(STATE_FILE, 'utf8')); + const admin = await adminToken(); + const login = await api('/api/auth/entry', { + method: 'POST', + body: { purePhoneNumber: state.phone, password: state.password }, + }); + const author = login.data?.token; + check( + '重启后作者仍能登录', + login.status === 200 && Boolean(author), + `status=${login.status}`, + ); + if (!author) process.exit(1); + + const uploadState = await api( + `/api/game-distribution/versions/${state.versionId}/package/upload-state`, + { token: author }, + ); + check( + '重启后已收字节仍在(暂存对象没因进程退出丢失)', + uploadState.status === 200 && + uploadState.data?.receivedBytes === state.v2Bytes, + `receivedBytes=${uploadState.data?.receivedBytes ?? 'n/a'} expected=${state.v2Bytes}`, + ); + + const confirm = await api( + `/api/game-distribution/versions/${state.versionId}/package/complete`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `restart-confirm-retry-${state.stamp}` }, + }, + ); + check( + '重启后重新确认能完成校验(可恢复)', + confirm.status === 200 && confirm.data?.status === 'uploaded', + `status=${confirm.status} versionStatus=${confirm.data?.status ?? ''}`, + ); + const submitted = await api( + `/api/game-distribution/versions/${state.versionId}/submit`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `restart-submit-v2-${state.stamp}` }, + body: { expectedPublicationRevision: 1 }, + }, + ); + check( + 'v2 送审成功(200/202)', + submitted.status === 202, + `status=${submitted.status}`, + ); + + const reviews = await api('/admin/api/game-distribution/reviews', { + token: admin, + }); + const backlog = (reviews.data?.entries ?? []).filter( + (entry) => entry.status === 'pending_review', + ); + check( + '审核积压可观测(待审队列能查到该版本)', + backlog.some((entry) => entry.versionId === state.versionId), + `pending=${backlog.length}`, + ); + const adminGames = await api('/admin/api/game-distribution/games', { + token: admin, + }); + const adminGame = (adminGames.data?.games ?? []).find( + (game) => game.gameId === state.gameId, + ); + check( + '后台游戏列表能看到同一游戏的两个版本状态', + (adminGame?.versions ?? []).length === 2 && + adminGame.versions.some((version) => version.status === 'pending_review'), + `versions=${adminGame?.versions?.length ?? 'n/a'}`, + ); + + // 失败可观测:上传一个结构损坏的包,作者当场能拿到失败码与原因,版本落 + // upload_failed(原因同时落库 last_error_code/last_error_message),后台能看到状态。 + const brokenBytes = Buffer.concat([ + Buffer.from('PK\x03\x04broken', 'binary'), + randomBytes(1024), + ]); + const metadata = gameMetadata(state.title, state.coverAssetId); + const v3 = await api( + `/api/game-distribution/games/${state.gameId}/versions`, + { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `restart-version-v3-${state.stamp}` }, + body: { + packageSha256: createHash('sha256').update(brokenBytes).digest('hex'), + packageBytes: brokenBytes.length, + packageFileCount: 1, + packageEntryPath: 'index.html', + gameMetadata: metadata, + }, + }, + ); + const v3Id = v3.data?.versionId; + const brokenUpload = await api( + `/api/game-distribution/versions/${v3Id}/package`, + { + method: 'PUT', + token: author, + headers: { + 'Idempotency-Key': `restart-upload-v3-${state.stamp}`, + 'Content-Type': 'application/zip', + }, + binary: brokenBytes, + }, + ); + const v3Readback = await api(`/api/game-distribution/versions/${v3Id}`, { + token: author, + }); + check( + '坏包校验失败可诊断(422 + 失败码 + 原因)', + brokenUpload.status === 422 && + brokenUpload.error?.code === 'PACKAGE_VALIDATION_FAILED' && + Boolean(brokenUpload.error?.details?.reason), + `upload=${brokenUpload.status} code=${brokenUpload.error?.code ?? ''} reason=${brokenUpload.error?.details?.reason ?? ''}`, + ); + check( + '失败版本落 upload_failed 且可回读', + v3Readback.data?.version?.status === 'upload_failed', + `versionStatus=${v3Readback.data?.version?.status ?? ''}`, + ); + const v3Admin = await api('/admin/api/game-distribution/games', { + token: admin, + }); + const v3AdminGame = (v3Admin.data?.games ?? []).find( + (game) => game.gameId === state.gameId, + ); + check( + '失败版本在后台列表可见(可观测)', + (v3AdminGame?.versions ?? []).some( + (version) => + version.versionId === v3Id && version.status === 'upload_failed', + ), + `statuses=${(v3AdminGame?.versions ?? []).map((version) => version.status).join(',')}`, + ); + + // 清理/误删防护:公开版本 v1 的发行内容在 v2 待审、v3 失败之后仍可读。 + const release = await fetch( + `${API}/api/game-distribution/releases/${state.gameId}/index.html`, + ); + const releaseBody = await release.text(); + check( + '公开版本内容在后续待审/失败之后仍然可读(没有被清理误删)', + release.status === 200 && releaseBody.includes('V1-OK'), + `status=${release.status} marker=${releaseBody.includes('V1-OK')}`, + ); + + console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`); + process.exit(failures === 0 ? 0 : 1); +} + +if (STAGE === 'prepare') { + await runPrepare(); +} else { + await runResume(); +} diff --git a/scripts/pe-version-info.mjs b/scripts/pe-version-info.mjs new file mode 100644 index 000000000..5e5405a3a --- /dev/null +++ b/scripts/pe-version-info.mjs @@ -0,0 +1,217 @@ +/** + * 读取 Windows PE(NSIS 安装包)里的 `VS_VERSION_INFO`。 + * + * 为什么需要它:AGC 的渠道身份会同时进产物文件名**和**PE 资源里的 `ProductName` / + * `FileVersion`。只核对文件名等于相信「谁都没改过这个文件」;2026-09-28 的 dev-mac 事故 + * 就是「清单指着一个身份/版本都不对的包」,mac 侧靠解 `Info.plist` 抓到,Windows 侧 + * 需要对称的能力:确认发布出去的 `.exe` 内部确实写着本渠道产品名与本轮版本。 + * + * 只解析需要的部分:资源目录 → `RT_VERSION` → `VS_FIXEDFILEINFO` 与 `StringFileInfo`。 + */ + +const SECTION_HEADER_SIZE = 40; +const RESOURCE_DIRECTORY_HEADER_SIZE = 16; +const RESOURCE_DIRECTORY_ENTRY_SIZE = 8; +const RT_VERSION = 16; + +function align4(value) { + return (value + 3) & ~3; +} + +/** 读 UTF-16 字符串(含结尾 NUL),并把游标对齐到 4 字节边界。 */ +function readUtf16z(buffer, offset, limit) { + let end = offset; + const endLimit = limit ?? buffer.length - 1; + while (end + 1 < endLimit && !(buffer[end] === 0 && buffer[end + 1] === 0)) { + end += 2; + } + return { + text: buffer.toString('utf16le', offset, end), + next: align4(end + 2), + }; +} + +function readOptionalHeaderLayout(buffer, peOffset) { + const coffOffset = peOffset + 4; + const sectionCount = buffer.readUInt16LE(coffOffset + 2); + const optionalSize = buffer.readUInt16LE(coffOffset + 16); + const optionalOffset = coffOffset + 20; + const magic = buffer.readUInt16LE(optionalOffset); + if (magic !== 0x10b && magic !== 0x20b) { + throw new Error(`PE 可选头 magic 不受支持:0x${magic.toString(16)}`); + } + return { + sectionCount, + optionalOffset, + sectionOffset: optionalOffset + optionalSize, + dataDirectoryOffset: optionalOffset + (magic === 0x20b ? 112 : 96), + }; +} + +function readSections(buffer, layout) { + const sections = []; + for (let index = 0; index < layout.sectionCount; index += 1) { + const base = layout.sectionOffset + index * SECTION_HEADER_SIZE; + sections.push({ + virtualSize: buffer.readUInt32LE(base + 8), + virtualAddress: buffer.readUInt32LE(base + 12), + rawSize: buffer.readUInt32LE(base + 16), + rawPointer: buffer.readUInt32LE(base + 20), + }); + } + return sections; +} + +function rvaToOffset(sections, rva) { + for (const section of sections) { + const size = Math.max(section.virtualSize, section.rawSize); + if (rva >= section.virtualAddress && rva < section.virtualAddress + size) { + return rva - section.virtualAddress + section.rawPointer; + } + } + throw new Error(`资源 RVA 0x${rva.toString(16)} 不属于任何节`); +} + +function readDirectoryEntries(buffer, offset) { + const named = buffer.readUInt16LE(offset + 12); + const ids = buffer.readUInt16LE(offset + 14); + const entries = []; + for (let index = 0; index < named + ids; index += 1) { + const base = + offset + + RESOURCE_DIRECTORY_HEADER_SIZE + + index * RESOURCE_DIRECTORY_ENTRY_SIZE; + entries.push({ + id: buffer.readUInt32LE(base), + offset: buffer.readUInt32LE(base + 4), + }); + } + return entries; +} + +/** 找到 `RT_VERSION` 资源并返回它在文件里的偏移与长度。 */ +function findVersionResource(buffer) { + if (buffer.length < 64 || buffer.readUInt16LE(0) !== 0x5a4d) { + throw new Error('不是 PE 文件(缺少 MZ 头)'); + } + const peOffset = buffer.readUInt32LE(0x3c); + if ( + peOffset + 24 > buffer.length || + buffer.readUInt32LE(peOffset) !== 0x00004550 + ) { + throw new Error('不是 PE 文件(缺少 PE 签名)'); + } + const layout = readOptionalHeaderLayout(buffer, peOffset); + const sections = readSections(buffer, layout); + const resourceRva = buffer.readUInt32LE(layout.dataDirectoryOffset + 2 * 8); + if (!resourceRva) throw new Error('可执行文件没有资源目录'); + const resourceOffset = rvaToOffset(sections, resourceRva); + const typeEntry = readDirectoryEntries(buffer, resourceOffset).find( + (entry) => entry.id === RT_VERSION, + ); + if (!typeEntry) throw new Error('资源目录里没有 RT_VERSION'); + const nameEntry = readDirectoryEntries( + buffer, + resourceOffset + (typeEntry.offset & 0x7fffffff), + )[0]; + const languageEntry = readDirectoryEntries( + buffer, + resourceOffset + (nameEntry.offset & 0x7fffffff), + )[0]; + const dataEntryOffset = resourceOffset + languageEntry.offset; + const versionRva = buffer.readUInt32LE(dataEntryOffset); + const versionSize = buffer.readUInt32LE(dataEntryOffset + 4); + return { + offset: rvaToOffset(sections, versionRva), + size: versionSize, + }; +} + +function readVersionBlock(buffer, offset, limit) { + const length = buffer.readUInt16LE(offset); + const valueLength = buffer.readUInt16LE(offset + 2); + const key = readUtf16z(buffer, offset + 6, limit); + return { + offset, + length, + valueLength, + key: key.text, + valueOffset: key.next, + }; +} + +function formatFixedVersion(ms, ls) { + return `${(ms >>> 16) & 0xffff}.${ms & 0xffff}.${(ls >>> 16) & 0xffff}.${ls & 0xffff}`; +} + +function readStringFileInfo(buffer, block) { + const strings = {}; + let tableCursor = align4(block.valueOffset + block.valueLength); + const blockEnd = block.offset + block.length; + while (tableCursor + 6 <= blockEnd) { + const table = readVersionBlock(buffer, tableCursor, blockEnd); + if (table.length === 0) break; + let entryCursor = align4(table.valueOffset + table.valueLength); + const tableEnd = table.offset + table.length; + while (entryCursor + 6 <= tableEnd) { + const entry = readVersionBlock(buffer, entryCursor, tableEnd); + if (entry.length === 0) break; + strings[entry.key] = buffer + .toString( + 'utf16le', + entry.valueOffset, + entry.valueOffset + entry.valueLength * 2, + ) + .replace(/\0+$/u, ''); + entryCursor = align4(entry.offset + entry.length); + } + tableCursor = align4(table.offset + table.length); + } + return strings; +} + +/** + * 读出 PE 版本资源里的版本号与字符串表。 + * + * 返回 `{ fileVersion, productVersion, strings }`;`strings` 通常含 + * `ProductName` / `FileDescription` / `FileVersion` / `ProductVersion`。 + */ +export function readPortableExecutableVersionInfo(buffer) { + if (!Buffer.isBuffer(buffer)) throw new Error('需要传入 PE 文件的 Buffer'); + const resource = findVersionResource(buffer); + const root = readVersionBlock( + buffer, + resource.offset, + resource.offset + resource.size, + ); + if (root.key !== 'VS_VERSION_INFO') { + throw new Error(`版本资源根节点异常:${root.key}`); + } + const fixedOffset = root.valueOffset; + const signature = buffer.readUInt32LE(fixedOffset); + if (signature !== 0xfeef04bd) { + throw new Error(`VS_FIXEDFILEINFO 签名异常:0x${signature.toString(16)}`); + } + const fixed = { + fileVersion: formatFixedVersion( + buffer.readUInt32LE(fixedOffset + 8), + buffer.readUInt32LE(fixedOffset + 12), + ), + productVersion: formatFixedVersion( + buffer.readUInt32LE(fixedOffset + 16), + buffer.readUInt32LE(fixedOffset + 20), + ), + }; + let strings = {}; + let cursor = align4(fixedOffset + root.valueLength); + const rootEnd = root.offset + root.length; + while (cursor + 6 <= rootEnd) { + const child = readVersionBlock(buffer, cursor, rootEnd); + if (child.length === 0) break; + if (child.key === 'StringFileInfo') { + strings = readStringFileInfo(buffer, child); + } + cursor = align4(child.offset + child.length); + } + return { ...fixed, strings }; +} diff --git a/scripts/pe-version-info.test.mjs b/scripts/pe-version-info.test.mjs new file mode 100644 index 000000000..04082141c --- /dev/null +++ b/scripts/pe-version-info.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { readPortableExecutableVersionInfo } from './pe-version-info.mjs'; + +// 真实安装包(dev-win 0.1.154 / release-win 0.1.150)已在本轮用线上对象验证过解析结果; +// 这里只守住「非 PE 输入必须失败关闭」这条,避免解析器坏掉时静默返回空身份。 +test('rejects buffers that are not PE files', () => { + assert.throws( + () => readPortableExecutableVersionInfo(Buffer.alloc(128)), + /不是 PE 文件/u, + ); + assert.throws( + () => readPortableExecutableVersionInfo('not a buffer'), + /需要传入 PE 文件的 Buffer/u, + ); +}); + +test('rejects a PE without a version resource', () => { + const buffer = Buffer.alloc(512); + buffer.writeUInt16LE(0x5a4d, 0); + buffer.writeUInt32LE(0x80, 0x3c); + buffer.writeUInt32LE(0x00004550, 0x80); + buffer.writeUInt16LE(1, 0x84 + 2); + buffer.writeUInt16LE(0xe0, 0x84 + 16); + buffer.writeUInt16LE(0x10b, 0x98); + assert.throws( + () => readPortableExecutableVersionInfo(buffer), + /没有资源目录|RT_VERSION|属于任何节/u, + ); +});