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