合并最新主分支到 Web 环境预检修复分支
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m2s
Project CI / Backend tests (pull_request) Successful in 4m27s
Project CI / Frontend tests (pull_request) Successful in 2m13s
Project CI / Native shell tests (pull_request) Successful in 6m34s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m41s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m30s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m47s
Project CI / Repository checks (pull_request) Successful in 2m43s

同步远端 master 至 8b67efbcf
保留双方排障记录并解决文档末尾冲突
保留 Web 环境探测隔离与独立测试夹具修复
This commit is contained in:
2026-09-28 23:26:18 +08:00
24 changed files with 2799 additions and 41 deletions
@@ -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),
@@ -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,
@@ -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,
);
});
@@ -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>${key}</key>\\s*<string>([^<]*)</string>`,
'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}`,
);
}
}
@@ -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 `<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>CFBundleShortVersionString</key><string>${version}</string>
<key>CFBundleIdentifier</key><string>${identifier}</string>
<key>CFBundleName</key><string>${name}</string>
<key>CFBundleDisplayName</key><string>${name}</string>
</dict></plist>`;
}
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',
}),
);
});
@@ -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);
@@ -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 {
@@ -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<Value, platform_llm::LlmError> {
) -> Result<Option<Value>, 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(&params, 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() {
@@ -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<CodexAppServerInner>,
reason: &str,
) -> Result<ProcessTreeExitProof, String> {
// 关闭原因必须可观测: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() {
@@ -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/<gameId>/`;`check-game-distribution-web-e2e` 里 iframe src 就是同源 `/games/<gameId>/`;三份 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/<gameId>/`,边缘原样映射;隔离靠 `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 条要求的「有任何核心路径未验证时不标记上线完成」。
@@ -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 凭据与授权,本地无法执行。
@@ -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`):只读拉取 `<OSS>/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 构建取证。
@@ -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`、各自原地更新。上面这些只证明「产物身份正确」,不代替真机并存验收。
@@ -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` 下)外,临时插桩已全部回滚。
@@ -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/<gameId>/` 的不透明来源方案,主规范 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 里,属于可丢弃的测试产物。
- 边界说明:失败用例都停在「上传校验」这一步(单发 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/<gameId>/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/<gameId>` 与发行入口 `/api/game-distribution/releases/<gameId>/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_id>game_[0-9a-f]{32})(?<game_path>/.*)?$` 映射到发行网关并 `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 项。
@@ -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 缓存窗口与真实客户端安装版的自动上传仍未验证。
- 边界:证据来自本机 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`。
@@ -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` 被清掉、六个声明组件与清单/声明保留。
@@ -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 启动累计):'
+3
View File
@@ -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",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+217
View File
@@ -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 };
}
+31
View File
@@ -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,
);
});