diff --git a/.gitignore b/.gitignore
index 88a842855..652820e39 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,6 +58,12 @@ temp*build*/
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
/apps/ai-game-creator-shell/src-tauri/resources/claude-agent/
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
+/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/
+/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/
+/apps/ai-game-creator-shell/src-tauri/resources/plugins-backup-*/
+/apps/ai-game-creator-shell/src-tauri/resources/codex/*-backup-*/
+/apps/ai-game-creator-shell/src-tauri/resources/claude-agent-staging-*/
+/apps/ai-game-creator-shell/src-tauri/resources/claude-agent-backup-*/
/apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json
/apps/mobile-shell/.expo/
diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx
index 7916e60e3..1e185e9a5 100644
--- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx
+++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx
@@ -16,7 +16,10 @@ import {
getAdminProjectSnapshotChannels,
listAdminProjectSnapshots,
} from '../api/adminApiClient';
-import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
+import type {
+ AdminProjectSnapshotEntry,
+ AdminProjectSnapshotListResponse,
+} from '../api/adminApiTypes';
import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage';
vi.mock('../api/adminApiClient', async () => ({
@@ -58,6 +61,241 @@ afterEach(() => {
vi.useRealTimers();
});
+test('全量读取后按同步时间和身份排序,本地分页并支持刷新和恢复默认', async () => {
+ const olderProjects = Array.from({ length: 22 }, (_, index) => ({
+ ...entry,
+ projectId: `older-${index}`,
+ projectName: `旧项目 ${index}`,
+ syncedAtMs: entry.syncedAtMs + index,
+ }));
+ const tiedProjects = [
+ { ...entry, userId: 'user-2', projectId: 'p-1', projectName: '同秒乙' },
+ { ...entry, userId: 'user-1', projectId: 'p-2', projectName: '同秒甲二' },
+ { ...entry, userId: 'user-1', projectId: 'p-1', projectName: '同秒甲一' },
+ ].map((project) => ({ ...project, syncedAtMs: entry.syncedAtMs + 100 }));
+ vi.mocked(listAdminProjectSnapshots)
+ .mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' })
+ .mockResolvedValueOnce({ items: olderProjects, nextCursor: 'scan-2' })
+ // 后端有界扫描可能返回带下一游标的空页,不能提前结束。
+ .mockResolvedValueOnce({ items: [], nextCursor: 'scan-3' })
+ .mockResolvedValueOnce({
+ items: [
+ ...tiedProjects,
+ {
+ ...olderProjects[0]!,
+ projectName: '更新项目',
+ syncedAtMs: entry.syncedAtMs + 200,
+ },
+ ],
+ nextCursor: null,
+ });
+ render();
+ await screen.findByText('三消工程');
+ fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
+ await screen.findByRole('button', { name: '恢复默认顺序' });
+ const rowNames = () =>
+ screen
+ .getAllByRole('row')
+ .map((row) => row.querySelector('td strong')?.textContent)
+ .filter(Boolean);
+ expect(rowNames().slice(0, 4)).toEqual([
+ '更新项目',
+ '同秒甲一',
+ '同秒甲二',
+ '同秒乙',
+ ]);
+ expect(rowNames()).toHaveLength(20);
+ expect(screen.getByRole('navigation').textContent).toContain('共 25 个');
+ expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
+ 4,
+ 'token',
+ { cursor: 'scan-3', limit: 100, channel: 'dev' },
+ expect.any(AbortSignal),
+ );
+ fireEvent.click(screen.getByRole('button', { name: '下一页' }));
+ expect(rowNames()).toHaveLength(5);
+ expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
+ fireEvent.click(screen.getByRole('button', { name: '上一页' }));
+ expect(rowNames()[0]).toBe('更新项目');
+ fireEvent.change(screen.getByLabelText('每页条数'), {
+ target: { value: '50' },
+ });
+ expect(rowNames()).toHaveLength(25);
+ expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(4);
+
+ vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
+ items: [{ ...entry, projectName: '刷新后的项目' }],
+ nextCursor: null,
+ });
+ fireEvent.click(screen.getByRole('button', { name: '刷新' }));
+ await screen.findByText('刷新后的项目');
+ expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
+ 5,
+ 'token',
+ { cursor: null, limit: 100, channel: 'dev' },
+ expect.any(AbortSignal),
+ );
+ fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
+ await screen.findByText('三消工程');
+ expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
+ 6,
+ 'token',
+ { cursor: null, limit: 50, channel: 'dev' },
+ expect.any(AbortSignal),
+ );
+});
+
+test('恢复默认顺序失败保留排序分页,重试成功后切换远端第一页及游标', async () => {
+ const projects = Array.from({ length: 45 }, (_, index) => ({
+ ...entry,
+ projectId: `sorted-${index}`,
+ projectName: `排序项目 ${index}`,
+ syncedAtMs: entry.syncedAtMs - index,
+ }));
+ vi.mocked(listAdminProjectSnapshots)
+ .mockResolvedValueOnce({ items: [entry], nextCursor: null })
+ .mockResolvedValueOnce({ items: projects, nextCursor: null })
+ .mockRejectedValueOnce(new Error('恢复失败'))
+ .mockResolvedValueOnce({
+ items: [{ ...entry, projectName: '默认第一页' }],
+ nextCursor: 'default-next',
+ })
+ .mockResolvedValueOnce({
+ items: [{ ...entry, projectName: '默认第二页' }],
+ nextCursor: null,
+ });
+ render();
+ await screen.findByText('三消工程');
+ fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
+ await screen.findByText('排序项目 0');
+ fireEvent.click(screen.getByRole('button', { name: '下一页' }));
+ fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
+ await screen.findByText('恢复失败');
+ expect(screen.getByText('排序项目 20')).toBeTruthy();
+ expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
+ expect(screen.getByRole('navigation').textContent).toContain('共 45 个');
+ expect(screen.getByRole('button', { name: '恢复默认顺序' })).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: '下一页' }));
+ expect(screen.getByText('排序项目 40')).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: '上一页' }));
+ expect(screen.getByText('排序项目 20')).toBeTruthy();
+ expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(3);
+
+ fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
+ await screen.findByText('默认第一页');
+ expect(screen.getByRole('navigation').textContent).toContain('第 1 页');
+ expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy();
+ expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
+ 4,
+ 'token',
+ { cursor: null, limit: 20, channel: 'dev' },
+ expect.any(AbortSignal),
+ );
+ fireEvent.click(screen.getByRole('button', { name: '下一页' }));
+ await screen.findByText('默认第二页');
+ expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
+ expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
+ 5,
+ 'token',
+ { cursor: 'default-next', limit: 20, channel: 'dev' },
+ expect.any(AbortSignal),
+ );
+});
+
+test('全量排序中途失败不发布部分结果,重试刷新失败保留完整排序', async () => {
+ vi.mocked(listAdminProjectSnapshots)
+ .mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' })
+ .mockResolvedValueOnce({
+ items: [{ ...entry, projectName: '部分读取项目' }],
+ nextCursor: 'scan-next',
+ })
+ .mockRejectedValueOnce(new Error('全量读取失败'));
+ render();
+ await screen.findByText('三消工程');
+ fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
+ await screen.findByText('全量读取失败');
+ expect(screen.getByText('三消工程')).toBeTruthy();
+ expect(screen.queryByText('部分读取项目')).toBeNull();
+ expect(screen.queryByRole('button', { name: '恢复默认顺序' })).toBeNull();
+ expect(
+ screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
+ ).toBe(false);
+
+ vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
+ items: [{ ...entry, projectName: '完整排序项目' }],
+ nextCursor: null,
+ });
+ fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
+ await screen.findByText('完整排序项目');
+ vi.mocked(listAdminProjectSnapshots).mockRejectedValueOnce(
+ new Error('刷新读取失败'),
+ );
+ fireEvent.click(screen.getByRole('button', { name: '刷新' }));
+ await screen.findByText('刷新读取失败');
+ expect(screen.getByText('完整排序项目')).toBeTruthy();
+ expect(screen.getByRole('navigation').textContent).toContain('共 1 个');
+});
+
+test('取消或切换渠道时中断全量读取,忽略随后到达的旧响应', async () => {
+ vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({
+ defaultChannel: 'dev',
+ channels: ['dev', 'release'],
+ });
+ let finishCancelled!: (value: AdminProjectSnapshotListResponse) => void;
+ let finishOldChannel!: (value: AdminProjectSnapshotListResponse) => void;
+ vi.mocked(listAdminProjectSnapshots)
+ .mockResolvedValueOnce({ items: [entry], nextCursor: null })
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishCancelled = resolve;
+ }),
+ )
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finishOldChannel = resolve;
+ }),
+ )
+ .mockResolvedValueOnce({
+ items: [{ ...entry, channel: 'release', projectName: '正式渠道项目' }],
+ nextCursor: null,
+ });
+ render();
+ await screen.findByText('三消工程');
+ fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
+ const cancelledSignal = vi.mocked(listAdminProjectSnapshots).mock
+ .calls[1]![2]!;
+ fireEvent.click(screen.getByRole('button', { name: '取消排序' }));
+ expect(cancelledSignal.aborted).toBe(true);
+ await act(async () => {
+ finishCancelled({
+ items: [{ ...entry, projectName: '已取消项目' }],
+ nextCursor: null,
+ });
+ });
+ expect(screen.getByText('三消工程')).toBeTruthy();
+ expect(screen.queryByText('已取消项目')).toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
+ const oldChannelSignal = vi.mocked(listAdminProjectSnapshots).mock
+ .calls[2]![2]!;
+ fireEvent.change(screen.getByLabelText('项目工程渠道'), {
+ target: { value: 'release' },
+ });
+ await screen.findByText('正式渠道项目');
+ expect(oldChannelSignal.aborted).toBe(true);
+ await act(async () => {
+ finishOldChannel({
+ items: [{ ...entry, projectName: '旧渠道项目' }],
+ nextCursor: null,
+ });
+ });
+ expect(screen.getByText('正式渠道项目')).toBeTruthy();
+ expect(screen.queryByText('旧渠道项目')).toBeNull();
+ expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy();
+});
+
test('按项目展示完整性并限制未完成工程下载', async () => {
vi.mocked(listAdminProjectSnapshots).mockResolvedValue({
items: [
diff --git a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx
index 026adec6b..b8c6d7b3a 100644
--- a/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx
+++ b/apps/admin-web/src/pages/AdminProjectSnapshotsPage.tsx
@@ -10,7 +10,7 @@ import {
AdminPagination,
AdminStatusPill,
} from '@genarrative/shared/components';
-import { Download, RefreshCcw, X } from 'lucide-react';
+import { ArrowDownWideNarrow, Download, RefreshCcw, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
@@ -50,6 +50,9 @@ const snapshotStatuses: Record<
const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
+const MAX_SORT_PAGES = 200;
+const MAX_SORT_PROJECTS = 10_000;
+const SORT_TIMEOUT_MS = 60_000;
export function AdminProjectSnapshotsPage({
token,
@@ -64,6 +67,9 @@ export function AdminProjectSnapshotsPage({
const [channelOptions, setChannelOptions] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [hasLoaded, setHasLoaded] = useState(false);
+ const [isSorted, setIsSorted] = useState(false);
+ const [isSorting, setIsSorting] = useState(false);
+ const [sortProgress, setSortProgress] = useState(0);
const [errorMessage, setErrorMessage] = useState('');
const [downloadingKey, setDownloadingKey] = useState(null);
const listController = useRef(null);
@@ -71,9 +77,23 @@ export function AdminProjectSnapshotsPage({
// 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回,
// 因此按页记录已取得的游标,翻页只在这些游标之间移动。
const pageCursors = useRef<(string | null)[]>([null]);
+ // 全量读取完成后才发布;分页只切片本次读到的完整集合。
+ const sortedItems = useRef(null);
const loadPage = useCallback(
- async (cursor: string | null, limit: number, page: number) => {
+ async (
+ cursor: string | null,
+ limit: number,
+ page: number,
+ restoreDefault = false,
+ ) => {
+ if (!restoreDefault && sortedItems.current !== null) {
+ setItems(sortedItems.current.slice((page - 1) * limit, page * limit));
+ setPageIndex(page);
+ setHasLoaded(true);
+ setErrorMessage('');
+ return;
+ }
listController.current?.abort();
const controller = new AbortController();
listController.current = controller;
@@ -86,6 +106,12 @@ export function AdminProjectSnapshotsPage({
controller.signal,
);
if (controller.signal.aborted) return;
+ // 远端第一页读取成功后再切换模式,失败时保留完整排序和分页。
+ if (restoreDefault) {
+ sortedItems.current = null;
+ setIsSorted(false);
+ pageCursors.current = [null];
+ }
setItems(response.items);
setNextCursor(response.nextCursor);
setPageIndex(page);
@@ -126,8 +152,16 @@ export function AdminProjectSnapshotsPage({
return () => controller.abort();
}, [token, onUnauthorized]);
+ useEffect(() => {
+ sortedItems.current = null;
+ setIsSorted(false);
+ }, [token, channel]);
+
useEffect(() => {
if (channel === null) return undefined;
+ setIsSorting(false);
+ setIsLoading(false);
+ setSortProgress(0);
pageCursors.current = [null];
setItems([]);
setNextCursor(null);
@@ -144,6 +178,10 @@ export function AdminProjectSnapshotsPage({
}, [loadPage, pageSize, channel]);
function goToNextPage() {
+ if (sortedItems.current !== null) {
+ void loadPage(null, pageSize, pageIndex + 1);
+ return;
+ }
if (!nextCursor) return;
pageCursors.current[pageIndex] = nextCursor;
void loadPage(nextCursor, pageSize, pageIndex + 1);
@@ -159,6 +197,10 @@ export function AdminProjectSnapshotsPage({
}
function refreshCurrentPage() {
+ if (sortedItems.current !== null) {
+ void sortAllProjects();
+ return;
+ }
void loadPage(
pageCursors.current[pageIndex - 1] ?? null,
pageSize,
@@ -166,6 +208,90 @@ export function AdminProjectSnapshotsPage({
);
}
+ async function sortAllProjects() {
+ if (channel === null) return;
+ listController.current?.abort();
+ const controller = new AbortController();
+ listController.current = controller;
+ setIsLoading(true);
+ setIsSorting(true);
+ setSortProgress(0);
+ setErrorMessage('');
+ let timedOut = false;
+ const timeout = setTimeout(() => {
+ timedOut = true;
+ controller.abort();
+ }, SORT_TIMEOUT_MS);
+ try {
+ const projects = new Map();
+ const seenCursors = new Set();
+ let cursor: string | null = null;
+ for (let page = 0; page < MAX_SORT_PAGES; page += 1) {
+ const response = await listAdminProjectSnapshots(
+ token,
+ { cursor, limit: 100, channel },
+ controller.signal,
+ );
+ if (controller.signal.aborted) return;
+ for (const project of response.items) {
+ const key = snapshotKey(project);
+ const previous = projects.get(key);
+ if (!previous || project.syncedAtMs >= previous.syncedAtMs) {
+ projects.set(key, project);
+ }
+ }
+ if (projects.size > MAX_SORT_PROJECTS) {
+ throw new Error('项目超过 10,000 个,无法完成全量排序');
+ }
+ setSortProgress(projects.size);
+ if (!response.nextCursor) {
+ const result = [...projects.values()].sort(
+ (left, right) =>
+ right.syncedAtMs - left.syncedAtMs ||
+ compareIds(left.userId, right.userId) ||
+ compareIds(left.projectId, right.projectId),
+ );
+ sortedItems.current = result;
+ setIsSorted(true);
+ setItems(result.slice(0, pageSize));
+ setPageIndex(1);
+ setNextCursor(null);
+ setHasLoaded(true);
+ return;
+ }
+ if (seenCursors.has(response.nextCursor)) {
+ throw new Error('项目列表游标重复,无法完成全量排序,请重试');
+ }
+ seenCursors.add(response.nextCursor);
+ cursor = response.nextCursor;
+ }
+ throw new Error('项目读取超过 200 页,无法完成全量排序');
+ } catch (error: unknown) {
+ if (!controller.signal.aborted) {
+ handlePageError(error, onUnauthorized, setErrorMessage);
+ }
+ } finally {
+ clearTimeout(timeout);
+ if (listController.current === controller) {
+ listController.current = null;
+ setIsLoading(false);
+ setIsSorting(false);
+ if (timedOut) setErrorMessage('全量项目读取超时,请重试');
+ }
+ }
+ }
+
+ function cancelSorting() {
+ listController.current?.abort();
+ listController.current = null;
+ setIsLoading(false);
+ setIsSorting(false);
+ }
+
+ function restoreDefaultOrder() {
+ void loadPage(null, pageSize, 1, true);
+ }
+
async function downloadProject(entry: AdminProjectSnapshotEntry) {
if (downloadController.current || entry.status === 'partial') return;
const controller = new AbortController();
@@ -236,6 +362,32 @@ export function AdminProjectSnapshotsPage({
))}
+ void sortAllProjects()
+ }
+ >
+
+ {isSorted ? '恢复默认顺序' : '按同步时间排序'}
+
+ {isSorting ? (
+ <>
+
+ 正在读取全部项目,已读取 {sortProgress} 个
+
+
+
+ 取消排序
+
+ >
+ ) : null}
第 {pageIndex} 页
{items.length ? `,本页 ${items.length} 个项目` : ''}
+ {isSorted
+ ? `,共 ${sortedItems.current?.length ?? 0} 个,按同步时间从新到旧`
+ : ''}
>
}
pageSize={{
@@ -279,7 +434,12 @@ export function AdminProjectSnapshotsPage({
}}
busy={isLoading}
previous={{ disabled: pageIndex <= 1, onClick: goToPreviousPage }}
- next={{ disabled: !nextCursor, onClick: goToNextPage }}
+ next={{
+ disabled: isSorted
+ ? pageIndex * pageSize >= (sortedItems.current?.length ?? 0)
+ : !nextCursor,
+ onClick: goToNextPage,
+ }}
/>
) : null
}
@@ -375,6 +535,10 @@ function snapshotKey(entry: AdminProjectSnapshotEntry) {
return `${entry.userId}/${entry.projectId}`;
}
+function compareIds(left: string, right: string) {
+ return left < right ? -1 : left > right ? 1 : 0;
+}
+
function formatBytes(bytes: number) {
const units = ['B', 'KiB', 'MiB', 'GiB'];
const unit = Math.min(
diff --git a/apps/ai-game-creator-shell/.taurignore b/apps/ai-game-creator-shell/.taurignore
deleted file mode 100644
index 8ec126556..000000000
--- a/apps/ai-game-creator-shell/.taurignore
+++ /dev/null
@@ -1,4 +0,0 @@
-# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
-# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
-# “构建 -> 监听 -> 再构建”的自触发循环。
-resources/plugins/
diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json
index 83dbe4b56..b0cef6b19 100644
--- a/apps/ai-game-creator-shell/package.json
+++ b/apps/ai-game-creator-shell/package.json
@@ -13,6 +13,10 @@
"skill-pack:check": "node scripts/check-skill-pack.mjs",
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
+ "bundled-resources:check": "node scripts/check-package-layout.mjs",
+ "bundled-resources:sync": "node scripts/check-package-layout.mjs --write",
+ "bundled-resources:prepare": "node scripts/prepare-bundled-resources.mjs",
+ "bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs",
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
"config": "node scripts/game-creator-config-wizard.mjs",
@@ -24,7 +28,7 @@
"agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill",
"agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs",
"agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill",
- "typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && node scripts/check-config.mjs"
+ "typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.3.285",
diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs
index 3ceaddaf8..bfb958806 100644
--- a/apps/ai-game-creator-shell/scripts/build-release.mjs
+++ b/apps/ai-game-creator-shell/scripts/build-release.mjs
@@ -11,6 +11,8 @@ import {
} from './agc-global-version.mjs';
import {
defaultEditorFeatures,
+ readCargoTarget,
+ resolveEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
import {
@@ -19,6 +21,10 @@ import {
resolveReleaseChannel,
} from './channel-identity.mjs';
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
+import {
+ prepareBundledResources,
+ supportedHostTarget,
+} from './prepare-bundled-resources.mjs';
import { stageNodeRuntime } from './stage-node-runtime.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -40,26 +46,7 @@ function defaultTarget() {
}
function explicitBuildTarget(args) {
- let target;
- const separator = args.indexOf('--');
- const options = separator < 0 ? args : args.slice(0, separator);
- for (let index = 0; index < options.length; index += 1) {
- const argument = options[index];
- let value;
- if (argument === '--target' || argument === '-t') {
- value = options[++index];
- } else if (argument.startsWith('--target=')) {
- value = argument.slice('--target='.length);
- } else {
- continue;
- }
- if (!value?.trim() || value.startsWith('-')) {
- throw new Error('--target 缺少有效目标');
- }
- if (target !== undefined) throw new Error('不能重复指定 --target');
- target = value.trim();
- }
- return target;
+ return readCargoTarget(args);
}
function validateReleaseTarget(target) {
@@ -469,10 +456,32 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
return configPath;
}
+/// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。
+export function stageBundledResources(
+ target,
+ { prepare = prepareBundledResources, features } = {},
+) {
+ const summaries = prepare({
+ target,
+ features: new Set(features ?? defaultEditorFeatures(target)),
+ profile: 'release',
+ log: (line) => console.log(`[ai-game-creator-shell] ${line}`),
+ });
+ for (const summary of summaries) {
+ console.log(`[ai-game-creator-shell] ${summary}`);
+ }
+}
+
export function runTauriBuild(
args = [],
context = resolveReleaseContext(args),
- { spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
+ {
+ spawn = spawnSync,
+ stageRuntime = stageNodeRuntime,
+ stageBundled = stageBundledResources,
+ platform = process.platform,
+ arch = process.arch,
+ } = {},
) {
if (
explicitBuildTarget(args) &&
@@ -480,14 +489,44 @@ export function runTauriBuild(
) {
throw new Error('构建参数与发布上下文目标不一致');
}
- const tauriArguments = buildTauriBuildArguments(args, context.target);
- const { channel, target } = context;
- if (!args.includes('--no-bundle')) stageRuntime(target);
- const configPath = writeChannelConfigFile(
- channel,
- target,
- !args.includes('--no-bundle'),
+ const tauriArguments = buildTauriBuildArguments(
+ args,
+ context.target,
+ platform,
);
+ const bundling = !args.includes('--no-bundle');
+ // 无显式 target 的 no-bundle 由 Cargo 构建宿主平台,不使用默认发布目标。
+ const resourceTarget =
+ explicitBuildTarget(args) ||
+ (bundling ? context.target : supportedHostTarget(platform, arch));
+ if (
+ !bundling &&
+ !resourceTarget &&
+ defaultEditorFeatures(platform).length > 0
+ ) {
+ throw new Error(
+ `当前宿主 ${platform}/${arch} 不在随包资源声明覆盖内;请用 --target 指定受支持的构建目标`,
+ );
+ }
+ const features = resolveEditorFeatures({
+ argv: tauriArguments,
+ target: resourceTarget ?? platform,
+ env: {},
+ });
+ const { channel } = context;
+ if (bundling) {
+ stageRuntime(resourceTarget);
+ }
+ if (resourceTarget) {
+ stageBundled(resourceTarget, { features });
+ } else {
+ console.log(
+ '[ai-game-creator-shell] 当前宿主平台不参与客户端随包资源构建,跳过资源准备',
+ );
+ }
+ // Linux smoke 不发布 updater,沿用渠道上下文;桌面宿主必须与实际构建目标一致。
+ const target = resourceTarget ?? context.target;
+ const configPath = writeChannelConfigFile(channel, target, bundling);
console.log(
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
);
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 8724bcce1..4b6fc20cc 100644
--- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs
+++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs
@@ -38,6 +38,7 @@ import {
AGC_PRODUCT_NAME,
resolveChannelInstallIdentity,
} from './channel-identity.mjs';
+import { supportedHostTarget } from './prepare-bundled-resources.mjs';
const windowsTarget = 'x86_64-pc-windows-msvc';
const universalTarget = 'universal-apple-darwin';
@@ -504,6 +505,8 @@ test('packaged renderer receives the same channel as the updater manifest', () =
// 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的
// Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。
stageRuntime: () => {},
+ // 同上:随包资源准备会读取真实上游包,本用例只关心渠道环境变量。
+ stageBundled: () => {},
spawn: (_binary, _args, options) => {
spawnOptions = options;
return { status: 0 };
@@ -594,6 +597,8 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
seenContexts.push(context);
runTauriBuild(args, context, {
stageRuntime: () => {},
+ // 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
+ stageBundled: () => {},
spawn: (_binary, command) => {
const configIndex = command.lastIndexOf('--config');
const config = JSON.parse(
@@ -843,6 +848,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme
context,
{
stageRuntime: () => {},
+ stageBundled: () => {},
spawn: (_binary, command) => {
assert.ok(
command.includes(
@@ -960,6 +966,10 @@ test('release stages Node before Tauri and injects its resource mapping only for
assert.equal(target, windowsTarget);
events.push('stage');
},
+ stageBundled(target) {
+ assert.equal(target, windowsTarget);
+ events.push('bundled');
+ },
spawn(_binary, args) {
events.push('build');
const config = JSON.parse(
@@ -975,12 +985,24 @@ test('release stages Node before Tauri and injects its resource mapping only for
return { status: 0 };
},
});
- assert.deepEqual(events, ['stage', 'build']);
+ assert.deepEqual(events, ['stage', 'bundled', 'build']);
+ events.length = 0;
runTauriBuild(['--no-bundle', '--target', windowsTarget], context, {
stageRuntime() {
- assert.fail('no-bundle must not stage resources');
+ assert.fail('no-bundle must not stage node runtime');
+ },
+ stageBundled(target, options) {
+ // app 构建本身就需要随包资源,因此 --no-bundle 也要 staging,
+ // 且必须拿到与 cargo 相同的 feature 集(避免「cargo 开、staging 没开」)。
+ assert.equal(target, windowsTarget);
+ assert.ok(
+ options?.features?.length > 0,
+ 'bundled staging 必须收到 feature 集',
+ );
+ events.push('bundled');
},
spawn(_binary, args) {
+ events.push('build');
const config = JSON.parse(
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
);
@@ -993,12 +1015,16 @@ test('release stages Node before Tauri and injects its resource mapping only for
return { status: 0 };
},
});
+ assert.deepEqual(events, ['bundled', 'build']);
assert.throws(
() =>
runTauriBuild(['--target', windowsTarget], context, {
stageRuntime() {
throw new Error('missing runtime');
},
+ stageBundled() {
+ assert.fail('invalid runtime must prevent bundled staging');
+ },
spawn() {
assert.fail('invalid runtime must prevent build');
},
@@ -1007,6 +1033,297 @@ test('release stages Node before Tauri and injects its resource mapping only for
);
});
+const windowsEditorFeatures = [
+ 'cocos-editor-execute',
+ 'unity-editor-execute',
+ 'godot-editor-execute',
+];
+
+function uniqueSorted(values) {
+ return [...new Set(values ?? [])].sort();
+}
+
+function splitFeatureList(value) {
+ return String(value ?? '')
+ .split(',')
+ .map((name) => name.trim())
+ .filter(Boolean);
+}
+
+/// 最终命令行里 Cargo 实际会收到的 feature 集:tauri 自身的 `--features` / `-f`
+/// 与 `--` 之后的 runner 参数(tauri 会把它们追加到 cargo 命令)都算数,
+/// 随包资源准备必须拿到同一集合。
+function cargoFeaturesFromCommand(command) {
+ const argv = command.slice(command.indexOf('--') + 1);
+ const features = [];
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = String(argv[index]);
+ if (argument.startsWith('--features=')) {
+ features.push(...splitFeatureList(argument.slice('--features='.length)));
+ } else if (argument === '--features' || argument === '-f') {
+ features.push(...splitFeatureList(argv[index + 1]));
+ index += 1;
+ } else if (/^-f.+/u.test(argument)) {
+ features.push(...splitFeatureList(argument.slice(2)));
+ }
+ }
+ return uniqueSorted(features);
+}
+
+/**
+ * PR520 的 CI 回归:`tauri build --no-bundle` 不带显式 target 时编译的是**宿主**平台,
+ * 而发布上下文默认目标始终是 Windows。随包资源与 feature 集必须跟着宿主走,
+ * 否则 Linux 宿主会拿着 Windows 目标去 staging(CI smoke 直接失败),
+ * 还会出现「cargo 没开 feature、staging 开了」的错配。
+ */
+test('no-bundle 在无随包资源声明的宿主(Linux)既不 staging 也不注入 Windows feature', () => {
+ const context = resolveReleaseContext(['--no-bundle'], {});
+ assert.equal(context.target, windowsTarget);
+ let command;
+ withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => {
+ runTauriBuild(['--no-bundle'], context, {
+ platform: 'linux',
+ arch: 'x64',
+ stageRuntime() {
+ assert.fail('--no-bundle 不得准备 Node 运行时');
+ },
+ stageBundled(target) {
+ assert.fail(`Linux 宿主没有随包资源声明,不得 staging ${target}`);
+ },
+ spawn(_binary, args) {
+ command = args;
+ return { status: 0 };
+ },
+ });
+ });
+ assert.ok(command, '宿主不受声明覆盖时仍必须完成本次构建');
+ assert.ok(!command.includes('--target'), '宿主构建不得打显式 target');
+ assert.deepEqual(
+ cargoFeaturesFromCommand(command),
+ [],
+ 'Linux 宿主不得注入 Windows 编辑器 feature',
+ );
+});
+
+test('no-bundle 在 macOS / Windows 宿主按真实宿主三元组准备随包资源', () => {
+ const context = resolveReleaseContext(['--no-bundle'], {});
+ for (const [platform, arch, hostTarget, expectedFeatures] of [
+ ['darwin', 'arm64', 'aarch64-apple-darwin', []],
+ ['darwin', 'x64', 'x86_64-apple-darwin', []],
+ ['win32', 'x64', windowsTarget, windowsEditorFeatures],
+ ]) {
+ const label = `${platform}/${arch}`;
+ let staged;
+ let command;
+ withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => {
+ runTauriBuild(['--no-bundle'], context, {
+ platform,
+ arch,
+ stageRuntime() {
+ assert.fail('--no-bundle 不得准备 Node 运行时');
+ },
+ stageBundled(target, options) {
+ staged = { target, features: options?.features };
+ },
+ spawn(_binary, args) {
+ command = args;
+ return { status: 0 };
+ },
+ });
+ });
+ assert.equal(
+ staged?.target,
+ hostTarget,
+ `${label} 必须按宿主三元组 staging`,
+ );
+ assert.deepEqual(
+ uniqueSorted(staged?.features),
+ uniqueSorted(expectedFeatures),
+ `${label} staging feature 与宿主不一致`,
+ );
+ assert.deepEqual(
+ cargoFeaturesFromCommand(command),
+ uniqueSorted(expectedFeatures),
+ `${label} Cargo feature 与 staging 不一致`,
+ );
+ assert.ok(
+ !command.includes('--target'),
+ `${label} 宿主构建不得打显式 target`,
+ );
+ }
+});
+
+test('no-bundle 的显式 target 优先于宿主:跨平台 / 跨架构仍按参数准备', () => {
+ for (const [platform, arch, target, expectedFeatures] of [
+ ['linux', 'x64', windowsTarget, windowsEditorFeatures],
+ ['darwin', 'arm64', 'x86_64-apple-darwin', []],
+ ]) {
+ const args = ['--no-bundle', '--target', target];
+ const context = resolveReleaseContext(args, {});
+ const label = `${platform}/${arch} → ${target}`;
+ let staged;
+ let command;
+ withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => {
+ runTauriBuild(args, context, {
+ platform,
+ arch,
+ stageRuntime() {
+ assert.fail('--no-bundle 不得准备 Node 运行时');
+ },
+ stageBundled(stageTarget, options) {
+ staged = { target: stageTarget, features: options?.features };
+ },
+ spawn(_binary, argv) {
+ command = argv;
+ return { status: 0 };
+ },
+ });
+ });
+ assert.equal(staged?.target, target, `${label} 必须按显式 target staging`);
+ assert.ok(
+ command.includes(target),
+ `${label} 必须把显式 target 传给 Tauri`,
+ );
+ assert.deepEqual(
+ uniqueSorted(staged?.features),
+ uniqueSorted(expectedFeatures),
+ `${label} staging feature 与预期不一致`,
+ );
+ assert.deepEqual(
+ cargoFeaturesFromCommand(command),
+ uniqueSorted(expectedFeatures),
+ `${label} Cargo feature 与 staging 不一致`,
+ );
+ }
+});
+
+test('打包构建(非 no-bundle)的 staging 目标跟随发布上下文而不是宿主', () => {
+ const crossArch = 'x86_64-apple-darwin';
+ const context = resolveReleaseContext([], { AGC_BUILD_TARGET: crossArch });
+ assert.equal(context.target, crossArch);
+ let runtimeTarget;
+ let staged;
+ let command;
+ withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => {
+ runTauriBuild([], context, {
+ platform: 'darwin',
+ arch: 'arm64',
+ stageRuntime(target) {
+ runtimeTarget = target;
+ },
+ stageBundled(target, options) {
+ staged = { target, features: options?.features };
+ },
+ spawn(_binary, args) {
+ command = args;
+ return { status: 0 };
+ },
+ });
+ });
+ assert.equal(runtimeTarget, crossArch);
+ assert.equal(
+ staged?.target,
+ crossArch,
+ '打包构建必须按发布上下文目标 staging',
+ );
+ assert.deepEqual(uniqueSorted(staged?.features), []);
+ assert.ok(
+ command.includes(crossArch),
+ '打包构建必须把发布上下文目标传给 Tauri',
+ );
+ assert.deepEqual(cargoFeaturesFromCommand(command), []);
+});
+
+test('staging feature 与最终 Cargo feature 一致:-f / --features / -- 之后的 runner 参数', () => {
+ const context = resolveReleaseContext(['--no-bundle'], {});
+ const cases = [
+ {
+ args: ['--no-bundle', '--features', 'custom-editor-feature'],
+ expected: ['custom-editor-feature'],
+ },
+ {
+ args: ['--no-bundle', '--features=custom-a,custom-b'],
+ expected: ['custom-a', 'custom-b'],
+ },
+ {
+ args: ['--no-bundle', '-f', 'custom-editor-feature'],
+ expected: ['custom-editor-feature'],
+ },
+ {
+ args: ['--no-bundle', '-fcustom-editor-feature'],
+ expected: ['custom-editor-feature'],
+ },
+ // `--` 之后的参数是 runner 参数:tauri 会把它们追加到 cargo 命令行
+ // (`tauri build --help`:Command line arguments passed to the runner),
+ // 因此 Cargo 最终拿到的是「注入的默认值 ∪ runner 参数」,staging 必须一致。
+ {
+ args: ['--no-bundle', '--', '--features=app-runner'],
+ expected: [...windowsEditorFeatures, 'app-runner'],
+ },
+ // 环境变量泄漏(Jenkins Job 参数)不得让 staging 与 Cargo 分叉。
+ {
+ args: ['--no-bundle'],
+ env: { AGC_DEV_CARGO_FEATURES: 'cocos-editor-execute' },
+ },
+ ];
+ for (const entry of cases) {
+ const { args, expected } = entry;
+ const label = args.join(' ');
+ let staged;
+ let command;
+ withEnv({ AGC_DEV_CARGO_FEATURES: undefined, ...entry.env }, () => {
+ runTauriBuild(args, context, {
+ platform: 'win32',
+ arch: 'x64',
+ stageRuntime() {
+ assert.fail(`${label} 不得准备 Node 运行时`);
+ },
+ stageBundled(target, options) {
+ staged = { target, features: options?.features };
+ },
+ spawn(_binary, argv) {
+ command = argv;
+ return { status: 0 };
+ },
+ });
+ });
+ assert.equal(staged?.target, windowsTarget, label);
+ const cargoFeatures = cargoFeaturesFromCommand(command);
+ assert.deepEqual(
+ uniqueSorted(staged?.features),
+ cargoFeatures,
+ `${label}:staging feature 必须等于 Cargo 收到的 feature`,
+ );
+ if (expected) {
+ assert.deepEqual(
+ cargoFeatures,
+ uniqueSorted(expected),
+ `${label}:Cargo feature 与预期不一致`,
+ );
+ }
+ }
+});
+
+test('未注入 platform / arch 时按真实 process 平台取宿主随包目标', () => {
+ const hostTarget = supportedHostTarget(process.platform, process.arch);
+ const context = resolveReleaseContext(['--no-bundle'], {});
+ const events = [];
+ withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => {
+ runTauriBuild(['--no-bundle'], context, {
+ stageRuntime() {
+ assert.fail('--no-bundle 不得准备 Node 运行时');
+ },
+ stageBundled(target) {
+ events.push(target);
+ },
+ spawn() {
+ return { status: 0 };
+ },
+ });
+ });
+ assert.deepEqual(events, hostTarget ? [hostTarget] : []);
+});
+
test('channel manifest carries version, platform keys and signature', () => {
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => {
@@ -1111,6 +1428,8 @@ for (const channel of ['release', 'beta-2']) {
);
runTauriBuild([`--target=${target}`], context, {
stageRuntime: () => {},
+ // 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
+ stageBundled: () => {},
spawn: (_binary, command) => {
const config = JSON.parse(
readFileSync(
diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.mjs
index 515432f5b..588912ac1 100644
--- a/apps/ai-game-creator-shell/scripts/cargo-features.mjs
+++ b/apps/ai-game-creator-shell/scripts/cargo-features.mjs
@@ -1,24 +1,135 @@
/** 默认桌面能力;显式 feature 参数优先,不把应用参数当 Cargo 参数。 */
export function withDefaultCargoFeatures(argv, features) {
- const separator = argv.indexOf('--');
- const cargoArgs = separator < 0 ? argv : argv.slice(0, separator);
- if (
- !features.length ||
- cargoArgs.some(
- (value) =>
- value === '--features' ||
- value === '-f' ||
- value.startsWith('--features=') ||
- /^-f.+/u.test(value),
- )
- ) {
+ // 只看第一个 `--` 之前的 Tauri 选项:runner 参数区里的 `--features` 会与这里注入的
+ // 默认值在 Cargo 侧合并,不能当作「用户已经指定过了」而跳过注入。
+ if (!features.length || tauriOptions(argv).some(hasExplicitFeatures)) {
return argv;
}
return [`--features=${features.join(',')}`, ...argv];
}
+/// 第一个 `--` 之前是 Tauri 自己的选项,之后是 runner(Cargo)参数。
+function tauriOptions(argv) {
+ const separator = argv.indexOf('--');
+ return separator < 0 ? argv : argv.slice(0, separator);
+}
+
+/**
+ * 交给 Tauri 与 Cargo 的参数区间:第一个 `--` 之前是 Tauri 选项,之后到第二个 `--` 是
+ * runner(Cargo)参数;第二个 `--` 之后由 Tauri 转交应用,不属于构建参数。
+ * dev 入口(`buildTauriArguments` 会把启动器的单个 `--` 补成 runner + 应用两段)与发布
+ * 入口都用这一份区间解析,两边的目标与 feature 必须来自同一个口径。
+ */
+function cargoArguments(argv) {
+ const separator = argv.indexOf('--');
+ if (separator < 0) return argv;
+ const applicationStart = argv.indexOf('--', separator + 1);
+ return applicationStart < 0 ? argv : argv.slice(0, applicationStart);
+}
+
+/// 四种拼写(`--features x` / `--features=x` / `-f x` / `-fx`)都算显式指定。
+function hasExplicitFeatures(value) {
+ return (
+ value === '--features' ||
+ value === '-f' ||
+ value.startsWith('--features=') ||
+ /^-f.+/u.test(value)
+ );
+}
+
+function parseFeatureNames(raw) {
+ return String(raw)
+ .split(/[\s,]+/u)
+ .map((name) => name.trim())
+ .filter(Boolean);
+}
+
export function defaultEditorFeatures(target) {
return target === 'win32' || target.includes('windows')
? ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute']
: [];
}
+
+/**
+ * 显式 Cargo 目标解析:`--target ` / `--target=` / `-t `。
+ * 应用参数区(第二个 `--` 之后)不参与解析。未显式指定返回 undefined;取值缺失、
+ * 取到另一个选项或重复指定都失败关闭。
+ * dev 入口与发布入口共用这一份解析:cargo 的构建目标与随包资源 staging 目标
+ * 必须来自同一个显式来源,不能一方读参数、另一方回退宿主默认值。
+ */
+export function readCargoTarget(argv = []) {
+ const options = cargoArguments(argv);
+ let target;
+ for (let index = 0; index < options.length; index += 1) {
+ const argument = String(options[index]);
+ let value;
+ if (argument === '--target' || argument === '-t') {
+ value = options[index + 1];
+ index += 1;
+ } else if (argument.startsWith('--target=')) {
+ value = argument.slice('--target='.length);
+ } else if (argument.startsWith('-t') && argument.length > 2) {
+ value = argument.slice(2).replace(/^=/u, '');
+ } else {
+ continue;
+ }
+ if (!value?.trim() || String(value).startsWith('-')) {
+ throw new Error('--target 缺少有效目标');
+ }
+ if (target !== undefined) throw new Error('不能重复指定 --target');
+ target = String(value).trim();
+ }
+ return target;
+}
+
+/**
+ * 单一声明式解析:本次构建实际生效的编辑器 feature 集。
+ * 解析顺序:命令行 `--features`(Cargo 真正收到的参数,不能被环境覆盖)
+ * > 环境变量 `AGC_DEV_CARGO_FEATURES`(存在即以它为准,空串即关闭默认 feature)
+ * > 目标平台默认值。
+ * 传入的必须是「最终交给 Tauri 的参数」:dev 入口与发布入口的 cargo 参数与随包资源
+ * 准备步骤都消费这里的返回值,否则会出现「cargo 开了 feature、staging 没开(或反之)」。
+ */
+export function resolveEditorFeatures({
+ argv = [],
+ target,
+ env = process.env,
+} = {}) {
+ const fromArgv = featuresFromArgv(argv);
+ if (fromArgv) {
+ return fromArgv;
+ }
+ // 变量存在本身即声明「本次 feature 集由环境决定」:空串是有意的关闭,不是未设置。
+ if (env.AGC_DEV_CARGO_FEATURES !== undefined) {
+ return parseFeatureNames(env.AGC_DEV_CARGO_FEATURES);
+ }
+ return defaultEditorFeatures(target);
+}
+
+/// 汇总 cargo 参数区里出现在任何位置的 `--features` / `-f`(Cargo 会把多个 flag 合并);
+/// 没有任何 feature flag 时返回 null。显式但为空(如 `--features=`)返回空数组。
+export function featuresFromArgv(argv = []) {
+ const options = cargoArguments(argv);
+ const features = [];
+ let found = false;
+ for (let index = 0; index < options.length; index += 1) {
+ const value = String(options[index]);
+ if (value.startsWith('--features=')) {
+ found = true;
+ features.push(...parseFeatureNames(value.slice('--features='.length)));
+ } else if (value === '--features' || value === '-f') {
+ found = true;
+ while (
+ index + 1 < options.length &&
+ !String(options[index + 1]).startsWith('-')
+ ) {
+ index += 1;
+ features.push(...parseFeatureNames(options[index]));
+ }
+ } else if (value.startsWith('-f') && value.length > 2) {
+ found = true;
+ features.push(...parseFeatureNames(value.slice(2).replace(/^=/u, '')));
+ }
+ }
+ return found ? features : null;
+}
diff --git a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs
index 3358524b9..baf462966 100644
--- a/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs
+++ b/apps/ai-game-creator-shell/scripts/cargo-features.test.mjs
@@ -2,7 +2,10 @@ import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildTauriBuildArguments } from './build-release.mjs';
-import { withDefaultCargoFeatures } from './cargo-features.mjs';
+import {
+ featuresFromArgv,
+ withDefaultCargoFeatures,
+} from './cargo-features.mjs';
test('Windows release includes the same editor feature as development', () => {
assert.deepEqual(
@@ -47,3 +50,16 @@ test('explicit Cargo features override defaults in every supported spelling', ()
['--features=cocos-editor-execute', '--', '--features=app'],
);
});
+
+test('features 变参会完整消费空格与逗号分隔的值', () => {
+ assert.deepEqual(
+ featuresFromArgv([
+ '-f',
+ 'cocos-editor-execute,unity-editor-execute',
+ 'godot-editor-execute',
+ '--target',
+ 'x86_64-pc-windows-msvc',
+ ]),
+ ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute'],
+ );
+});
diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs
index a2c349e77..ca6897814 100644
--- a/apps/ai-game-creator-shell/scripts/check-config.mjs
+++ b/apps/ai-game-creator-shell/scripts/check-config.mjs
@@ -1358,7 +1358,6 @@ for (const channel of ['release', 'beta-2']) {
const expectedBundledDesignAgentResources = {
'design-agent': 'design-agent',
- 'resources/claude-agent': 'claude-agent',
...Object.fromEntries(
[
'codex-patch-parser',
@@ -1387,6 +1386,7 @@ const expectedBundledWindowsResources = {
'resources/codex/win-x64/NOTICE.md': 'coding-agent/win-x64/NOTICE.md',
'resources/codex/win-x64/manifest.json': 'coding-agent/win-x64/manifest.json',
'resources/plugins': 'plugins',
+ 'resources/claude-agent': 'claude-agent',
};
assert.deepEqual(
tauriConfig.bundle?.resources,
@@ -1399,6 +1399,13 @@ for (const key of Object.keys(tauriConfig.bundle?.resources ?? {})) {
'AI game creator shell base Tauri config must not require Windows-only Codex resources',
);
}
+ // 基线配置同时服务 Linux(只有 CI 会编译壳 crate):随包资源由准备步骤按目标生成,
+ // 基线声明它们会让没有 node_modules 的平台在构建期就因资源缺失失败。
+ if (String(key).startsWith('resources/')) {
+ throw new Error(
+ 'AI game creator shell base Tauri config must not require platform-only bundled resources',
+ );
+ }
}
assert.deepEqual(
windowsTauriConfig.bundle?.resources,
@@ -1438,6 +1445,7 @@ assert.deepEqual(
]),
),
['resources/plugins', 'plugins'],
+ ['resources/claude-agent', 'claude-agent'],
]),
'macOS must bundle the complete native Codex layout and plugin workspace',
);
diff --git a/apps/ai-game-creator-shell/scripts/check-package-layout.mjs b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs
new file mode 100755
index 000000000..5682da895
--- /dev/null
+++ b/apps/ai-game-creator-shell/scripts/check-package-layout.mjs
@@ -0,0 +1,917 @@
+#!/usr/bin/env node
+// 随包资源声明门禁:把 build_support/package-layout.json(唯一人工声明)渲染成
+// build_support/package-layout.generated.rs(Rust 编译期常量),并校验声明结构与不变量。
+//
+// 用法:
+// node scripts/check-package-layout.mjs 校验生成结果是否与声明一致(不一致 exit 1)
+// node scripts/check-package-layout.mjs --write 重新生成
+//
+// 设计约束:Rust 侧不解析 JSON(避免运行期解析与生命周期妥协),只使用本脚本产出的常量;
+// Node 侧准备步骤直接读同一份 JSON。因此本门禁是“单一声明”的机械保障。
+
+import { existsSync, readFileSync, writeFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
+const APP_ROOT = path.resolve(SCRIPT_DIR, '..');
+const SRC_TAURI = path.join(APP_ROOT, 'src-tauri');
+const DECLARATION_PATH = path.join(
+ SRC_TAURI,
+ 'build_support/package-layout.json',
+);
+const GENERATED_PATH = path.join(
+ SRC_TAURI,
+ 'build_support/package-layout.generated.rs',
+);
+
+const EXPECTED_SCHEMA = 'agc-package-layout.v1';
+
+class DeclarationError extends Error {}
+
+function fail(message) {
+ throw new DeclarationError(message);
+}
+
+function expectObject(value, at) {
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
+ fail(`${at} 必须是对象`);
+ }
+ return value;
+}
+
+function expectArray(value, at) {
+ if (!Array.isArray(value)) {
+ fail(`${at} 必须是数组`);
+ }
+ return value;
+}
+
+function expectString(value, at) {
+ if (typeof value !== 'string' || value.length === 0) {
+ fail(`${at} 必须是非空字符串`);
+ }
+ return value;
+}
+
+function expectBoolean(value, at) {
+ if (typeof value !== 'boolean') {
+ fail(`${at} 必须是布尔值`);
+ }
+ return value;
+}
+
+function expectStringArray(value, at) {
+ return expectArray(value, at).map((item, index) =>
+ expectString(item, `${at}[${index}]`),
+ );
+}
+
+function optionalStringArray(source, key, at) {
+ if (source[key] === undefined) {
+ return [];
+ }
+ return expectStringArray(source[key], `${at}.${key}`);
+}
+
+function expectInteger(value, at) {
+ if (!Number.isInteger(value) || value < 0) {
+ fail(`${at} 必须是非负整数`);
+ }
+ return value;
+}
+
+// 随包子目录来源:`source` 直接来自仓库源码(准备步骤复制),
+// `prepared` 需要先由准备步骤运行声明的构建命令产出,再复制进随包目录。
+function expectOrigin(value, at) {
+ if (value !== 'source' && value !== 'prepared') {
+ fail(`${at} 必须是 source 或 prepared(实际 ${String(value)})`);
+ }
+ return value;
+}
+
+// JSON 字符串字面量与 Rust 字符串字面量几乎一致,唯一差异是控制字符的 \uXXXX 与 \u{XX}。
+function rustString(value) {
+ return JSON.stringify(value).replace(/\\u([0-9a-fA-F]{4})/g, '\\u{$1}');
+}
+
+function rustStrings(values) {
+ return `&[${values.map(rustString).join(', ')}]`;
+}
+
+function indent(level) {
+ return ' '.repeat(level);
+}
+
+function renderStruct(name, fields, level = 0) {
+ const body = fields
+ .map(([key, rendered]) => `${indent(level + 1)}${key}: ${rendered},`)
+ .join('\n');
+ return `${name} {\n${body}\n${indent(level)}}`;
+}
+
+function parsePackageMetadata(value) {
+ const metadata = expectObject(value, 'codex.packageMetadata');
+ return {
+ layoutVersion: expectInteger(
+ metadata.layoutVersion,
+ 'codex.packageMetadata.layoutVersion',
+ ),
+ resourcesDir: expectString(
+ metadata.resourcesDir,
+ 'codex.packageMetadata.resourcesDir',
+ ),
+ pathDir: expectString(metadata.pathDir, 'codex.packageMetadata.pathDir'),
+ };
+}
+
+function parseDeclaration(raw) {
+ const root = expectObject(JSON.parse(raw), 'root');
+ if (root.schema !== EXPECTED_SCHEMA) {
+ fail(
+ `不支持的声明 schema:${String(root.schema)}(期望 ${EXPECTED_SCHEMA})`,
+ );
+ }
+ const layoutVersion = expectInteger(root.layoutVersion, 'layoutVersion');
+
+ const codex = expectObject(root.codex, 'codex');
+ const targets = expectArray(codex.targets, 'codex.targets').map(
+ (entry, index) => {
+ const at = `codex.targets[${index}]`;
+ const parsed = expectObject(entry, at);
+ const files = expectStringArray(parsed.files, `${at}.files`);
+ if (files.length === 0) {
+ fail(`${at}.files 不能为空`);
+ }
+ const executable = expectString(parsed.executable, `${at}.executable`);
+ if (!files.includes(executable)) {
+ fail(`${at}.executable 必须属于组件白名单:${executable}`);
+ }
+ return {
+ target: expectString(parsed.target, `${at}.target`),
+ platform: expectString(parsed.platform, `${at}.platform`),
+ directory: expectString(parsed.directory, `${at}.directory`),
+ executable,
+ files,
+ };
+ },
+ );
+ const seenTargets = new Set();
+ for (const entry of targets) {
+ if (seenTargets.has(entry.target)) {
+ fail(`codex.targets 重复声明目标 ${entry.target}`);
+ }
+ seenTargets.add(entry.target);
+ }
+
+ const noticeSources = expectArray(
+ codex.noticeSources,
+ 'codex.noticeSources',
+ ).map((entry, index) => {
+ const at = `codex.noticeSources[${index}]`;
+ const parsed = expectObject(entry, at);
+ return {
+ targets: expectStringArray(parsed.targets, `${at}.targets`),
+ source: expectString(parsed.source, `${at}.source`),
+ preserve: expectBoolean(parsed.preserve, `${at}.preserve`),
+ };
+ });
+ for (const entry of targets) {
+ const covered = noticeSources.filter((notice) =>
+ notice.targets.includes(entry.target),
+ );
+ if (covered.length !== 1) {
+ fail(
+ `目标 ${entry.target} 必须且只能有一条第三方声明来源(实际 ${covered.length} 条)`,
+ );
+ }
+ }
+
+ const universalGroups = expectArray(
+ codex.universalGroups,
+ 'codex.universalGroups',
+ ).map((entry, index) => {
+ const at = `codex.universalGroups[${index}]`;
+ const parsed = expectObject(entry, at);
+ const groupTargets = expectStringArray(parsed.targets, `${at}.targets`);
+ for (const target of groupTargets) {
+ if (!seenTargets.has(target)) {
+ fail(`${at}.targets 含未声明目标 ${target}`);
+ }
+ }
+ return {
+ name: expectString(parsed.name, `${at}.name`),
+ directory: expectString(parsed.directory, `${at}.directory`),
+ targets: groupTargets,
+ };
+ });
+
+ const plugins = expectObject(root.plugins, 'plugins');
+ const subdirectories = expectArray(
+ plugins.subdirectories,
+ 'plugins.subdirectories',
+ ).map((entry, index) => {
+ const at = `plugins.subdirectories[${index}]`;
+ const parsed = expectObject(entry, at);
+ return {
+ path: expectString(parsed.path, `${at}.path`),
+ origin: expectOrigin(parsed.origin, `${at}.origin`),
+ plugin:
+ parsed.plugin === undefined
+ ? ''
+ : expectString(parsed.plugin, `${at}.plugin`),
+ targetContains: optionalStringArray(parsed, 'targetContains', at),
+ targets: optionalStringArray(parsed, 'targets', at),
+ features: optionalStringArray(parsed, 'features', at),
+ };
+ });
+ const libraryStaging = expectArray(
+ plugins.libraryStaging,
+ 'plugins.libraryStaging',
+ ).map((entry, index) => {
+ const at = `plugins.libraryStaging[${index}]`;
+ const parsed = expectObject(entry, at);
+ const files = expectStringArray(parsed.files, `${at}.files`);
+ if (files.length === 0) {
+ fail(`${at}.files 不能为空`);
+ }
+ return {
+ plugin: expectString(parsed.plugin, `${at}.plugin`),
+ sourceSubdirectory: expectString(
+ parsed.sourceSubdirectory,
+ `${at}.sourceSubdirectory`,
+ ),
+ prepare: expectString(parsed.prepare, `${at}.prepare`),
+ targets: expectStringArray(parsed.targets, `${at}.targets`),
+ features: expectStringArray(parsed.features, `${at}.features`),
+ layout: expectString(parsed.layout, `${at}.layout`),
+ files,
+ };
+ });
+
+ const claudeAgent = expectObject(root.claudeAgent, 'claudeAgent');
+ const claudeAgentTargets = expectArray(
+ claudeAgent.targets,
+ 'claudeAgent.targets',
+ ).map((entry, index) => {
+ const at = `claudeAgent.targets[${index}]`;
+ const parsed = expectObject(entry, at);
+ return {
+ target: expectString(parsed.target, `${at}.target`),
+ runtimePackage: expectString(
+ parsed.runtimePackage,
+ `${at}.runtimePackage`,
+ ),
+ runtimeFileName: expectString(
+ parsed.runtimeFileName,
+ `${at}.runtimeFileName`,
+ ),
+ };
+ });
+ const seenClaudeAgentTargets = new Set();
+ for (const entry of claudeAgentTargets) {
+ if (seenClaudeAgentTargets.has(entry.target)) {
+ fail(`claudeAgent.targets 重复声明目标 ${entry.target}`);
+ }
+ seenClaudeAgentTargets.add(entry.target);
+ }
+ const sdkPackageName = expectString(
+ claudeAgent.sdkPackageName,
+ 'claudeAgent.sdkPackageName',
+ );
+ if (!/^@[^/]+\/[^/]+$/u.test(sdkPackageName)) {
+ fail(`claudeAgent.sdkPackageName 必须是带 scope 的包名:${sdkPackageName}`);
+ }
+ for (const entry of claudeAgentTargets) {
+ // 原生运行时包与 SDK 同处一个 scope 目录下,包名必须是裸名。
+ if (entry.runtimePackage.includes('/')) {
+ fail(
+ `claudeAgent.targets 的 runtimePackage 必须是裸包名:${entry.runtimePackage}`,
+ );
+ }
+ }
+
+ return {
+ layoutVersion,
+ codex: {
+ version: expectString(codex.version, 'codex.version'),
+ cliVersionPrefix: expectString(
+ codex.cliVersionPrefix,
+ 'codex.cliVersionPrefix',
+ ),
+ manifestSchema: expectString(
+ codex.manifestSchema,
+ 'codex.manifestSchema',
+ ),
+ packageMetadata: parsePackageMetadata(codex.packageMetadata),
+ resourceDirectory: expectString(
+ codex.resourceDirectory,
+ 'codex.resourceDirectory',
+ ),
+ manifestFileName: expectString(
+ codex.manifestFileName,
+ 'codex.manifestFileName',
+ ),
+ packageMetadataFileName: expectString(
+ codex.packageMetadataFileName,
+ 'codex.packageMetadataFileName',
+ ),
+ noticeFileName: expectString(
+ codex.noticeFileName,
+ 'codex.noticeFileName',
+ ),
+ sourceRoots: expectStringArray(codex.sourceRoots, 'codex.sourceRoots'),
+ sourceRelativePaths: expectStringArray(
+ codex.sourceRelativePaths,
+ 'codex.sourceRelativePaths',
+ ),
+ noticeSources,
+ universalGroups,
+ targets,
+ },
+ claudeAgent: {
+ version: expectString(claudeAgent.version, 'claudeAgent.version'),
+ resourceDirectory: assertRelativePath(
+ expectString(
+ claudeAgent.resourceDirectory,
+ 'claudeAgent.resourceDirectory',
+ ),
+ 'claudeAgent.resourceDirectory',
+ ),
+ entryRelativePath: assertRelativePath(
+ expectString(
+ claudeAgent.entryRelativePath,
+ 'claudeAgent.entryRelativePath',
+ ),
+ 'claudeAgent.entryRelativePath',
+ ),
+ entryFileName: expectString(
+ claudeAgent.entryFileName,
+ 'claudeAgent.entryFileName',
+ ),
+ nodeModulesDirectory: expectString(
+ claudeAgent.nodeModulesDirectory,
+ 'claudeAgent.nodeModulesDirectory',
+ ),
+ sdkPackageName,
+ sdkEntryFileName: expectString(
+ claudeAgent.sdkEntryFileName,
+ 'claudeAgent.sdkEntryFileName',
+ ),
+ sdkPackageMetadataFileName: expectString(
+ claudeAgent.sdkPackageMetadataFileName,
+ 'claudeAgent.sdkPackageMetadataFileName',
+ ),
+ sourceRoots: expectStringArray(
+ claudeAgent.sourceRoots,
+ 'claudeAgent.sourceRoots',
+ ),
+ skipDirectoryNames: expectStringArray(
+ claudeAgent.skipDirectoryNames,
+ 'claudeAgent.skipDirectoryNames',
+ ),
+ skipDirectoryNamePrefixes: expectStringArray(
+ claudeAgent.skipDirectoryNamePrefixes,
+ 'claudeAgent.skipDirectoryNamePrefixes',
+ ),
+ skipFileNamePrefixes: expectStringArray(
+ claudeAgent.skipFileNamePrefixes,
+ 'claudeAgent.skipFileNamePrefixes',
+ ),
+ skipFileNameFragments: expectStringArray(
+ claudeAgent.skipFileNameFragments,
+ 'claudeAgent.skipFileNameFragments',
+ ),
+ targets: claudeAgentTargets,
+ },
+ plugins: {
+ sourceDirectory: expectString(
+ plugins.sourceDirectory,
+ 'plugins.sourceDirectory',
+ ),
+ destinationDirectory: expectString(
+ plugins.destinationDirectory,
+ 'plugins.destinationDirectory',
+ ),
+ manifestFileName: expectString(
+ plugins.manifestFileName,
+ 'plugins.manifestFileName',
+ ),
+ targetContainsAny: expectStringArray(
+ plugins.targetContainsAny,
+ 'plugins.targetContainsAny',
+ ),
+ subdirectories,
+ libraryStaging,
+ skipDirectoryNames: expectStringArray(
+ plugins.skipDirectoryNames,
+ 'plugins.skipDirectoryNames',
+ ),
+ skipDirectoryNamePrefixes: expectStringArray(
+ plugins.skipDirectoryNamePrefixes,
+ 'plugins.skipDirectoryNamePrefixes',
+ ),
+ skipFileNamePrefixes: expectStringArray(
+ plugins.skipFileNamePrefixes,
+ 'plugins.skipFileNamePrefixes',
+ ),
+ skipFileNameFragments: expectStringArray(
+ plugins.skipFileNameFragments,
+ 'plugins.skipFileNameFragments',
+ ),
+ },
+ };
+}
+
+function renderCodexTarget(entry) {
+ return renderStruct(
+ 'CodexTarget',
+ [
+ ['target', rustString(entry.target)],
+ ['platform', rustString(entry.platform)],
+ ['directory', rustString(entry.directory)],
+ ['executable', rustString(entry.executable)],
+ ['files', rustStrings(entry.files)],
+ ],
+ 1,
+ );
+}
+
+function renderNoticeSource(entry) {
+ return renderStruct(
+ 'NoticeSource',
+ [
+ ['targets', rustStrings(entry.targets)],
+ ['source', rustString(entry.source)],
+ ['preserve', entry.preserve ? 'true' : 'false'],
+ ],
+ 1,
+ );
+}
+
+function renderUniversalGroup(entry) {
+ return renderStruct(
+ 'UniversalGroup',
+ [
+ ['name', rustString(entry.name)],
+ ['directory', rustString(entry.directory)],
+ ['targets', rustStrings(entry.targets)],
+ ],
+ 1,
+ );
+}
+
+function renderSubdirectory(entry) {
+ return renderStruct(
+ 'Subdirectory',
+ [
+ ['path', rustString(entry.path)],
+ ['plugin', rustString(entry.plugin ?? '')],
+ ['origin', rustString(entry.origin)],
+ ['target_contains', rustStrings(entry.targetContains)],
+ ['targets', rustStrings(entry.targets)],
+ ['features', rustStrings(entry.features)],
+ ],
+ 1,
+ );
+}
+
+function renderLibraryStaging(entry) {
+ return renderStruct(
+ 'LibraryStaging',
+ [
+ ['plugin', rustString(entry.plugin)],
+ ['source_subdirectory', rustString(entry.sourceSubdirectory)],
+ ['targets', rustStrings(entry.targets)],
+ ['features', rustStrings(entry.features)],
+ ['layout', rustString(entry.layout)],
+ ['files', rustStrings(entry.files)],
+ ],
+ 1,
+ );
+}
+
+function renderClaudeAgentTarget(entry) {
+ return renderStruct(
+ 'ClaudeAgentTarget',
+ [
+ ['target', rustString(entry.target)],
+ ['runtime_package', rustString(entry.runtimePackage)],
+ ['runtime_file_name', rustString(entry.runtimeFileName)],
+ ],
+ 1,
+ );
+}
+
+function renderGenerated(declaration) {
+ const codex = declaration.codex;
+ const godotStaging = declaration.plugins.libraryStaging.find(
+ (entry) => entry.layout === 'godot-bundle',
+ );
+ if (!godotStaging || godotStaging.files.length === 0) {
+ fail('声明缺少 godot-bundle 随包文件清单,拒绝生成空清单');
+ }
+ const godotFiles = godotStaging.files;
+ const plugins = declaration.plugins;
+ const codexStruct = renderStruct('Codex', [
+ [
+ 'package_metadata',
+ renderStruct('PackageMetadata', [
+ ['layout_version', String(codex.packageMetadata.layoutVersion)],
+ ['resources_dir', rustString(codex.packageMetadata.resourcesDir)],
+ ['path_dir', rustString(codex.packageMetadata.pathDir)],
+ ]),
+ ],
+ ['resource_directory', rustString(codex.resourceDirectory)],
+ ['manifest_file_name', rustString(codex.manifestFileName)],
+ ['package_metadata_file_name', rustString(codex.packageMetadataFileName)],
+ ['notice_file_name', rustString(codex.noticeFileName)],
+ ['source_roots', rustStrings(codex.sourceRoots)],
+ ['source_relative_paths', rustStrings(codex.sourceRelativePaths)],
+ [
+ 'notice_sources',
+ `&[\n${codex.noticeSources.map(renderNoticeSource).join(',\n')}\n]`,
+ ],
+ [
+ 'universal_groups',
+ `&[\n${codex.universalGroups.map(renderUniversalGroup).join(',\n')}\n]`,
+ ],
+ ['targets', `&[\n${codex.targets.map(renderCodexTarget).join(',\n')}\n]`],
+ ]);
+ const claudeAgent = declaration.claudeAgent;
+ const claudeAgentStruct = renderStruct('ClaudeAgent', [
+ ['version', rustString(claudeAgent.version)],
+ ['resource_directory', rustString(claudeAgent.resourceDirectory)],
+ ['entry_relative_path', rustString(claudeAgent.entryRelativePath)],
+ ['entry_file_name', rustString(claudeAgent.entryFileName)],
+ ['node_modules_directory', rustString(claudeAgent.nodeModulesDirectory)],
+ ['sdk_package_name', rustString(claudeAgent.sdkPackageName)],
+ ['sdk_entry_file_name', rustString(claudeAgent.sdkEntryFileName)],
+ [
+ 'sdk_package_metadata_file_name',
+ rustString(claudeAgent.sdkPackageMetadataFileName),
+ ],
+ [
+ 'targets',
+ `&[\n${claudeAgent.targets.map(renderClaudeAgentTarget).join(',\n')}\n]`,
+ ],
+ ]);
+ const pluginsStruct = renderStruct('Plugins', [
+ ['source_directory', rustString(plugins.sourceDirectory)],
+ ['destination_directory', rustString(plugins.destinationDirectory)],
+ ['manifest_file_name', rustString(plugins.manifestFileName)],
+ ['target_contains_any', rustStrings(plugins.targetContainsAny)],
+ [
+ 'subdirectories',
+ `&[\n${plugins.subdirectories.map(renderSubdirectory).join(',\n')}\n]`,
+ ],
+ [
+ 'library_staging',
+ `&[\n${plugins.libraryStaging.map(renderLibraryStaging).join(',\n')}\n]`,
+ ],
+ ['skip_directory_names', rustStrings(plugins.skipDirectoryNames)],
+ [
+ 'skip_directory_name_prefixes',
+ rustStrings(plugins.skipDirectoryNamePrefixes),
+ ],
+ ['skip_file_name_prefixes', rustStrings(plugins.skipFileNamePrefixes)],
+ ['skip_file_name_fragments', rustStrings(plugins.skipFileNameFragments)],
+ ]);
+
+ return `// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs
+// 来源:build_support/package-layout.json。不要手工编辑本文件。
+// 修改随包资源布局请编辑声明文件,然后运行
+// npm run agc:bundled-resources:sync(在仓库根目录)
+// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。
+
+pub const DECLARATION_SCHEMA: &str = ${rustString(EXPECTED_SCHEMA)};
+pub const LAYOUT_VERSION: u64 = ${declaration.layoutVersion};
+
+pub const CODEX_VERSION: &str = ${rustString(declaration.codex.version)};
+pub const CODEX_CLI_VERSION: &str = ${rustString(declaration.codex.cliVersionPrefix + declaration.codex.version)};
+pub const CODEX_MANIFEST_SCHEMA: &str = ${rustString(declaration.codex.manifestSchema)};
+
+pub const CLAUDE_AGENT_SDK_VERSION: &str = ${rustString(declaration.claudeAgent.version)};
+
+pub const GODOT_BUNDLE_FILES: &[&str] = ${rustStrings(godotFiles)};
+
+pub const CODEX: Codex = ${codexStruct};
+
+pub const CLAUDE_AGENT: ClaudeAgent = ${claudeAgentStruct};
+
+pub const PLUGINS: Plugins = ${pluginsStruct};
+`;
+}
+
+function appLockedCodexVersion() {
+ const appPackage = expectObject(
+ JSON.parse(readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')),
+ 'apps/ai-game-creator-shell/package.json',
+ );
+ const declared =
+ appPackage.dependencies?.['@openai/codex'] ??
+ appPackage.devDependencies?.['@openai/codex'];
+ if (typeof declared !== 'string' || declared.length === 0) {
+ fail(
+ '应用 package.json 未声明 @openai/codex,无法校验声明的 codex.version',
+ );
+ }
+ return declared.replace(/^[\^~]/, '');
+}
+
+/// Claude Agent SDK 有两个锁点:客户端依赖与 sidecar 自己的依赖。两处都必须与声明一致,
+/// 否则随包的 SDK 与运行它的 sidecar 会悄悄分叉。
+function lockedClaudeAgentVersions() {
+ const manifests = [
+ [
+ 'apps/ai-game-creator-shell/package.json',
+ path.join(APP_ROOT, 'package.json'),
+ ],
+ [
+ 'apps/ai-game-creator-shell/agent-sidecar/package.json',
+ path.join(APP_ROOT, 'agent-sidecar', 'package.json'),
+ ],
+ ];
+ return manifests.map(([label, file]) => {
+ const manifest = expectObject(
+ JSON.parse(readFileSync(file, 'utf8')),
+ label,
+ );
+ const declared = manifest.dependencies?.['@anthropic-ai/claude-agent-sdk'];
+ if (typeof declared !== 'string' || declared.length === 0) {
+ fail(
+ `${label} 未声明 @anthropic-ai/claude-agent-sdk,无法校验声明的 claudeAgent.version`,
+ );
+ }
+ return declared.replace(/^[\^~]/, '');
+ });
+}
+
+const REPO_ROOT = path.resolve(APP_ROOT, '..', '..');
+const PLUGINS_ROOT = path.join(REPO_ROOT, 'plugins');
+const SHELL_MANIFEST_PATH = path.join(SRC_TAURI, 'Cargo.toml');
+
+/// 声明里的路径必须是仓库内相对路径:绝对路径或含 `..` 会写到工作区之外。
+function assertRelativePath(value, at) {
+ if (
+ path.isAbsolute(value) ||
+ value.startsWith('/') ||
+ value.split('/').includes('..')
+ ) {
+ fail(`${at} 必须是仓库内相对路径(不允许绝对路径或 ..):${value}`);
+ }
+ return value;
+}
+
+/// 声明引用的插件必须是真实存在的插件目录(拼错插件的后果是静默不交付)。
+function assertKnownPlugin(name, at) {
+ const directory = path.join(PLUGINS_ROOT, name);
+ if (!existsSync(path.join(directory, 'plugin.json'))) {
+ fail(`${at} 指向的插件不存在或缺少 plugin.json:${name}`);
+ }
+ return name;
+}
+
+/// shell crate 的 feature 表(拼错 feature 的后果同样是静默不交付)。
+function shellCrateFeatures() {
+ const manifest = readFileSync(SHELL_MANIFEST_PATH, 'utf8');
+ const section = /\[features\]([\s\S]*?)(?:\n\[|$)/u.exec(manifest);
+ if (!section) {
+ fail('无法从 src-tauri/Cargo.toml 读取 [features] 段');
+ }
+ return new Set(
+ section[1]
+ .split('\n')
+ .map((line) => /^([A-Za-z0-9_-]+)\s*=/u.exec(line.trim())?.[1])
+ .filter(Boolean),
+ );
+}
+
+/// 新 section(prepareSteps / nativePayloads / prepared 来源)不参与 Rust 生成物,
+/// 必须在门禁里单独把关,避免「声明了却没人用」或引用到不存在的准备步骤。
+function validateExtendedDeclarations() {
+ const root = expectObject(
+ JSON.parse(readFileSync(DECLARATION_PATH, 'utf8')),
+ 'root',
+ );
+ const plugins = expectObject(root.plugins, 'plugins');
+ const steps = expectArray(plugins.prepareSteps ?? [], 'plugins.prepareSteps');
+ const names = new Set();
+ for (const [index, raw] of steps.entries()) {
+ const at = `plugins.prepareSteps[${index}]`;
+ const step = expectObject(raw, at);
+ const name = expectString(step.name, `${at}.name`);
+ if (names.has(name)) {
+ fail(`${at}.name 重复:${name}`);
+ }
+ names.add(name);
+ const kind = expectString(step.kind, `${at}.kind`);
+ if (kind !== 'powershell' && kind !== 'cargo') {
+ fail(`${at}.kind 只能是 powershell 或 cargo`);
+ }
+ if (kind === 'powershell') {
+ expectString(step.workingDirectory, `${at}.workingDirectory`);
+ expectString(step.scriptFileName, `${at}.scriptFileName`);
+ } else {
+ expectString(step.packageDirectory, `${at}.packageDirectory`);
+ }
+ expectStringArray(step.requiredOutputs ?? [], `${at}.requiredOutputs`);
+ }
+ const payloads = expectArray(
+ plugins.nativePayloads ?? [],
+ 'plugins.nativePayloads',
+ );
+ for (const [index, raw] of payloads.entries()) {
+ const at = `plugins.nativePayloads[${index}]`;
+ const payload = expectObject(raw, at);
+ expectString(payload.plugin, `${at}.plugin`);
+ expectString(payload.prepare, `${at}.prepare`);
+ expectString(payload.sourceFileName, `${at}.sourceFileName`);
+ expectString(payload.destinationFileName, `${at}.destinationFileName`);
+ expectString(
+ payload.destinationSubdirectory,
+ `${at}.destinationSubdirectory`,
+ );
+ if (!names.has(payload.prepare)) {
+ fail(`${at}.prepare 引用了未声明的准备步骤:${payload.prepare}`);
+ }
+ }
+ const preparedByPlugin = new Map();
+ for (const entry of expectArray(
+ plugins.subdirectories ?? [],
+ 'plugins.subdirectories',
+ )) {
+ const subdirectory = expectObject(entry, 'subdirectory');
+ if (subdirectory.origin !== 'prepared') {
+ continue;
+ }
+ const owner = expectString(
+ subdirectory.plugin ?? '',
+ 'subdirectory.plugin',
+ );
+ if (!owner) {
+ fail(`origin=prepared 的子目录必须声明 plugin:${subdirectory.path}`);
+ }
+ preparedByPlugin.set(owner, [
+ ...(preparedByPlugin.get(owner) ?? []),
+ subdirectory.path,
+ ]);
+ }
+ for (const payload of payloads) {
+ const candidates = preparedByPlugin.get(payload.plugin) ?? [];
+ if (!candidates.includes(payload.destinationSubdirectory)) {
+ fail(
+ `nativePayloads 的 destinationSubdirectory(${payload.destinationSubdirectory})必须是该插件 origin=prepared 的子目录之一(现有:${candidates.join('、') || '无'})`,
+ );
+ }
+ }
+
+ const knownFeatures = shellCrateFeatures();
+ const checkFeatures = (values, at) => {
+ for (const name of expectStringArray(values ?? [], at)) {
+ if (!knownFeatures.has(name)) {
+ fail(`${at} 引用了 Cargo.toml 里不存在的 feature:${name}`);
+ }
+ }
+ };
+ for (const [index, raw] of expectArray(
+ plugins.subdirectories ?? [],
+ 'plugins.subdirectories',
+ ).entries()) {
+ const at = `plugins.subdirectories[${index}]`;
+ const subdirectory = expectObject(raw, at);
+ assertRelativePath(
+ expectString(subdirectory.path, `${at}.path`),
+ `${at}.path`,
+ );
+ if (subdirectory.plugin) {
+ assertKnownPlugin(subdirectory.plugin, `${at}.plugin`);
+ }
+ checkFeatures(subdirectory.features, `${at}.features`);
+ }
+ for (const [index, raw] of expectArray(
+ plugins.libraryStaging ?? [],
+ 'plugins.libraryStaging',
+ ).entries()) {
+ const at = `plugins.libraryStaging[${index}]`;
+ const staging = expectObject(raw, at);
+ assertKnownPlugin(
+ expectString(staging.plugin, `${at}.plugin`),
+ `${at}.plugin`,
+ );
+ assertRelativePath(
+ expectString(staging.sourceSubdirectory, `${at}.sourceSubdirectory`),
+ `${at}.sourceSubdirectory`,
+ );
+ for (const relative of expectStringArray(
+ staging.files ?? [],
+ `${at}.files`,
+ )) {
+ assertRelativePath(relative, `${at}.files`);
+ }
+ checkFeatures(staging.features, `${at}.features`);
+ }
+ for (const payload of payloads) {
+ assertRelativePath(
+ payload.destinationSubdirectory,
+ `nativePayloads.${payload.plugin}.destinationSubdirectory`,
+ );
+ checkFeatures(
+ payload.features,
+ `nativePayloads.${payload.plugin}.features`,
+ );
+ }
+ for (const [index, raw] of steps.entries()) {
+ const at = `plugins.prepareSteps[${index}]`;
+ const step = expectObject(raw, at);
+ for (const key of ['workingDirectory', 'packageDirectory']) {
+ if (step[key]) {
+ assertRelativePath(
+ expectString(step[key], `${at}.${key}`),
+ `${at}.${key}`,
+ );
+ }
+ }
+ for (const relative of expectStringArray(
+ step.requiredOutputs ?? [],
+ `${at}.requiredOutputs`,
+ )) {
+ assertRelativePath(relative, `${at}.requiredOutputs`);
+ }
+ }
+
+ const referenced = [
+ ...expectArray(plugins.subdirectories ?? [], 'plugins.subdirectories')
+ .filter(
+ (entry) => expectObject(entry, 'subdirectory').origin === 'prepared',
+ )
+ .map((entry) => [entry.path, entry.prepare]),
+ ...expectArray(plugins.libraryStaging ?? [], 'plugins.libraryStaging').map(
+ (entry) => [entry.sourceSubdirectory, entry.prepare],
+ ),
+ ...payloads.map((entry) => [
+ `${entry.plugin}/${entry.destinationSubdirectory}`,
+ entry.prepare,
+ ]),
+ ];
+ for (const [label, prepare] of referenced) {
+ if (!prepare) {
+ fail(`prepared 来源的条目缺少 prepare 声明:${label}`);
+ }
+ if (!names.has(prepare)) {
+ fail(`${label} 引用了未声明的准备步骤:${prepare}`);
+ }
+ }
+}
+
+function main() {
+ const declaration = parseDeclaration(readFileSync(DECLARATION_PATH, 'utf8'));
+ validateExtendedDeclarations();
+ const lockedVersion = appLockedCodexVersion();
+ if (lockedVersion !== declaration.codex.version) {
+ fail(
+ `声明 codex.version(${declaration.codex.version})与应用锁定的 @openai/codex(${lockedVersion})不一致;请同步更新 build_support/package-layout.json`,
+ );
+ }
+ for (const lockedClaudeVersion of lockedClaudeAgentVersions()) {
+ if (lockedClaudeVersion !== declaration.claudeAgent.version) {
+ fail(
+ `声明 claudeAgent.version(${declaration.claudeAgent.version})与锁定的 @anthropic-ai/claude-agent-sdk(${lockedClaudeVersion})不一致;请同步更新 build_support/package-layout.json`,
+ );
+ }
+ }
+ const rendered = renderGenerated(declaration);
+ const write = process.argv.includes('--write');
+ if (write) {
+ writeFileSync(GENERATED_PATH, rendered);
+ console.log(
+ `随包资源声明已生成:${path.relative(process.cwd(), GENERATED_PATH)}(layoutVersion ${declaration.layoutVersion})`,
+ );
+ return;
+ }
+ const current = readFileSync(GENERATED_PATH, 'utf8');
+ if (current !== rendered) {
+ console.error(
+ [
+ `随包资源声明与 Rust 常量不一致:`,
+ ` 声明:${path.relative(process.cwd(), DECLARATION_PATH)}`,
+ ` 生成:${path.relative(process.cwd(), GENERATED_PATH)}`,
+ '请运行:npm run agc:bundled-resources:sync',
+ ].join('\n'),
+ );
+ process.exit(1);
+ }
+ console.log(
+ `随包资源声明一致(layoutVersion ${declaration.layoutVersion},codex ${declaration.codex.version},目标 ${declaration.codex.targets.length},插件子目录 ${declaration.plugins.subdirectories.length})`,
+ );
+}
+
+try {
+ main();
+} catch (error) {
+ if (error instanceof DeclarationError) {
+ console.error(`随包资源声明无效:${error.message}`);
+ process.exit(1);
+ }
+ throw error;
+}
diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs
new file mode 100755
index 000000000..1b25f2585
--- /dev/null
+++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs
@@ -0,0 +1,1773 @@
+#!/usr/bin/env node
+// AGC 随包资源准备步骤:在 `tauri dev` / `tauri build` 之前把随包资源一次性 staging 到位。
+//
+// 合同见 docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md §4.3:
+// 只替换本工具产物、写入临时目录后原子替换、命中缓存不重写任何文件、失败即关闭并给出可执行提示。
+// 布局与组件白名单来自唯一声明 src-tauri/build_support/package-layout.json(Node 与 Rust 共用)。
+//
+// 本里程碑(M1)覆盖两条纯复制路径:随包 Codex CLI 与插件工作区。
+// 编辑器分支产物(Unity/Godot/Cocos)仍由构建脚本生成,归位在 M3。
+//
+// 用法(在 apps/ai-game-creator-shell 下):
+// node scripts/prepare-bundled-resources.mjs [--target ] [--dry-run]
+
+import { spawnSync } from 'node:child_process';
+import { createHash, randomBytes } from 'node:crypto';
+import {
+ chmodSync,
+ closeSync,
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ openSync,
+ readdirSync,
+ readFileSync,
+ readSync,
+ renameSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { defaultEditorFeatures } from './cargo-features.mjs';
+
+const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
+const APP_ROOT = path.resolve(SCRIPT_DIR, '..');
+const REPO_ROOT = path.resolve(APP_ROOT, '..', '..');
+/** 应用 src-tauri 目录(随包资源的落点)。 */
+export const SRC_TAURI_DIR = path.join(APP_ROOT, 'src-tauri');
+/** 唯一的随包资源声明文件。 */
+export const DECLARATION_PATH = path.join(
+ SRC_TAURI_DIR,
+ 'build_support/package-layout.json',
+);
+/** 缓存记录(放在 gitignored 的 target 下,丢失只多一次哈希)。 */
+export const RECORD_PATH = path.join(
+ SRC_TAURI_DIR,
+ 'target/agc-resource-staging.json',
+);
+
+const HOST_TRIPLES = new Map([
+ ['darwin:arm64', 'aarch64-apple-darwin'],
+ ['darwin:x64', 'x86_64-apple-darwin'],
+ ['win32:x64', 'x86_64-pc-windows-msvc'],
+]);
+
+class PrepareError extends Error {}
+
+function fail(message) {
+ throw new PrepareError(message);
+}
+
+/// 声明覆盖的宿主目标;不受声明覆盖的平台返回 null(入口据此跳过资源准备)。
+export function supportedHostTarget(
+ platform = process.platform,
+ arch = process.arch,
+) {
+ return HOST_TRIPLES.get(`${platform}:${arch}`) ?? null;
+}
+
+export function resolveHostTarget(
+ platform = process.platform,
+ arch = process.arch,
+) {
+ const triple = supportedHostTarget(platform, arch);
+ if (!triple) {
+ fail(
+ `不支持的目标平台:${platform}/${arch}(随包资源声明只覆盖 ${[...HOST_TRIPLES.values()].join('、')})`,
+ );
+ }
+ return triple;
+}
+
+export function readDeclaration(declarationPath = DECLARATION_PATH) {
+ const declaration = JSON.parse(readFileSync(declarationPath, 'utf8'));
+ if (declaration.schema !== 'agc-package-layout.v1') {
+ fail(`不支持的随包资源声明 schema:${String(declaration.schema)}`);
+ }
+ return declaration;
+}
+
+function codexLayout(declaration, target) {
+ const layout = declaration.codex.targets.find(
+ (entry) => entry.target === target,
+ );
+ if (!layout) {
+ fail(`随包资源声明不含目标 ${target} 的 Codex 布局`);
+ }
+ return layout;
+}
+
+/// staging 的替换单位:属于整目录分组时替换整个分组目录(macOS 双架构共用),否则替换自身目录。
+export function stagingUnit(declaration, target) {
+ const layout = codexLayout(declaration, target);
+ const group = declaration.codex.universalGroups.find((entry) =>
+ entry.targets.includes(target),
+ );
+ const directory = group ? group.directory : layout.directory;
+ return {
+ directory,
+ targets: (group ? group.targets : [target]).map((member) => ({
+ target: member,
+ layout: codexLayout(declaration, member),
+ })),
+ };
+}
+
+function sha256File(file) {
+ const hash = createHash('sha256');
+ const buffer = Buffer.allocUnsafe(64 * 1024);
+ const descriptor = openSync(file, 'r');
+ try {
+ for (;;) {
+ const read = readSync(descriptor, buffer, 0, buffer.length, null);
+ if (read === 0) {
+ break;
+ }
+ hash.update(buffer.subarray(0, read));
+ }
+ } finally {
+ closeSync(descriptor);
+ }
+ return hash.digest('hex');
+}
+
+function sha256Text(text) {
+ return createHash('sha256').update(text).digest('hex');
+}
+
+function copyFilePreservingMode(source, destination) {
+ const info = statSync(source);
+ if (!info.isFile()) {
+ fail(`随包资源来源不是普通文件:${source}`);
+ }
+ mkdirSync(path.dirname(destination), { recursive: true });
+ copyFileSync(source, destination);
+ chmodSync(destination, info.mode & 0o777);
+}
+
+export function readRecord(recordPath = RECORD_PATH) {
+ if (!existsSync(recordPath)) {
+ return {};
+ }
+ try {
+ return JSON.parse(readFileSync(recordPath, 'utf8'));
+ } catch {
+ return {};
+ }
+}
+
+function writeRecord(record, recordPath) {
+ mkdirSync(path.dirname(recordPath), { recursive: true });
+ writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`);
+}
+
+/// 上游平台包目录:按声明顺序取第一个「声明白名单文件齐全」的候选。
+export function findCodexSource(declaration, target, roots) {
+ const layout = codexLayout(declaration, target);
+ const candidates = [];
+ for (const rootName of declaration.codex.sourceRoots) {
+ const root = roots[rootName];
+ if (!root) {
+ fail(`未知的声明 sourceRoots 取值:${rootName}`);
+ }
+ for (const relative of declaration.codex.sourceRelativePaths) {
+ candidates.push(
+ path.join(
+ root,
+ relative
+ .replaceAll('', layout.platform)
+ .replaceAll('', target),
+ ),
+ );
+ }
+ }
+ const source = candidates.find((candidate) =>
+ layout.files.every((entry) => existsSync(path.join(candidate, entry))),
+ );
+ if (!source) {
+ fail(
+ `内置 Codex CLI 上游包缺失;请先在仓库根目录执行 npm ci(已检查:${candidates.join(';')})`,
+ );
+ }
+ return source;
+}
+
+/// 上游原生包元数据必须与声明一致:版本、目标、入口、资源目录与 path 目录。
+export function validateUpstreamMetadata(declaration, target, source) {
+ const metadataFile = path.join(
+ source,
+ declaration.codex.packageMetadataFileName,
+ );
+ const metadata = JSON.parse(readFileSync(metadataFile, 'utf8'));
+ const layout = codexLayout(declaration, target);
+ const expected = declaration.codex.packageMetadata;
+ const mismatch =
+ metadata.layoutVersion !== expected.layoutVersion ||
+ metadata.version !== declaration.codex.version ||
+ metadata.target !== target ||
+ metadata.entrypoint !== layout.executable ||
+ metadata.resourcesDir !== expected.resourcesDir ||
+ metadata.pathDir !== expected.pathDir;
+ if (mismatch) {
+ fail(
+ `内置 Codex CLI 上游包元数据与声明不一致(${metadataFile}):期望 layoutVersion=${expected.layoutVersion} version=${declaration.codex.version} target=${target} entrypoint=${layout.executable};请确认上游包版本后再同步 build_support/package-layout.json`,
+ );
+ }
+}
+
+/// 缓存 key 的锁定信息:上游 package-lock 的 resolved + integrity。
+export function readLockedUpstream(
+ declaration,
+ target,
+ lockfilePath = path.join(REPO_ROOT, 'package-lock.json'),
+) {
+ const layout = codexLayout(declaration, target);
+ const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf8'));
+ const entry =
+ lockfile.packages?.[`node_modules/@openai/codex-${layout.platform}`];
+ if (!entry?.resolved || !entry?.integrity) {
+ fail(
+ `package-lock.json 缺少 @openai/codex-${layout.platform} 的 resolved/integrity;请先在仓库根目录执行 npm ci`,
+ );
+ }
+ return { resolved: entry.resolved, integrity: entry.integrity };
+}
+
+function serializeManifest(declaration, layout, hashes) {
+ const files = {};
+ for (const relative of [...layout.files].sort()) {
+ files[relative] = hashes.get(relative);
+ }
+ return `${JSON.stringify(
+ {
+ files,
+ platform: layout.platform,
+ schemaVersion: declaration.codex.manifestSchema,
+ version: `${declaration.codex.cliVersionPrefix}${declaration.codex.version}`,
+ },
+ null,
+ 2,
+ )}\n`;
+}
+
+function noticeSourceFor(declaration, target) {
+ const notice = declaration.codex.noticeSources.find((entry) =>
+ entry.targets.includes(target),
+ );
+ if (!notice) {
+ fail(`随包资源声明缺少目标 ${target} 的第三方声明来源`);
+ }
+ return notice;
+}
+
+function unitPathOf(destinationRoot, declaration, unit) {
+ return path.join(
+ destinationRoot,
+ declaration.codex.resourceDirectory,
+ unit.directory,
+ );
+}
+
+function targetPathWithin(unitPath, declaration, unit, target) {
+ const layout = codexLayout(declaration, target);
+ const relative = path.relative(unit.directory, layout.directory);
+ return relative ? path.join(unitPath, relative) : unitPath;
+}
+
+/// 该替换单位允许出现的顶层条目:整目录分组下是各架构子目录,单目标下是组件首段路径 + 第三方声明 + 清单。
+function allowedUnitEntries(declaration, unit) {
+ const allowed = new Set();
+ for (const member of unit.targets) {
+ const relativeDirectory = path.relative(
+ unit.directory,
+ member.layout.directory,
+ );
+ if (relativeDirectory) {
+ allowed.add(relativeDirectory.split(path.sep)[0]);
+ continue;
+ }
+ for (const relative of member.layout.files) {
+ allowed.add(relative.split('/')[0]);
+ }
+ allowed.add(declaration.codex.noticeFileName);
+ allowed.add(declaration.codex.manifestFileName);
+ }
+ return allowed;
+}
+
+function assertOwnedUnit(unitPath, declaration, unit) {
+ if (!existsSync(unitPath)) {
+ return;
+ }
+ const allowed = allowedUnitEntries(declaration, unit);
+ for (const entry of readdirSync(unitPath)) {
+ const entryPath = path.join(unitPath, entry);
+ if (!allowed.has(entry)) {
+ fail(
+ `随包资源目录被非本工具内容占用:${entryPath};请人工确认后删除该目录再重试`,
+ );
+ }
+ }
+}
+
+/// 期望产物:每个目标的组件摘要与清单文本。
+function desiredCodex(declaration, unit, roots) {
+ const desired = new Map();
+ for (const member of unit.targets) {
+ const source = findCodexSource(declaration, member.target, roots);
+ validateUpstreamMetadata(declaration, member.target, source);
+ const hashes = new Map();
+ for (const relative of member.layout.files) {
+ hashes.set(relative, sha256File(path.join(source, relative)));
+ }
+ desired.set(member.target, {
+ source,
+ files: new Map(
+ [...hashes].map(([relative, digest]) => [
+ relative,
+ { sha256: digest, size: statSync(path.join(source, relative)).size },
+ ]),
+ ),
+ manifest: serializeManifest(declaration, member.layout, hashes),
+ });
+ }
+ return desired;
+}
+
+function unitMatchesExisting(unitPath, declaration, unit, desired) {
+ if (!existsSync(unitPath)) {
+ return false;
+ }
+ const allowed = allowedUnitEntries(declaration, unit);
+ for (const entry of readdirSync(unitPath)) {
+ if (!allowed.has(entry)) {
+ return false;
+ }
+ }
+ for (const member of unit.targets) {
+ const expected = desired.get(member.target);
+ const targetDir = targetPathWithin(
+ unitPath,
+ declaration,
+ unit,
+ member.target,
+ );
+ const manifestPath = path.join(
+ targetDir,
+ declaration.codex.manifestFileName,
+ );
+ if (
+ !existsSync(manifestPath) ||
+ readFileSync(manifestPath, 'utf8') !== expected.manifest
+ ) {
+ return false;
+ }
+ for (const [relative, file] of expected.files) {
+ const filePath = path.join(targetDir, relative);
+ if (!existsSync(filePath)) {
+ return false;
+ }
+ const info = statSync(filePath);
+ if (info.size !== file.size || sha256File(filePath) !== file.sha256) {
+ return false;
+ }
+ if (
+ process.platform !== 'win32' &&
+ relative === member.layout.executable &&
+ (info.mode & 0o111) === 0
+ ) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+function recordEntryFor(unitPath, declaration, unit, desired) {
+ const manifests = {};
+ const sizes = {};
+ for (const member of unit.targets) {
+ const expected = desired.get(member.target);
+ const targetDir = targetPathWithin(
+ unitPath,
+ declaration,
+ unit,
+ member.target,
+ );
+ manifests[member.target] = sha256Text(expected.manifest);
+ sizes[member.layout.directory] = Object.fromEntries(
+ [...expected.files].map(([relative, file]) => [relative, file.size]),
+ );
+ void targetDir;
+ }
+ return { manifests, sizes };
+}
+
+function unitRecordMatches(unitPath, declaration, unit, recorded) {
+ if (!recorded) {
+ return false;
+ }
+ for (const member of unit.targets) {
+ const targetDir = targetPathWithin(
+ unitPath,
+ declaration,
+ unit,
+ member.target,
+ );
+ const manifestPath = path.join(
+ targetDir,
+ declaration.codex.manifestFileName,
+ );
+ if (!existsSync(manifestPath)) {
+ return false;
+ }
+ if (
+ sha256Text(readFileSync(manifestPath, 'utf8')) !==
+ recorded.manifests?.[member.target]
+ ) {
+ return false;
+ }
+ const expectedSizes = recorded.sizes?.[member.layout.directory];
+ if (!expectedSizes) {
+ return false;
+ }
+ for (const relative of member.layout.files) {
+ const file = path.join(targetDir, relative);
+ if (
+ !existsSync(file) ||
+ statSync(file).size !== expectedSizes[relative]
+ ) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+function buildUnitInto(
+ stagingPath,
+ declaration,
+ unit,
+ desired,
+ unitPath,
+ destinationRoot,
+) {
+ for (const member of unit.targets) {
+ const expected = desired.get(member.target);
+ const targetDir = targetPathWithin(
+ stagingPath,
+ declaration,
+ unit,
+ member.target,
+ );
+ for (const relative of member.layout.files) {
+ copyFilePreservingMode(
+ path.join(expected.source, relative),
+ path.join(targetDir, relative),
+ );
+ }
+ const notice = noticeSourceFor(declaration, member.target);
+ const noticeDestination = path.join(
+ targetDir,
+ declaration.codex.noticeFileName,
+ );
+ const noticeSourcePath = notice.preserve
+ ? path.join(
+ targetPathWithin(unitPath, declaration, unit, member.target),
+ declaration.codex.noticeFileName,
+ )
+ : path.join(destinationRoot, notice.source);
+ if (!existsSync(noticeSourcePath)) {
+ fail(`第三方声明来源缺失:${noticeSourcePath}`);
+ }
+ copyFilePreservingMode(noticeSourcePath, noticeDestination);
+ writeFileSync(
+ path.join(targetDir, declaration.codex.manifestFileName),
+ expected.manifest,
+ );
+ }
+}
+
+function stageAtomically(unitPath, builder) {
+ const suffix = `${process.pid}-${randomBytes(4).toString('hex')}`;
+ const stagingPath = `${unitPath}-staging-${suffix}`;
+ const backupPath = `${unitPath}-backup-${suffix}`;
+ rmSync(stagingPath, { recursive: true, force: true });
+ rmSync(backupPath, { recursive: true, force: true });
+ mkdirSync(stagingPath, { recursive: true });
+ try {
+ builder(stagingPath);
+ } catch (error) {
+ rmSync(stagingPath, { recursive: true, force: true });
+ throw error;
+ }
+
+ const hadPrevious = existsSync(unitPath);
+ try {
+ if (hadPrevious) {
+ renameSync(unitPath, backupPath);
+ }
+ renameSync(stagingPath, unitPath);
+ } catch (error) {
+ let restored = !hadPrevious;
+ if (hadPrevious && existsSync(backupPath)) {
+ try {
+ if (existsSync(unitPath)) {
+ rmSync(unitPath, { recursive: true, force: true });
+ }
+ renameSync(backupPath, unitPath);
+ restored = true;
+ } catch {
+ restored = false;
+ }
+ }
+ fail(
+ `替换 ${unitPath} 失败(${error.code ?? error.message}):staging 保留在 ${stagingPath};旧资源${restored ? '已恢复' : '恢复失败,请检查 ' + backupPath},确认没有并发进程后重试`,
+ );
+ }
+ rmSync(backupPath, { recursive: true, force: true });
+}
+
+function prepareCodex({
+ declaration,
+ target,
+ destinationRoot,
+ roots,
+ lockfilePath,
+ record,
+ dryRun,
+}) {
+ const unit = stagingUnit(declaration, target);
+ const label = `codex ${unit.targets.map((member) => member.target).join('+')}`;
+ const unitPath = unitPathOf(destinationRoot, declaration, unit);
+ const key = [
+ `layoutVersion=${declaration.layoutVersion}`,
+ ...unit.targets.map(
+ (member) =>
+ `${member.target}:${readLockedUpstream(declaration, member.target, lockfilePath).integrity}`,
+ ),
+ ].join('|');
+ const recorded = record.codex?.[unit.directory];
+ if (
+ recorded?.key === key &&
+ unitRecordMatches(unitPath, declaration, unit, recorded)
+ ) {
+ return { summary: `${label} 命中缓存(未写入)`, record: undefined };
+ }
+
+ const desired = desiredCodex(declaration, unit, roots);
+ const entry = {
+ key,
+ ...recordEntryFor(unitPath, declaration, unit, desired),
+ };
+ if (unitMatchesExisting(unitPath, declaration, unit, desired)) {
+ return { summary: `${label} 命中缓存(内容一致,未写入)`, record: entry };
+ }
+ if (dryRun) {
+ return {
+ summary: `${label} 需要重新生成(dry-run 未写入)`,
+ record: undefined,
+ };
+ }
+ assertOwnedUnit(unitPath, declaration, unit);
+ stageAtomically(unitPath, (staging) =>
+ buildUnitInto(
+ staging,
+ declaration,
+ unit,
+ desired,
+ unitPath,
+ destinationRoot,
+ ),
+ );
+ return {
+ summary: `${label} 重新生成(${unit.targets.length} 个架构,写入 ${declaration.codex.resourceDirectory}/${unit.directory})`,
+ record: entry,
+ };
+}
+
+/// Claude Agent SDK sidecar 的声明目标;未声明的目标不随包 sidecar(Linux 等平台直接跳过)。
+function claudeAgentLayout(declaration, target) {
+ return (
+ declaration.claudeAgent.targets.find((entry) => entry.target === target) ??
+ null
+ );
+}
+
+/// 上游包目录:按声明顺序在 app / 仓库根的 node_modules 下取第一个命中的候选。
+function requireClaudeAgentPackage(declaration, roots, packageName, label) {
+ const candidates = declaration.claudeAgent.sourceRoots.map((rootName) => {
+ const root = roots[rootName];
+ if (!root) {
+ fail(`未知的声明 sourceRoots 取值:${rootName}`);
+ }
+ return path.join(
+ root,
+ declaration.claudeAgent.nodeModulesDirectory,
+ packageName,
+ );
+ });
+ const found = candidates.find((candidate) => existsSync(candidate));
+ if (!found) {
+ fail(
+ `${label}缺失;请先在仓库根目录执行 npm ci(已检查:${candidates.join(';')})`,
+ );
+ }
+ return found;
+}
+
+/// 上游包版本必须与声明一致:静默发旧 SDK 的代价是 sidecar 与客户端说不上话。
+function validateClaudeAgentPackageVersion(packageDirectory, declared, label) {
+ const metadataFile = path.join(
+ packageDirectory,
+ declared.sdkPackageMetadataFileName,
+ );
+ let metadata;
+ try {
+ metadata = JSON.parse(readFileSync(metadataFile, 'utf8'));
+ } catch (error) {
+ fail(
+ `${label} 元数据不可读或不是合法 JSON:${metadataFile}(${error.message})`,
+ );
+ }
+ if (metadata.version !== declared.version) {
+ fail(
+ `${label} 版本与声明不一致:${String(metadata.version)}(期望 ${declared.version},${metadataFile});请同步更新 build_support/package-layout.json`,
+ );
+ }
+}
+
+/// 期望产物:仓库里的 sidecar 入口 + 上游 SDK 包 + 平台原生运行时包(随包相对路径 → 来源文件)。
+function claudeAgentDesiredFiles(declaration, layout, roots) {
+ const declared = declaration.claudeAgent;
+ const scope = declared.sdkPackageName.split('/')[0];
+ const entrySource = path.join(roots.app, declared.entryRelativePath);
+ if (!existsSync(entrySource)) {
+ fail(`Claude Agent SDK sidecar 入口缺失:${entrySource}`);
+ }
+ const sdkSource = requireClaudeAgentPackage(
+ declaration,
+ roots,
+ declared.sdkPackageName,
+ 'Claude Agent SDK 上游包',
+ );
+ if (!existsSync(path.join(sdkSource, declared.sdkEntryFileName))) {
+ fail(
+ `Claude Agent SDK 上游包缺少入口 ${declared.sdkEntryFileName}:${sdkSource}`,
+ );
+ }
+ const runtimePackage = `${scope}/${layout.runtimePackage}`;
+ const runtimeSource = requireClaudeAgentPackage(
+ declaration,
+ roots,
+ runtimePackage,
+ `Claude Agent SDK 原生运行时(${layout.runtimePackage})`,
+ );
+ if (!existsSync(path.join(runtimeSource, layout.runtimeFileName))) {
+ fail(
+ `Claude Agent SDK 原生运行时缺少可执行文件:${path.join(runtimeSource, layout.runtimeFileName)}`,
+ );
+ }
+ validateClaudeAgentPackageVersion(sdkSource, declared, 'Claude Agent SDK');
+ validateClaudeAgentPackageVersion(
+ runtimeSource,
+ declared,
+ 'Claude Agent SDK 原生运行时',
+ );
+
+ const files = new Map([
+ [
+ declared.entryFileName,
+ {
+ source: entrySource,
+ executable: false,
+ },
+ ],
+ ]);
+ for (const relative of walkFiles(
+ declared,
+ sdkSource,
+ 'Claude Agent SDK 上游包',
+ )) {
+ files.set(
+ `${declared.nodeModulesDirectory}/${declared.sdkPackageName}/${relative}`,
+ {
+ source: path.join(sdkSource, relative),
+ executable: false,
+ },
+ );
+ }
+ for (const relative of walkFiles(
+ declared,
+ runtimeSource,
+ 'Claude Agent SDK 原生运行时',
+ )) {
+ files.set(
+ `${declared.nodeModulesDirectory}/${runtimePackage}/${relative}`,
+ {
+ source: path.join(runtimeSource, relative),
+ executable: relative === layout.runtimeFileName,
+ },
+ );
+ }
+ return files;
+}
+
+/// 已落盘目录里的全部普通文件(相对路径);不套用复制规则,越界文件必须能被看见。
+function collectClaudeAgentFiles(root) {
+ const files = [];
+ const stack = [['', root]];
+ while (stack.length > 0) {
+ const [prefix, directory] = stack.pop();
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
+ const entryPath = path.join(directory, entry.name);
+ if (entry.isSymbolicLink()) {
+ fail(`Claude Agent SDK 随包资源不允许符号链接:${entryPath}`);
+ }
+ if (entry.isDirectory()) {
+ stack.push([relative, entryPath]);
+ } else if (entry.isFile()) {
+ files.push(relative);
+ }
+ }
+ }
+ return files.sort();
+}
+
+/// 随包目录与期望产物是否一致:路径集合、尺寸、可执行位,必要时再逐文件比对内容。
+function claudeAgentTreeMatches(unitPath, files, { compareContent }) {
+ if (!existsSync(unitPath)) {
+ return false;
+ }
+ const expected = [...files.keys()].sort();
+ const actual = collectClaudeAgentFiles(unitPath);
+ if (
+ actual.length !== expected.length ||
+ actual.some((relative, index) => relative !== expected[index])
+ ) {
+ return false;
+ }
+ for (const [relative, entry] of files) {
+ const staged = path.join(unitPath, ...relative.split('/'));
+ const sourceInfo = statSync(entry.source);
+ const stagedInfo = statSync(staged);
+ if (sourceInfo.size !== stagedInfo.size) {
+ return false;
+ }
+ // Windows 的 mode 位不表达可执行语义;其它平台要求声明为可执行的文件确实带可执行位。
+ if (
+ process.platform !== 'win32' &&
+ entry.executable &&
+ (stagedInfo.mode & 0o111) === 0
+ ) {
+ return false;
+ }
+ if (compareContent && !sameFileContent(entry.source, staged)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/// 只允许替换由本工具创建的目录:出现白名单外的顶层条目即失败关闭。
+function assertOwnedClaudeAgentRoot(unitPath, declared) {
+ if (!existsSync(unitPath)) {
+ return;
+ }
+ const allowed = new Set([
+ declared.entryFileName,
+ declared.nodeModulesDirectory,
+ ]);
+ for (const entry of readdirSync(unitPath)) {
+ if (!allowed.has(entry)) {
+ fail(
+ `Claude Agent SDK sidecar 随包目录被非本工具内容占用:${path.join(unitPath, entry)};请人工确认后删除该目录再重试`,
+ );
+ }
+ }
+}
+
+/// Claude Agent SDK sidecar:入口来自仓库源码、SDK 与原生运行时来自上游 npm 包。
+/// 整目录原子替换;命中缓存时不写任何文件。缓存 key = 声明版本 + 目标 + 入口摘要,
+/// 上游包按 npm 版本锁定,因此快速路径只比路径集合、尺寸与可执行位(与 codex 的缓存口径一致)。
+function prepareClaudeAgent({
+ declaration,
+ target,
+ destinationRoot,
+ roots,
+ record,
+ dryRun,
+}) {
+ const layout = claudeAgentLayout(declaration, target);
+ if (!layout) {
+ return {
+ summary: `claude-agent 跳过(目标 ${target} 不适用)`,
+ record: undefined,
+ };
+ }
+ const declared = declaration.claudeAgent;
+ const label = `claude-agent ${target}`;
+ const unitPath = path.join(destinationRoot, declared.resourceDirectory);
+ const files = claudeAgentDesiredFiles(declaration, layout, roots);
+ const key = [
+ `layoutVersion=${declaration.layoutVersion}`,
+ `target=${target}`,
+ `version=${declared.version}`,
+ `entry=${sha256File(path.join(roots.app, declared.entryRelativePath))}`,
+ ].join('|');
+ assertOwnedClaudeAgentRoot(unitPath, declared);
+ if (
+ record.claudeAgent?.[target]?.key === key &&
+ claudeAgentTreeMatches(unitPath, files, { compareContent: false })
+ ) {
+ return { summary: `${label} 命中缓存(未写入)`, record: undefined };
+ }
+ if (claudeAgentTreeMatches(unitPath, files, { compareContent: true })) {
+ return {
+ summary: `${label} 命中缓存(内容一致,未写入)`,
+ record: { key },
+ };
+ }
+ if (dryRun) {
+ return {
+ summary: `${label} 需要重新生成(dry-run 未写入)`,
+ record: undefined,
+ };
+ }
+ stageAtomically(unitPath, (staging) => {
+ for (const [relative, entry] of files) {
+ const destination = path.join(staging, ...relative.split('/'));
+ copyFilePreservingMode(entry.source, destination);
+ // 原生运行时必须可执行:上游 tarball 的权限位偶有丢失,这里按声明兜底补一次。
+ if (entry.executable && process.platform !== 'win32') {
+ chmodSync(destination, 0o755);
+ }
+ }
+ });
+ return {
+ summary: `${label} 重新生成(${files.size} 个文件,写入 ${declared.resourceDirectory})`,
+ record: { key },
+ };
+}
+
+export function pluginDirectories(declaration, repoRoot) {
+ const root = path.join(repoRoot, declaration.plugins.sourceDirectory);
+ if (!existsSync(root)) {
+ fail(`插件工作区缺失:${root}`);
+ }
+ return readdirSync(root, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
+ .map((entry) => ({ name: entry.name, root: path.join(root, entry.name) }))
+ .filter((plugin) =>
+ existsSync(path.join(plugin.root, declaration.plugins.manifestFileName)),
+ );
+}
+
+function subdirectoryAppliesToPlugin(subdirectory, pluginName) {
+ return !subdirectory.plugin || subdirectory.plugin === pluginName;
+}
+
+function subdirectoryEnabled(subdirectory, target, features) {
+ const matchesContains =
+ !subdirectory.targetContains?.length ||
+ subdirectory.targetContains.some((needle) => target.includes(needle));
+ const matchesTarget =
+ !subdirectory.targets?.length || subdirectory.targets.includes(target);
+ const matchesFeatures = (subdirectory.features ?? []).every((name) =>
+ features.has(name),
+ );
+ return matchesContains && matchesTarget && matchesFeatures;
+}
+
+function collectTreeFiles(root, label) {
+ const files = [];
+ const stack = [['', root]];
+ while (stack.length > 0) {
+ const [prefix, directory] = stack.pop();
+ if (!existsSync(directory)) {
+ continue;
+ }
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
+ const entryPath = path.join(directory, entry.name);
+ if (entry.isSymbolicLink()) {
+ fail(`${label}不允许符号链接:${entryPath}`);
+ }
+ if (entry.isDirectory()) {
+ stack.push([relative, entryPath]);
+ } else if (entry.isFile()) {
+ files.push(relative);
+ }
+ }
+ }
+ return files.sort();
+}
+
+/// 复制规则由各 section 自带(plugins / claudeAgent),同名语义、同序判定。
+function skipDirectory(rules, name) {
+ return (
+ rules.skipDirectoryNames.includes(name) ||
+ rules.skipDirectoryNamePrefixes.some((prefix) => name.startsWith(prefix))
+ );
+}
+
+function skipFileName(rules, name) {
+ return (
+ rules.skipFileNamePrefixes.some((prefix) => name.startsWith(prefix)) ||
+ rules.skipFileNameFragments.some((fragment) => name.includes(fragment))
+ );
+}
+
+function walkFiles(rules, root, label) {
+ const files = [];
+ const stack = [['', root]];
+ while (stack.length > 0) {
+ const [prefix, directory] = stack.pop();
+ if (!existsSync(directory)) {
+ continue;
+ }
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
+ const entryPath = path.join(directory, entry.name);
+ if (entry.isSymbolicLink()) {
+ fail(`${label}不允许符号链接:${entryPath}`);
+ }
+ if (entry.isDirectory()) {
+ if (!skipDirectory(rules, entry.name)) {
+ stack.push([relative, entryPath]);
+ }
+ } else if (entry.isFile() && !skipFileName(rules, entry.name)) {
+ files.push(relative);
+ }
+ }
+ }
+ return files.sort();
+}
+
+function copyPluginSubdirectory(declaration, source, destination) {
+ if (!existsSync(source)) {
+ return;
+ }
+ mkdirSync(destination, { recursive: true });
+ for (const entry of readdirSync(source, { withFileTypes: true })) {
+ const entrySource = path.join(source, entry.name);
+ const entryDestination = path.join(destination, entry.name);
+ if (entry.isSymbolicLink()) {
+ fail(`插件资源不允许符号链接:${entrySource}`);
+ }
+ if (entry.isDirectory()) {
+ if (!skipDirectory(declaration.plugins, entry.name)) {
+ copyPluginSubdirectory(declaration, entrySource, entryDestination);
+ }
+ } else if (
+ entry.isFile() &&
+ !skipFileName(declaration.plugins, entry.name)
+ ) {
+ copyFilePreservingMode(entrySource, entryDestination);
+ }
+ }
+}
+
+function pluginSourceFingerprint(declaration, plugins, target, features) {
+ const lines = [];
+ for (const plugin of plugins) {
+ for (const subdirectory of declaration.plugins.subdirectories) {
+ if (
+ !subdirectoryAppliesToPlugin(subdirectory, plugin.name) ||
+ !subdirectoryEnabled(subdirectory, target, features)
+ ) {
+ continue;
+ }
+ const root = path.join(plugin.root, subdirectory.path);
+ for (const file of walkFiles(declaration.plugins, root, '插件资源')) {
+ const absolute = path.join(root, file);
+ const info = statSync(absolute);
+ // prepared 产物由准备步骤无条件复制,mtime 会变;用内容哈希保证幂等判断稳定。
+ const stamp =
+ subdirectory.origin === 'prepared'
+ ? `sha256:${sha256File(absolute)}`
+ : `${info.size}:${Math.round(info.mtimeMs)}`;
+ lines.push(`${plugin.name}/${subdirectory.path}/${file}:${stamp}`);
+ }
+ }
+ const manifest = path.join(
+ plugin.root,
+ declaration.plugins.manifestFileName,
+ );
+ const info = statSync(manifest);
+ lines.push(
+ `${plugin.name}/plugin.json:${info.size}:${Math.round(info.mtimeMs)}`,
+ );
+ for (const staging of declaration.plugins.libraryStaging ?? []) {
+ if (
+ plugin.name !== staging.plugin ||
+ !libraryStagingEnabled(staging, target, features)
+ ) {
+ continue;
+ }
+ for (const relative of staging.files) {
+ const file = path.join(
+ plugin.root,
+ staging.sourceSubdirectory,
+ relative,
+ );
+ lines.push(
+ `${plugin.name}/${staging.sourceSubdirectory}/${relative}:${
+ existsSync(file) ? `sha256:${sha256File(file)}` : 'missing'
+ }`,
+ );
+ }
+ }
+ for (const payload of declaration.plugins.nativePayloads ?? []) {
+ if (
+ plugin.name !== payload.plugin ||
+ !nativePayloadEnabled(payload, target, features)
+ ) {
+ continue;
+ }
+ const delivered = path.join(
+ plugin.root,
+ payload.destinationSubdirectory,
+ payload.destinationFileName ?? payload.sourceFileName,
+ );
+ lines.push(
+ `${plugin.name}/${payload.destinationSubdirectory}:${
+ existsSync(delivered) ? `sha256:${sha256File(delivered)}` : 'missing'
+ }`,
+ );
+ }
+ }
+ return sha256Text(lines.join('\n'));
+}
+
+function pluginTreeMatches(
+ declaration,
+ plugins,
+ target,
+ features,
+ destination,
+) {
+ if (!existsSync(destination)) {
+ return false;
+ }
+ const allowedFiles = new Set();
+ for (const plugin of plugins) {
+ const pluginDestination = path.join(destination, plugin.name);
+ const pluginPrefix = `${plugin.name}/`;
+ allowedFiles.add(`${pluginPrefix}${declaration.plugins.manifestFileName}`);
+ if (
+ !existsSync(
+ path.join(pluginDestination, declaration.plugins.manifestFileName),
+ )
+ ) {
+ return false;
+ }
+ for (const subdirectory of declaration.plugins.subdirectories) {
+ if (!subdirectoryAppliesToPlugin(subdirectory, plugin.name)) {
+ continue;
+ }
+ const stagedDirectory = path.join(pluginDestination, subdirectory.path);
+ if (!subdirectoryEnabled(subdirectory, target, features)) {
+ if (existsSync(stagedDirectory)) {
+ return false;
+ }
+ continue;
+ }
+ const relativePrefix = `${plugin.name}/${subdirectory.path}/`;
+ const sourceRoot = path.join(plugin.root, subdirectory.path);
+ if (subdirectory.origin !== 'source') {
+ if (existsSync(sourceRoot) && !existsSync(stagedDirectory)) {
+ return false;
+ }
+ for (const relative of walkFiles(
+ declaration.plugins,
+ sourceRoot,
+ '插件资源',
+ )) {
+ const staged = path.join(stagedDirectory, relative);
+ if (!existsSync(staged)) {
+ return false;
+ }
+ allowedFiles.add(`${relativePrefix}${relative}`);
+ }
+ continue;
+ }
+ for (const relative of walkFiles(
+ declaration.plugins,
+ sourceRoot,
+ '插件资源',
+ )) {
+ const source = path.join(sourceRoot, relative);
+ const staged = path.join(
+ pluginDestination,
+ subdirectory.path,
+ relative,
+ );
+ allowedFiles.add(`${relativePrefix}${relative}`);
+ if (!existsSync(staged)) {
+ return false;
+ }
+ if (statSync(source).size !== statSync(staged).size) {
+ return false;
+ }
+ if (sha256File(source) !== sha256File(staged)) {
+ return false;
+ }
+ }
+ }
+ for (const staging of declaration.plugins.libraryStaging ?? []) {
+ if (
+ plugin.name === staging.plugin &&
+ libraryStagingEnabled(staging, target, features)
+ ) {
+ for (const relative of staging.files) {
+ const staged = path.join(
+ pluginDestination,
+ staging.sourceSubdirectory,
+ relative,
+ );
+ if (!existsSync(staged)) {
+ return false;
+ }
+ allowedFiles.add(
+ `${plugin.name}/${staging.sourceSubdirectory}/${relative}`,
+ );
+ }
+ }
+ }
+ for (const payload of declaration.plugins.nativePayloads ?? []) {
+ if (
+ plugin.name === payload.plugin &&
+ nativePayloadEnabled(payload, target, features)
+ ) {
+ const relative = `${payload.destinationSubdirectory}/${payload.destinationFileName ?? payload.sourceFileName}`;
+ if (!existsSync(path.join(pluginDestination, relative))) {
+ return false;
+ }
+ allowedFiles.add(`${plugin.name}/${relative}`);
+ }
+ }
+ }
+ return collectTreeFiles(destination, '插件随包目录').every((relative) =>
+ allowedFiles.has(relative),
+ );
+}
+
+function assertOwnedPluginRoot(destination, plugins) {
+ if (!existsSync(destination)) {
+ return;
+ }
+ const known = new Set(plugins.map((plugin) => plugin.name));
+ for (const entry of readdirSync(destination)) {
+ if (!known.has(entry)) {
+ fail(
+ `插件随包目录被非本工具内容占用:${path.join(destination, entry)};请人工确认后删除该目录再重试`,
+ );
+ }
+ }
+}
+
+/// 声明的准备步骤:需要外部工具链或同一次 cargo 构建才能产出的随包内容。
+function prepareStepsReferencedBy(declaration, target, features) {
+ const required = new Set();
+ for (const subdirectory of declaration.plugins.subdirectories) {
+ if (
+ subdirectory.origin === 'prepared' &&
+ subdirectoryEnabled(subdirectory, target, features)
+ ) {
+ required.add(subdirectory.prepare);
+ }
+ }
+ for (const staging of declaration.plugins.libraryStaging ?? []) {
+ if (libraryStagingEnabled(staging, target, features)) {
+ required.add(staging.prepare);
+ }
+ }
+ for (const payload of declaration.plugins.nativePayloads ?? []) {
+ if (nativePayloadEnabled(payload, target, features)) {
+ required.add(payload.prepare);
+ }
+ }
+ return required;
+}
+
+function libraryStagingEnabled(staging, target, features) {
+ return (
+ (!staging.targets?.length || staging.targets.includes(target)) &&
+ (staging.features ?? []).every((name) => features.has(name))
+ );
+}
+
+function nativePayloadEnabled(payload, target, features) {
+ return (
+ (!payload.targets?.length || payload.targets.includes(target)) &&
+ (payload.features ?? []).every((name) => features.has(name))
+ );
+}
+
+/// 指纹覆盖声明的根目录(跳过排除目录),与构建脚本原先的规则一致:按相对路径排序后逐文件哈希。
+function fingerprintSources(repoRoot, step) {
+ const { roots, excludeDirectoryNames } = step.fingerprint;
+ const lines = [];
+ for (const root of roots) {
+ const absoluteRoot = path.join(repoRoot, step.workingDirectory, root);
+ for (const relative of collectFingerprintFiles(
+ absoluteRoot,
+ excludeDirectoryNames,
+ )) {
+ const file = path.join(absoluteRoot, relative);
+ const info = statSync(file);
+ lines.push(`${relative}\u0000${info.size}\u0000${sha256File(file)}`);
+ }
+ }
+ return sha256Text(lines.join('\n'));
+}
+
+function collectFingerprintFiles(root, excludeDirectoryNames, prefix = '') {
+ const files = [];
+ if (!existsSync(root)) {
+ return files;
+ }
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
+ if (entry.isSymbolicLink()) {
+ fail(`准备步骤源码不允许符号链接:${path.join(root, entry.name)}`);
+ }
+ if (entry.isDirectory()) {
+ if (!excludeDirectoryNames.includes(entry.name)) {
+ files.push(
+ ...collectFingerprintFiles(
+ path.join(root, entry.name),
+ excludeDirectoryNames,
+ relative,
+ ),
+ );
+ }
+ } else if (entry.isFile()) {
+ files.push(relative);
+ }
+ }
+ return files.sort();
+}
+
+function requiredOutputsPresent(repoRoot, step) {
+ return step.requiredOutputs.every((relative) => {
+ const file = path.join(repoRoot, relative);
+ return (
+ existsSync(file) && statSync(file).isFile() && statSync(file).size > 0
+ );
+ });
+}
+
+function defaultRunCommand({ program, args, cwd, removeEnvironment }) {
+ const environment = { ...process.env };
+ for (const name of removeEnvironment ?? []) {
+ delete environment[name];
+ }
+ const result = spawnSync(program, args, {
+ cwd,
+ env: environment,
+ stdio: 'inherit',
+ });
+ if (result.error) {
+ throw new PrepareError(`执行 ${program} 失败:${result.error.message}`);
+ }
+ if (result.status !== 0) {
+ throw new PrepareError(`${program} 退出码 ${result.status}`);
+ }
+}
+
+/// 按声明产出「已准备」随包内容;命中指纹且必需产物齐全时零写入。
+export function ensurePreparedArtifacts({
+ declaration,
+ repoRoot,
+ srcTauriRoot,
+ target,
+ features,
+ profile = 'debug',
+ runCommand = defaultRunCommand,
+ log = () => {},
+ dryRun = false,
+}) {
+ const required = prepareStepsReferencedBy(declaration, target, features);
+ const steps = new Map(
+ (declaration.plugins.prepareSteps ?? []).map((step) => [step.name, step]),
+ );
+ for (const name of required) {
+ const step = steps.get(name);
+ if (!step) {
+ fail(`声明引用了未定义的准备步骤:${name}`);
+ }
+ const stampPath = step.fingerprint
+ ? path.join(
+ repoRoot,
+ step.workingDirectory,
+ step.fingerprint.stampRelativePath,
+ )
+ : undefined;
+ if (step.fingerprint && step.requiredOutputs.length > 0) {
+ const expected = fingerprintSources(repoRoot, step);
+ const current = existsSync(stampPath)
+ ? readFileSync(stampPath, 'utf8').trim()
+ : '';
+ if (current === expected && requiredOutputsPresent(repoRoot, step)) {
+ log(`${name} 命中缓存(指纹一致)`);
+ continue;
+ }
+ }
+ if (dryRun) {
+ log(`${name} 需要重新构建(dry-run 未执行)`);
+ continue;
+ }
+ if (step.kind === 'powershell') {
+ runCommand({
+ program: 'powershell.exe',
+ args: [
+ '-NoProfile',
+ '-NonInteractive',
+ '-ExecutionPolicy',
+ 'Bypass',
+ '-File',
+ path.join(repoRoot, step.workingDirectory, step.scriptFileName),
+ ],
+ cwd: path.join(repoRoot, step.workingDirectory),
+ removeEnvironment: step.removeEnvironment ?? [],
+ });
+ } else if (step.kind === 'cargo') {
+ runCommand({
+ program: 'cargo',
+ args: [
+ 'build',
+ '--manifest-path',
+ path.join(repoRoot, step.packageDirectory, 'Cargo.toml'),
+ '--target-dir',
+ path.join(srcTauriRoot, 'target'),
+ '--target',
+ target,
+ ...(profile === 'release' ? ['--release'] : []),
+ ...(step.features?.length
+ ? ['--features', step.features.join(',')]
+ : []),
+ ],
+ cwd: repoRoot,
+ removeEnvironment: [],
+ });
+ } else {
+ fail(`未实现的准备步骤类型:${step.kind}`);
+ }
+ if (!requiredOutputsPresent(repoRoot, step)) {
+ const missing = step.requiredOutputs.filter(
+ (relative) => !existsSync(path.join(repoRoot, relative)),
+ );
+ fail(`${name} 未产出必需文件:${missing.join('、') || '(未知)'}`);
+ }
+ if (stampPath && step.fingerprint) {
+ mkdirSync(path.dirname(stampPath), { recursive: true });
+ writeFileSync(stampPath, `${fingerprintSources(repoRoot, step)}\n`);
+ }
+ log(`${name} 已构建`);
+ }
+}
+
+/// 复制声明为已准备的随包子目录与随包库。
+function copyPreparedPayloads({
+ declaration,
+ repoRoot,
+ staging,
+ target,
+ features,
+}) {
+ for (const plugin of pluginDirectories(declaration, repoRoot)) {
+ for (const subdirectory of declaration.plugins.subdirectories) {
+ if (
+ subdirectory.origin !== 'prepared' ||
+ !subdirectoryEnabled(subdirectory, target, features)
+ ) {
+ continue;
+ }
+ copyPluginSubdirectory(
+ declaration,
+ path.join(plugin.root, subdirectory.path),
+ path.join(staging, plugin.name, subdirectory.path),
+ );
+ }
+ for (const stagingEntry of declaration.plugins.libraryStaging ?? []) {
+ if (
+ plugin.name !== stagingEntry.plugin ||
+ !libraryStagingEnabled(stagingEntry, target, features)
+ ) {
+ continue;
+ }
+ for (const relative of stagingEntry.files) {
+ copyFilePreservingMode(
+ path.join(plugin.root, stagingEntry.sourceSubdirectory, relative),
+ path.join(
+ staging,
+ plugin.name,
+ stagingEntry.sourceSubdirectory,
+ relative,
+ ),
+ );
+ }
+ }
+ }
+}
+
+/// 逐字节相等且尺寸一致;目标不存在即视为不同。
+function sameFileContent(source, destination) {
+ if (!existsSync(destination)) {
+ return false;
+ }
+ const sourceInfo = statSync(source);
+ const destinationInfo = statSync(destination);
+ return (
+ sourceInfo.size === destinationInfo.size &&
+ Buffer.compare(readFileSync(source), readFileSync(destination)) === 0
+ );
+}
+
+/// 内容一致时不重写(保住时间戳与「命中缓存零写入」口径)。
+function copyFilePreservingModeIfChanged(source, destination) {
+ if (sameFileContent(source, destination)) {
+ return;
+ }
+ copyFilePreservingMode(source, destination);
+}
+
+/// Cocos bridge 的 dll 既要进插件工作区(唯一真源),也要进随包目录。
+function copyNativePayloads({
+ declaration,
+ repoRoot,
+ srcTauriRoot,
+ staging,
+ target,
+ features,
+ profile,
+}) {
+ for (const payload of declaration.plugins.nativePayloads ?? []) {
+ if (!nativePayloadEnabled(payload, target, features)) {
+ continue;
+ }
+ const candidates = [
+ path.join(
+ srcTauriRoot,
+ 'target',
+ target,
+ profile,
+ 'deps',
+ payload.sourceFileName,
+ ),
+ path.join(
+ srcTauriRoot,
+ 'target',
+ target,
+ profile,
+ payload.sourceFileName,
+ ),
+ path.join(
+ srcTauriRoot,
+ 'target',
+ profile,
+ 'deps',
+ payload.sourceFileName,
+ ),
+ path.join(srcTauriRoot, 'target', profile, payload.sourceFileName),
+ ];
+ const source = candidates.find((candidate) => existsSync(candidate));
+ if (!source) {
+ fail(
+ `Cocos bridge native payload 未构建:${candidates.map((candidate) => path.relative(repoRoot, candidate)).join(';')}`,
+ );
+ }
+ const destinationName =
+ payload.destinationFileName ?? payload.sourceFileName;
+ copyFilePreservingModeIfChanged(
+ source,
+ path.join(
+ repoRoot,
+ 'plugins',
+ payload.plugin,
+ payload.destinationSubdirectory,
+ destinationName,
+ ),
+ );
+ copyFilePreservingModeIfChanged(
+ source,
+ path.join(
+ staging,
+ payload.plugin,
+ payload.destinationSubdirectory,
+ destinationName,
+ ),
+ );
+ }
+}
+
+function preparePlugins({
+ declaration,
+ target,
+ destinationRoot,
+ repoRoot,
+ features,
+ plugins,
+ record,
+ profile,
+ dryRun,
+}) {
+ const destination = path.join(
+ destinationRoot,
+ declaration.plugins.destinationDirectory,
+ );
+ const fingerprint = pluginSourceFingerprint(
+ declaration,
+ plugins,
+ target,
+ features,
+ );
+ const upToDate =
+ record.plugins?.fingerprint === fingerprint &&
+ pluginTreeMatches(declaration, plugins, target, features, destination);
+ // dry-run 必须零写入:只做只读判断就返回。
+ if (dryRun) {
+ return {
+ summary: upToDate
+ ? `plugins 命中缓存(未写入,${plugins.length} 个插件)`
+ : `plugins 需要重新生成(dry-run 未写入,${plugins.length} 个插件)`,
+ record: undefined,
+ };
+ }
+ // 所有权断言先于任何写入(含下面的预缓存交付)。
+ assertOwnedPluginRoot(destination, plugins);
+ // payload 交付不能排在缓存短路之后:否则「先默认构建、之后开 injection」会把交付整条跳过。
+ if (existsSync(destination)) {
+ copyNativePayloads({
+ declaration,
+ repoRoot,
+ srcTauriRoot: destinationRoot,
+ staging: destination,
+ target,
+ features,
+ profile,
+ });
+ }
+ if (upToDate) {
+ return {
+ summary: `plugins 命中缓存(未写入,${plugins.length} 个插件)`,
+ record: undefined,
+ };
+ }
+ stageAtomically(destination, (staging) => {
+ for (const plugin of plugins) {
+ const pluginDestination = path.join(staging, plugin.name);
+ copyFilePreservingMode(
+ path.join(plugin.root, declaration.plugins.manifestFileName),
+ path.join(pluginDestination, declaration.plugins.manifestFileName),
+ );
+ for (const subdirectory of declaration.plugins.subdirectories) {
+ if (subdirectory.origin !== 'source') {
+ continue;
+ }
+ if (!subdirectoryEnabled(subdirectory, target, features)) {
+ continue;
+ }
+ copyPluginSubdirectory(
+ declaration,
+ path.join(plugin.root, subdirectory.path),
+ path.join(pluginDestination, subdirectory.path),
+ );
+ }
+ }
+ copyPreparedPayloads({
+ declaration,
+ repoRoot,
+ staging,
+ target,
+ features,
+ });
+ copyNativePayloads({
+ declaration,
+ repoRoot,
+ srcTauriRoot: destinationRoot,
+ staging,
+ target,
+ features,
+ profile,
+ });
+ });
+ return {
+ summary: `plugins 重新生成(${plugins.length} 个插件,写入 ${declaration.plugins.destinationDirectory})`,
+ record: {
+ fingerprint: pluginSourceFingerprint(
+ declaration,
+ plugins,
+ target,
+ features,
+ ),
+ },
+ };
+}
+/// 准备全部随包资源;返回逐条汇总,供入口日志与测试断言。
+export function prepareBundledResources({
+ target = resolveHostTarget(),
+ destinationRoot = SRC_TAURI_DIR,
+ declarationPath = DECLARATION_PATH,
+ features = new Set(defaultEditorFeatures(target)),
+ recordPath = RECORD_PATH,
+ lockfilePath = path.join(REPO_ROOT, 'package-lock.json'),
+ profile = 'debug',
+ runCommand = defaultRunCommand,
+ log = () => {},
+ dryRun = false,
+ repoRoot = REPO_ROOT,
+ appRoot = path.dirname(destinationRoot),
+} = {}) {
+ const declaration = readDeclaration(declarationPath);
+ const record = readRecord(recordPath);
+ const summaries = [];
+ let changed = false;
+ const codex = prepareCodex({
+ declaration,
+ target,
+ destinationRoot,
+ roots: { app: appRoot, repo: repoRoot },
+ lockfilePath,
+ record,
+ dryRun,
+ });
+ summaries.push(codex.summary);
+ if (codex.record) {
+ record.codex = {
+ ...record.codex,
+ [stagingUnit(declaration, target).directory]: codex.record,
+ };
+ changed = true;
+ }
+ if (
+ declaration.plugins.targetContainsAny.some((needle) =>
+ target.includes(needle),
+ )
+ ) {
+ ensurePreparedArtifacts({
+ declaration,
+ repoRoot,
+ srcTauriRoot: destinationRoot,
+ target,
+ features,
+ profile,
+ runCommand,
+ dryRun,
+ log,
+ });
+ const plugins = preparePlugins({
+ declaration,
+ target,
+ destinationRoot,
+ repoRoot,
+ features,
+ plugins: pluginDirectories(declaration, repoRoot),
+ record,
+ profile,
+ dryRun,
+ });
+ summaries.push(plugins.summary);
+ if (plugins.record) {
+ record.plugins = plugins.record;
+ changed = true;
+ }
+ } else {
+ summaries.push(`plugins 跳过(目标 ${target} 不适用)`);
+ }
+ const claudeAgent = prepareClaudeAgent({
+ declaration,
+ target,
+ destinationRoot,
+ roots: { app: appRoot, repo: repoRoot },
+ record,
+ dryRun,
+ });
+ summaries.push(claudeAgent.summary);
+ if (claudeAgent.record) {
+ record.claudeAgent = {
+ ...record.claudeAgent,
+ [target]: claudeAgent.record,
+ };
+ changed = true;
+ }
+ if (changed && !dryRun) {
+ writeRecord(record, recordPath);
+ }
+ return summaries;
+}
+
+function requireFlagValue(argv, index, flag) {
+ const value = argv[index + 1];
+ if (value === undefined || String(value).startsWith('--')) {
+ fail(
+ `${flag} 缺少取值(例如 ${flag} );拒绝回退到宿主默认值,以免准备错目标`,
+ );
+ }
+ return String(value);
+}
+
+function parseArguments(argv) {
+ const args = { target: undefined, destinationRoot: undefined, dryRun: false };
+ for (let index = 0; index < argv.length; index += 1) {
+ const value = argv[index];
+ if (value === '--target') {
+ args.target = requireFlagValue(argv, index, '--target');
+ index += 1;
+ } else if (value === '--destination') {
+ args.destinationRoot = requireFlagValue(argv, index, '--destination');
+ index += 1;
+ } else if (value === '--dry-run') {
+ args.dryRun = true;
+ } else if (value.startsWith('--features=')) {
+ args.features = new Set(
+ value.slice('--features='.length).split(',').filter(Boolean),
+ );
+ } else if (value === '--features') {
+ args.features = new Set(
+ String(argv[index + 1] ?? '')
+ .split(',')
+ .filter(Boolean),
+ );
+ index += 1;
+ } else {
+ fail(`未知参数:${value}`);
+ }
+ }
+ return args;
+}
+
+function main(argv) {
+ const args = parseArguments(argv);
+ const summaries = prepareBundledResources({
+ target: args.target ?? resolveHostTarget(),
+ destinationRoot: args.destinationRoot ?? SRC_TAURI_DIR,
+ ...(args.features ? { features: args.features } : {}),
+ dryRun: args.dryRun,
+ log: (line) => console.log(`[agc-resources] ${line}`),
+ });
+ for (const summary of summaries) {
+ console.log(`[agc-resources] ${summary}`);
+ }
+}
+
+if (
+ process.argv[1] &&
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
+) {
+ try {
+ main(process.argv.slice(2));
+ } catch (error) {
+ if (error instanceof PrepareError) {
+ console.error(`[agc-resources] ${error.message}`);
+ process.exit(1);
+ }
+ throw error;
+ }
+}
diff --git a/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs
new file mode 100644
index 000000000..c808ab43c
--- /dev/null
+++ b/apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs
@@ -0,0 +1,1159 @@
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import fs from 'node:fs';
+import { syncBuiltinESMExports } from 'node:module';
+import os from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+ DECLARATION_PATH,
+ findCodexSource,
+ pluginDirectories,
+ prepareBundledResources,
+ readDeclaration,
+ resolveHostTarget,
+ stagingUnit,
+} from './prepare-bundled-resources.mjs';
+
+const WINDOWS_TARGET = 'x86_64-pc-windows-msvc';
+const MAC_TARGET = 'aarch64-apple-darwin';
+
+function sha256File(file) {
+ return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
+}
+
+/// 造一个最小工作区:app(含 node_modules 上游包)、repo(含 plugins 工作区)、lockfile。
+/// 造出声明里所有「已准备」产物:真实构建要 Windows 工具链,用例只需要文件在位。
+function writePrepareArtifacts(root, declaration) {
+ const created = [];
+ for (const step of declaration.plugins.prepareSteps ?? []) {
+ for (const relative of step.requiredOutputs) {
+ const file = path.join(root, relative);
+ if (fs.existsSync(file)) {
+ continue;
+ }
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, `prepared ${relative}\n`);
+ created.push(relative);
+ }
+ }
+ return created;
+}
+
+/// 记录调用的假执行器:默认把声明的必需产物造出来。
+function fakeRunCommand({ created = [], failOn = null } = {}) {
+ return (invocation) => {
+ created.push(`${invocation.program} ${(invocation.args ?? []).join(' ')}`);
+ if (failOn && invocation.program === failOn) {
+ throw new Error('fake run failure');
+ }
+ };
+}
+
+function buildFixture({
+ targets = [WINDOWS_TARGET],
+ plugins = true,
+ claudeAgent = true,
+} = {}) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-resources-'));
+ const appRoot = path.join(root, 'app');
+ const repoRoot = path.join(root, 'repo');
+ const destinationRoot = path.join(appRoot, 'src-tauri');
+ const declaration = readDeclaration(DECLARATION_PATH);
+ const lockfile = { packages: {} };
+
+ for (const target of targets) {
+ const layout = declaration.codex.targets.find(
+ (entry) => entry.target === target,
+ );
+ assert.ok(layout, `声明缺少目标 ${target}`);
+ const vendor = path.join(
+ appRoot,
+ `node_modules/@openai/codex-${layout.platform}/vendor/${target}`,
+ );
+ for (const relative of layout.files) {
+ const file = path.join(vendor, relative);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(
+ file,
+ relative === declaration.codex.packageMetadataFileName
+ ? `${JSON.stringify(
+ {
+ layoutVersion: declaration.codex.packageMetadata.layoutVersion,
+ version: declaration.codex.version,
+ target,
+ entrypoint: layout.executable,
+ resourcesDir: declaration.codex.packageMetadata.resourcesDir,
+ pathDir: declaration.codex.packageMetadata.pathDir,
+ },
+ null,
+ 2,
+ )}\n`
+ : `component ${target} ${relative}\n`,
+ );
+ if (relative === layout.executable) {
+ fs.chmodSync(file, 0o755);
+ }
+ }
+ lockfile.packages[`node_modules/@openai/codex-${layout.platform}`] = {
+ resolved: `https://registry.npmjs.org/@openai/codex-${layout.platform}/-/${layout.platform}.tgz`,
+ integrity: `sha512-${target}`,
+ };
+ }
+
+ fs.mkdirSync(path.join(destinationRoot, 'resources/codex'), {
+ recursive: true,
+ });
+ for (const entry of declaration.codex.noticeSources) {
+ if (entry.preserve) {
+ continue;
+ }
+ const file = path.join(destinationRoot, entry.source);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, 'mac codex notice\n');
+ }
+ if (targets.includes(WINDOWS_TARGET)) {
+ const tracked = path.join(
+ destinationRoot,
+ 'resources/codex/win-x64/NOTICE.md',
+ );
+ fs.mkdirSync(path.dirname(tracked), { recursive: true });
+ fs.writeFileSync(tracked, 'windows codex notice\n');
+ }
+
+ if (plugins) {
+ const pluginRoot = path.join(repoRoot, 'plugins/agc-demo-editor');
+ fs.mkdirSync(path.join(pluginRoot, 'src'), { recursive: true });
+ fs.mkdirSync(path.join(pluginRoot, 'panels'), { recursive: true });
+ fs.mkdirSync(path.join(pluginRoot, 'target'), { recursive: true });
+ fs.mkdirSync(path.join(pluginRoot, '.git'), { recursive: true });
+ fs.writeFileSync(
+ path.join(pluginRoot, 'plugin.json'),
+ '{"name":"agc-demo-editor"}\n',
+ );
+ fs.writeFileSync(
+ path.join(pluginRoot, 'src/entry.mjs'),
+ 'export const entry = 1;\n',
+ );
+ fs.writeFileSync(
+ path.join(pluginRoot, 'panels/panel.html'),
+ '\n',
+ );
+ fs.writeFileSync(path.join(pluginRoot, 'panels/panel.test.mjs'), 'test\n');
+ fs.writeFileSync(path.join(pluginRoot, 'target/junk.rs'), 'junk\n');
+ fs.writeFileSync(path.join(pluginRoot, '.git/HEAD'), 'ref\n');
+ fs.writeFileSync(path.join(pluginRoot, '.env'), 'secret\n');
+
+ for (const name of [
+ 'agc-cocos-editor',
+ 'agc-unity-editor',
+ 'agc-godot-editor',
+ ]) {
+ const root = path.join(repoRoot, 'plugins', name);
+ fs.mkdirSync(path.join(root, 'src'), {
+ recursive: true,
+ });
+ fs.writeFileSync(path.join(root, 'plugin.json'), `{"name":"${name}"}\n`);
+ fs.writeFileSync(
+ path.join(root, 'src/entry.mjs'),
+ `export const ${name} = 1;\n`,
+ );
+ }
+ const cocosRoot = path.join(repoRoot, 'plugins/agc-cocos-editor');
+ fs.mkdirSync(path.join(cocosRoot, 'src'), { recursive: true });
+ fs.writeFileSync(
+ path.join(cocosRoot, 'plugin.json'),
+ '{"name":"agc-cocos-editor"}\n',
+ );
+ fs.writeFileSync(
+ path.join(cocosRoot, 'src/entry.mjs'),
+ 'export const cocos = 1;\n',
+ );
+ }
+
+ if (claudeAgent) {
+ const declared = declaration.claudeAgent;
+ const entry = path.join(appRoot, declared.entryRelativePath);
+ fs.mkdirSync(path.dirname(entry), { recursive: true });
+ fs.writeFileSync(entry, 'console.log("sidecar");\n');
+ const scope = declared.sdkPackageName.split('/')[0];
+ const sdkRoot = path.join(
+ appRoot,
+ declared.nodeModulesDirectory,
+ declared.sdkPackageName,
+ );
+ fs.mkdirSync(path.join(sdkRoot, 'src'), { recursive: true });
+ fs.writeFileSync(
+ path.join(sdkRoot, declared.sdkEntryFileName),
+ 'export const sdk = 1;\n',
+ );
+ fs.writeFileSync(
+ path.join(sdkRoot, 'src/runner.mjs'),
+ 'export const run = 1;\n',
+ );
+ // 上游包里不该随包的内容:跳过规则必须把它们挡在 staging 之外。
+ fs.writeFileSync(path.join(sdkRoot, '.env'), 'secret\n');
+ fs.writeFileSync(path.join(sdkRoot, 'src/runner.test.mjs'), 'test\n');
+ fs.writeFileSync(
+ path.join(sdkRoot, declared.sdkPackageMetadataFileName),
+ `${JSON.stringify({ version: declared.version })}\n`,
+ );
+ for (const target of targets) {
+ const layout = declared.targets.find((item) => item.target === target);
+ assert.ok(layout, `声明缺少目标 ${target} 的 Claude Agent SDK 布局`);
+ const runtimeRoot = path.join(
+ appRoot,
+ declared.nodeModulesDirectory,
+ scope,
+ layout.runtimePackage,
+ );
+ fs.mkdirSync(runtimeRoot, { recursive: true });
+ const runtimeFile = path.join(runtimeRoot, layout.runtimeFileName);
+ fs.writeFileSync(runtimeFile, `runtime ${layout.runtimePackage}\n`);
+ fs.chmodSync(runtimeFile, 0o755);
+ fs.writeFileSync(
+ path.join(runtimeRoot, declared.sdkPackageMetadataFileName),
+ `${JSON.stringify({ version: declared.version })}\n`,
+ );
+ }
+ }
+
+ writePrepareArtifacts(repoRoot, declaration);
+ const cocosDll = path.join(
+ destinationRoot,
+ 'target',
+ WINDOWS_TARGET,
+ 'debug',
+ 'deps',
+ 'cocos_editor_bridge.dll',
+ );
+ fs.mkdirSync(path.dirname(cocosDll), { recursive: true });
+ fs.writeFileSync(cocosDll, 'cocos bridge payload\n');
+
+ const lockfilePath = path.join(root, 'package-lock.json');
+ fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2));
+ return {
+ root,
+ appRoot,
+ repoRoot,
+ destinationRoot,
+ lockfilePath,
+ recordPath: path.join(root, 'record.json'),
+ declaration,
+ cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
+ };
+}
+
+function snapshot(directory) {
+ const entries = [];
+ const stack = [['', directory]];
+ while (stack.length > 0) {
+ const [prefix, current] = stack.pop();
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
+ const full = path.join(current, entry.name);
+ if (entry.isDirectory()) {
+ stack.push([relative, full]);
+ } else {
+ const info = fs.statSync(full);
+ entries.push({
+ relative,
+ size: info.size,
+ mtimeMs: info.mtimeMs,
+ mode: info.mode & 0o777,
+ sha256: sha256File(full),
+ });
+ }
+ }
+ }
+ return entries.sort((left, right) =>
+ left.relative.localeCompare(right.relative),
+ );
+}
+
+function prepare(fixture, overrides = {}) {
+ const runner = overrides.runCommand ?? fakeRunCommand();
+ return prepareBundledResources({
+ target: WINDOWS_TARGET,
+ destinationRoot: fixture.destinationRoot,
+ declarationPath: DECLARATION_PATH,
+ recordPath: fixture.recordPath,
+ lockfilePath: fixture.lockfilePath,
+ repoRoot: fixture.repoRoot,
+ appRoot: fixture.appRoot,
+ runCommand: runner,
+ ...overrides,
+ });
+}
+
+/// 替换失败注入:让「staging → 目标目录」的那一次 rename 抛错。
+/// 走 node:fs 的 ESM 活绑定(syncBuiltinESMExports 同步),实现里不得为测试开后门;
+/// body 是同步的,注入窗口内不会有别的调用跑进来。
+function withRenameFailure(predicate, body) {
+ const original = fs.renameSync;
+ let injected = false;
+ fs.renameSync = (from, to) => {
+ if (
+ !injected &&
+ predicate(path.resolve(String(from)), path.resolve(String(to)))
+ ) {
+ injected = true;
+ throw Object.assign(new Error('注入的替换失败'), { code: 'EPERM' });
+ }
+ return original.call(fs, from, to);
+ };
+ syncBuiltinESMExports();
+ try {
+ return body();
+ } finally {
+ fs.renameSync = original;
+ syncBuiltinESMExports();
+ }
+}
+
+/// 把「必须失败」的调用收敛成断言:返回错误对象,没抛错即用例失败。
+function expectThrow(body) {
+ try {
+ body();
+ } catch (error) {
+ return error;
+ }
+ throw new Error('预期抛错但调用成功了');
+}
+
+test('stages declared codex components with manifest and preserved notice', () => {
+ const fixture = buildFixture();
+ try {
+ const summaries = prepare(fixture);
+ assert.match(summaries[0], /codex x86_64-pc-windows-msvc 重新生成/);
+
+ const declaration = fixture.declaration;
+ const layout = declaration.codex.targets.find(
+ (entry) => entry.target === WINDOWS_TARGET,
+ );
+ const unit = path.join(
+ fixture.destinationRoot,
+ 'resources/codex',
+ layout.directory,
+ );
+ for (const relative of layout.files) {
+ assert.ok(
+ fs.existsSync(path.join(unit, relative)),
+ `缺少组件 ${relative}`,
+ );
+ }
+ assert.equal(
+ fs.readFileSync(path.join(unit, 'NOTICE.md'), 'utf8'),
+ 'windows codex notice\n',
+ '受版本控制的第三方声明必须原地保留',
+ );
+ const manifest = JSON.parse(
+ fs.readFileSync(path.join(unit, 'manifest.json'), 'utf8'),
+ );
+ assert.deepEqual(Object.keys(manifest), [
+ 'files',
+ 'platform',
+ 'schemaVersion',
+ 'version',
+ ]);
+ assert.equal(manifest.platform, layout.platform);
+ assert.equal(manifest.schemaVersion, declaration.codex.manifestSchema);
+ assert.equal(
+ manifest.version,
+ `${declaration.codex.cliVersionPrefix}${declaration.codex.version}`,
+ );
+ assert.deepEqual(
+ Object.keys(manifest.files).sort(),
+ [...layout.files].sort(),
+ '清单文件集合必须等于组件白名单',
+ );
+ for (const relative of layout.files) {
+ assert.equal(
+ manifest.files[relative],
+ sha256File(path.join(unit, relative)),
+ );
+ }
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('second run is a no-op: identical content and timestamps', () => {
+ const fixture = buildFixture();
+ try {
+ prepare(fixture);
+ const unit = path.join(
+ fixture.destinationRoot,
+ 'resources/codex',
+ 'win-x64',
+ );
+ const plugins = path.join(fixture.destinationRoot, 'resources/plugins');
+ const claudeAgent = path.join(
+ fixture.destinationRoot,
+ fixture.declaration.claudeAgent.resourceDirectory,
+ );
+ const before = {
+ codex: snapshot(unit),
+ plugins: snapshot(plugins),
+ claudeAgent: snapshot(claudeAgent),
+ };
+ const summaries = prepare(fixture);
+ assert.match(summaries[0], /命中缓存/);
+ assert.match(summaries[1], /命中缓存/);
+ assert.match(summaries[2], /命中缓存/);
+ assert.deepEqual(
+ snapshot(unit),
+ before.codex,
+ 'codex 产物内容与时间戳必须不变',
+ );
+ assert.deepEqual(
+ snapshot(plugins),
+ before.plugins,
+ '插件产物内容与时间戳必须不变',
+ );
+ assert.deepEqual(
+ snapshot(claudeAgent),
+ before.claudeAgent,
+ 'Claude Agent SDK sidecar 产物内容与时间戳必须不变',
+ );
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('stages the macOS universal group with both architectures', () => {
+ const fixture = buildFixture({
+ targets: [MAC_TARGET, 'x86_64-apple-darwin'],
+ });
+ try {
+ const summaries = prepare(fixture, { target: MAC_TARGET });
+ assert.match(summaries[0], /mac-native/);
+ const unit = path.join(
+ fixture.destinationRoot,
+ 'resources/codex/mac-native',
+ );
+ for (const directory of ['darwin-arm64', 'darwin-x64']) {
+ for (const file of ['bin/codex', 'manifest.json', 'NOTICE.md']) {
+ assert.ok(
+ fs.existsSync(path.join(unit, directory, file)),
+ `缺少 ${directory}/${file}`,
+ );
+ }
+ assert.equal(
+ fs.readFileSync(path.join(unit, directory, 'NOTICE.md'), 'utf8'),
+ 'mac codex notice\n',
+ );
+ }
+ assert.deepEqual(fs.readdirSync(unit).sort(), [
+ 'darwin-arm64',
+ 'darwin-x64',
+ ]);
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('copies only whitelisted plugin subdirectories', () => {
+ const fixture = buildFixture();
+ try {
+ prepare(fixture);
+ const staged = path.join(
+ fixture.destinationRoot,
+ 'resources/plugins/agc-demo-editor',
+ );
+ assert.ok(fs.existsSync(path.join(staged, 'plugin.json')));
+ assert.ok(fs.existsSync(path.join(staged, 'src/entry.mjs')));
+ assert.ok(fs.existsSync(path.join(staged, 'panels/panel.html')));
+ assert.ok(
+ !fs.existsSync(path.join(staged, 'panels/panel.test.mjs')),
+ '测试文件不随包',
+ );
+ assert.ok(
+ !fs.existsSync(path.join(staged, 'target')),
+ '构建产物目录不随包',
+ );
+ assert.ok(!fs.existsSync(path.join(staged, '.git')), '隐藏目录不随包');
+ assert.ok(!fs.existsSync(path.join(staged, '.env')), '隐藏文件不随包');
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('fails closed when the upstream package metadata drifts from the declaration', () => {
+ const fixture = buildFixture();
+ try {
+ const declaration = fixture.declaration;
+ const layout = declaration.codex.targets.find(
+ (entry) => entry.target === WINDOWS_TARGET,
+ );
+ const metadataFile = path.join(
+ fixture.appRoot,
+ `node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}/codex-package.json`,
+ );
+ const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8'));
+ assert.equal(metadata.version, declaration.codex.version);
+ for (const [key, value] of [
+ ['version', '0.0.0'],
+ ['layoutVersion', 2],
+ ['entrypoint', 'bin/other.exe'],
+ ['resourcesDir', '../private'],
+ ]) {
+ fs.writeFileSync(
+ metadataFile,
+ `${JSON.stringify({ ...metadata, [key]: value }, null, 2)}\n`,
+ );
+ assert.throws(
+ () => prepare(fixture),
+ /上游包元数据与声明不一致/,
+ `${key} 漂移必须被拒绝`,
+ );
+ }
+ assert.ok(
+ !fs.existsSync(
+ path.join(
+ fixture.destinationRoot,
+ 'resources/codex/win-x64/manifest.json',
+ ),
+ ),
+ '拒绝时不得留下产物',
+ );
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('fails closed when the upstream package is missing', () => {
+ const fixture = buildFixture();
+ try {
+ fs.rmSync(path.join(fixture.appRoot, 'node_modules'), {
+ recursive: true,
+ force: true,
+ });
+ assert.throws(() => prepare(fixture), /npm ci/);
+ assert.ok(
+ !fs.existsSync(
+ path.join(
+ fixture.destinationRoot,
+ 'resources/codex/win-x64/manifest.json',
+ ),
+ ),
+ '失败时不得留下半成品清单',
+ );
+ assert.ok(
+ !fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')),
+ );
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('fails closed for unsupported targets', () => {
+ const fixture = buildFixture();
+ try {
+ assert.throws(
+ () => prepare(fixture, { target: 'x86_64-unknown-linux-gnu' }),
+ /声明不含目标/,
+ );
+ assert.throws(() => resolveHostTarget('linux', 'x64'), /不支持的目标平台/);
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('fails closed when the destination is owned by something else', () => {
+ const fixture = buildFixture();
+ try {
+ const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64');
+ fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n');
+ assert.throws(() => prepare(fixture), /被非本工具内容占用/);
+
+ const plugins = path.join(fixture.destinationRoot, 'resources/plugins');
+ fs.rmSync(path.join(unit, 'foreign.bin'), { force: true });
+ fs.mkdirSync(path.join(plugins, 'someone-elses-plugin'), {
+ recursive: true,
+ });
+ assert.throws(() => prepare(fixture), /插件随包目录被非本工具内容占用/);
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('stages the Claude Agent SDK sidecar with the platform runtime', () => {
+ const fixture = buildFixture();
+ try {
+ const summaries = prepare(fixture);
+ const declared = fixture.declaration.claudeAgent;
+ assert.match(summaries[2], /claude-agent x86_64-pc-windows-msvc 重新生成/);
+ const unit = path.join(fixture.destinationRoot, declared.resourceDirectory);
+ const layout = declared.targets.find(
+ (entry) => entry.target === WINDOWS_TARGET,
+ );
+ const scope = declared.sdkPackageName.split('/')[0];
+ const stagedSdk = path.join(
+ unit,
+ declared.nodeModulesDirectory,
+ declared.sdkPackageName,
+ );
+ for (const file of [
+ path.join(unit, declared.entryFileName),
+ path.join(stagedSdk, declared.sdkEntryFileName),
+ path.join(stagedSdk, 'src/runner.mjs'),
+ path.join(
+ unit,
+ declared.nodeModulesDirectory,
+ scope,
+ layout.runtimePackage,
+ layout.runtimeFileName,
+ ),
+ ]) {
+ assert.ok(fs.existsSync(file), `缺少 ${file}`);
+ }
+ // 跳过规则:上游包里的私密文件与测试文件不得随包。
+ assert.ok(!fs.existsSync(path.join(stagedSdk, '.env')));
+ assert.ok(!fs.existsSync(path.join(stagedSdk, 'src/runner.test.mjs')));
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('fails closed when the Claude Agent SDK upstream package is missing', () => {
+ const fixture = buildFixture();
+ try {
+ fs.rmSync(path.join(fixture.appRoot, 'node_modules/@anthropic-ai'), {
+ recursive: true,
+ force: true,
+ });
+ assert.throws(() => prepare(fixture), /Claude Agent SDK 上游包缺失/);
+ assert.ok(
+ !fs.existsSync(
+ path.join(
+ fixture.destinationRoot,
+ fixture.declaration.claudeAgent.resourceDirectory,
+ ),
+ ),
+ '失败关闭时不得留下半成品',
+ );
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('fails closed when the upstream Claude Agent SDK version drifts', () => {
+ const fixture = buildFixture();
+ try {
+ const declared = fixture.declaration.claudeAgent;
+ const metadataFile = path.join(
+ fixture.appRoot,
+ declared.nodeModulesDirectory,
+ declared.sdkPackageName,
+ declared.sdkPackageMetadataFileName,
+ );
+ fs.writeFileSync(metadataFile, `${JSON.stringify({ version: '0.0.0' })}\n`);
+ assert.throws(() => prepare(fixture), /版本与声明不一致/);
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('fails closed when the Claude Agent SDK destination is owned by something else', () => {
+ const fixture = buildFixture();
+ try {
+ const unit = path.join(
+ fixture.destinationRoot,
+ fixture.declaration.claudeAgent.resourceDirectory,
+ );
+ fs.mkdirSync(unit, { recursive: true });
+ fs.writeFileSync(path.join(unit, 'foreign.bin'), 'foreign\n');
+ assert.throws(() => prepare(fixture), /sidecar 随包目录被非本工具内容占用/);
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('dry run writes nothing', () => {
+ const fixture = buildFixture();
+ try {
+ const summaries = prepare(fixture, {
+ dryRun: true,
+ features: new Set([
+ 'unity-editor-execute',
+ 'godot-editor-execute',
+ 'cocos-editor-injection',
+ ]),
+ });
+ assert.match(summaries[0], /需要重新生成(dry-run 未写入)/);
+ const unit = path.join(fixture.destinationRoot, 'resources/codex/win-x64');
+ assert.deepEqual(
+ fs.readdirSync(unit),
+ ['NOTICE.md'],
+ 'dry-run 不得写入任何组件或清单',
+ );
+ assert.ok(
+ !fs.existsSync(path.join(fixture.destinationRoot, 'resources/plugins')),
+ );
+ assert.ok(
+ !fs.existsSync(
+ path.join(
+ fixture.destinationRoot,
+ fixture.declaration.claudeAgent.resourceDirectory,
+ ),
+ ),
+ );
+ assert.ok(!fs.existsSync(fixture.recordPath));
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('declaration drives source lookup and staging units', () => {
+ const declaration = readDeclaration(DECLARATION_PATH);
+ const windows = stagingUnit(declaration, WINDOWS_TARGET);
+ assert.equal(windows.directory, 'win-x64');
+ assert.deepEqual(
+ windows.targets.map((member) => member.target),
+ [WINDOWS_TARGET],
+ );
+ const mac = stagingUnit(declaration, MAC_TARGET);
+ assert.equal(mac.directory, 'mac-native');
+ assert.deepEqual(
+ mac.targets.map((member) => member.target),
+ ['aarch64-apple-darwin', 'x86_64-apple-darwin'],
+ );
+
+ const fixture = buildFixture();
+ try {
+ const source = findCodexSource(declaration, WINDOWS_TARGET, {
+ app: fixture.appRoot,
+ repo: fixture.repoRoot,
+ });
+ assert.match(
+ source,
+ /codex-win32-x64[\\/]vendor[\\/]x86_64-pc-windows-msvc$/u,
+ );
+ assert.equal(pluginDirectories(declaration, fixture.repoRoot).length, 4);
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('runs declared prepare steps once and copies their artifacts into the staged tree', () => {
+ const fixture = buildFixture();
+ try {
+ const created = [];
+ const summaries = prepare(fixture, {
+ runCommand: fakeRunCommand({ created }),
+ features: new Set([
+ 'unity-editor-execute',
+ 'godot-editor-execute',
+ 'cocos-editor-injection',
+ ]),
+ });
+ assert.match(summaries[0], /命中缓存|重新生成/);
+ assert.ok(
+ created.some(
+ (entry) =>
+ entry.startsWith('powershell.exe') && entry.includes('build.ps1'),
+ ),
+ '必须执行声明的 powershell 准备步骤',
+ );
+ assert.ok(
+ created.some(
+ (entry) =>
+ entry.startsWith('cargo build') && entry.includes('--target'),
+ ),
+ '必须执行声明的 cargo 准备步骤',
+ );
+ const stagedPayload = path.join(
+ fixture.destinationRoot,
+ 'resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll',
+ );
+ assert.ok(fs.existsSync(stagedPayload), 'native payload 必须进随包目录');
+ assert.ok(
+ fs.existsSync(
+ path.join(
+ fixture.repoRoot,
+ 'plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll',
+ ),
+ ),
+ 'native payload 必须写回插件工作区(唯一真源)',
+ );
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('skips prepare steps whose fingerprint is unchanged', () => {
+ const fixture = buildFixture();
+ try {
+ const first = [];
+ prepare(fixture, { runCommand: fakeRunCommand({ created: first }) });
+ assert.ok(first.length > 0, '首次必须执行准备步骤');
+ const second = [];
+ prepare(fixture, { runCommand: fakeRunCommand({ created: second }) });
+ const unityRuns = second.filter(
+ (entry) =>
+ entry.startsWith('powershell.exe') &&
+ entry.includes('agc-unity-editor'),
+ );
+ assert.deepEqual(
+ unityRuns,
+ [],
+ '指纹一致时不应再跑声明了指纹的 Unity 准备步骤',
+ );
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('fails closed when a prepare step does not produce its declared outputs', () => {
+ const fixture = buildFixture();
+ try {
+ fs.rmSync(path.join(fixture.repoRoot, 'plugins/agc-unity-editor'), {
+ recursive: true,
+ force: true,
+ });
+ assert.throws(
+ () => prepare(fixture, { runCommand: fakeRunCommand() }),
+ /未产出必需文件/,
+ );
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('delivers editor branch artifacts declared as prepared or library staging', () => {
+ const fixture = buildFixture();
+ try {
+ const features = new Set([
+ 'unity-editor-execute',
+ 'godot-editor-execute',
+ 'cocos-editor-injection',
+ ]);
+ prepare(fixture, { features });
+ const staged = (relative) =>
+ path.join(fixture.destinationRoot, 'resources/plugins', relative);
+
+ // Unity:prepared 子目录整体复制
+ assert.ok(
+ fs.existsSync(
+ staged('agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe'),
+ ),
+ 'Unity helper 必须随包',
+ );
+ // Godot:libraryStaging 声明文件逐个复制
+ assert.ok(
+ fs.existsSync(
+ staged(
+ 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll',
+ ),
+ ),
+ 'Godot 扩展必须随包',
+ );
+ assert.ok(
+ fs.existsSync(
+ staged('agc-godot-editor/native/gdextension/vendor/provenance.json'),
+ ),
+ 'Godot 随包清单文件必须随包',
+ );
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+/// 全量 Windows 编辑器 feature:prepared / libraryStaging / nativePayload 三条交付路径全开。
+const ALL_WINDOWS_FEATURES = new Set([
+ 'unity-editor-execute',
+ 'godot-editor-execute',
+ 'cocos-editor-injection',
+]);
+
+function pluginStaged(fixture, relative) {
+ return path.join(
+ fixture.destinationRoot,
+ fixture.declaration.plugins.destinationDirectory,
+ relative,
+ );
+}
+
+test('keeps the previous codex resources when the replacement fails', () => {
+ const fixture = buildFixture();
+ try {
+ prepare(fixture);
+ const declaration = fixture.declaration;
+ const layout = declaration.codex.targets.find(
+ (entry) => entry.target === WINDOWS_TARGET,
+ );
+ const unit = path.join(
+ fixture.destinationRoot,
+ declaration.codex.resourceDirectory,
+ layout.directory,
+ );
+ const before = snapshot(unit);
+ const component = layout.files.find((relative) =>
+ relative.includes('code-mode-host'),
+ );
+ const vendor = path.join(
+ fixture.appRoot,
+ `node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}`,
+ );
+ // 上游组件变了:这一次必然走「staging → 目标」的替换,注入的失败正好落在替换上。
+ fs.writeFileSync(path.join(vendor, component), 'changed component\n');
+ // 锁定包未变化时 Codex 走缓存;移除记录强制重新读取上游内容。
+ fs.rmSync(fixture.recordPath);
+
+ const error = expectThrow(() =>
+ withRenameFailure(
+ (from, to) => to === path.resolve(unit),
+ () => prepare(fixture),
+ ),
+ );
+ assert.match(error.message, /替换 .*win-x64.* 失败/u);
+ assert.match(error.message, /旧资源已恢复/u);
+ assert.deepEqual(
+ snapshot(unit),
+ before,
+ '替换失败必须把旧资源原样恢复:内容、尺寸、时间戳与可执行位都不许变',
+ );
+ assert.equal(
+ fs.readFileSync(
+ path.join(unit, declaration.codex.noticeFileName),
+ 'utf8',
+ ),
+ 'windows codex notice\n',
+ '受版本控制的第三方声明必须还在原位',
+ );
+ const stagingPath = /staging 保留在 ([^;]+);/u.exec(error.message)?.[1];
+ assert.ok(stagingPath, `报错必须给出 staging 位置:${error.message}`);
+ // 目标目录旁边只允许剩「旧资源」和「留给人工检查的新 staging」;backup 必须已经归位。
+ const siblings = fs.readdirSync(path.dirname(unit));
+ assert.ok(siblings.includes(path.basename(unit)));
+ assert.ok(siblings.includes(path.basename(stagingPath)));
+ assert.ok(
+ siblings.every((entry) => !entry.includes('-backup-')),
+ '恢复成功后不得留下 backup 目录',
+ );
+ assert.equal(
+ fs.readFileSync(path.join(stagingPath, component), 'utf8'),
+ 'changed component\n',
+ '没有落盘的新产物必须留在 staging 里供人工检查',
+ );
+
+ // 注入消失后重试必须成功:旧资源完整 → 替换重新做一遍 → 落盘的是上游新内容。
+ const summaries = prepare(fixture);
+ assert.match(summaries[0], /重新生成/u);
+ assert.equal(
+ fs.readFileSync(path.join(unit, component), 'utf8'),
+ 'changed component\n',
+ );
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('keeps the previous plugin resources when the replacement fails', () => {
+ const fixture = buildFixture();
+ try {
+ prepare(fixture);
+ const destination = path.join(
+ fixture.destinationRoot,
+ fixture.declaration.plugins.destinationDirectory,
+ );
+ // 未声明的额外文件让缓存判定必然漂移:这次一定会走完整替换。
+ fs.writeFileSync(
+ path.join(destination, 'agc-demo-editor/src/rogue.mjs'),
+ 'rogue\n',
+ );
+ const before = snapshot(destination);
+
+ const error = expectThrow(() =>
+ withRenameFailure(
+ (from, to) => to === path.resolve(destination),
+ () => prepare(fixture),
+ ),
+ );
+ assert.match(error.message, /替换 .*plugins.* 失败/u);
+ assert.match(error.message, /旧资源已恢复/u);
+ assert.deepEqual(
+ snapshot(destination),
+ before,
+ '替换失败时旧插件资源(含尚未清理的额外文件)必须逐字节保留',
+ );
+
+ prepare(fixture);
+ assert.ok(
+ !fs.existsSync(path.join(destination, 'agc-demo-editor/src/rogue.mjs')),
+ '注入消失后重试必须成功并清掉额外文件',
+ );
+ assert.ok(
+ fs.existsSync(
+ path.join(destination, 'agc-demo-editor/panels/panel.html'),
+ ),
+ '重试后声明的随包内容必须齐全',
+ );
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('clears undeclared and hidden entries left in the staged plugin tree', () => {
+ const fixture = buildFixture();
+ try {
+ prepare(fixture);
+ // 三种残留:额外目录、隐藏目录、声明的源码目录里的隐藏文件。
+ for (const relative of [
+ 'agc-demo-editor/extra/rogue.txt',
+ 'agc-demo-editor/.cache/tmp.bin',
+ 'agc-demo-editor/src/.env',
+ ]) {
+ const file = pluginStaged(fixture, relative);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, 'residue\n');
+ }
+
+ const summaries = prepare(fixture);
+ assert.match(summaries[1], /plugins 重新生成/u);
+ for (const relative of [
+ 'agc-demo-editor/extra',
+ 'agc-demo-editor/.cache',
+ 'agc-demo-editor/src/.env',
+ ]) {
+ assert.ok(
+ !fs.existsSync(pluginStaged(fixture, relative)),
+ `未声明的残留 ${relative} 必须被清掉`,
+ );
+ }
+ for (const relative of [
+ 'agc-demo-editor/plugin.json',
+ 'agc-demo-editor/src/entry.mjs',
+ 'agc-demo-editor/panels/panel.html',
+ ]) {
+ assert.ok(
+ fs.existsSync(pluginStaged(fixture, relative)),
+ `声明的随包内容 ${relative} 必须还在`,
+ );
+ }
+ assert.ok(
+ !fs.existsSync(
+ pluginStaged(fixture, 'agc-demo-editor/panels/panel.test.mjs'),
+ ),
+ '跳过规则在重建后依然生效',
+ );
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('removes staged plugin files whose source has been deleted', () => {
+ const fixture = buildFixture();
+ try {
+ prepare(fixture);
+ assert.ok(
+ fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/panels/panel.html')),
+ '前置条件:源码里的文件已经随包',
+ );
+ fs.rmSync(
+ path.join(fixture.repoRoot, 'plugins/agc-demo-editor/panels/panel.html'),
+ );
+
+ const summaries = prepare(fixture);
+ assert.match(summaries[1], /plugins 重新生成/u);
+ assert.ok(
+ !fs.existsSync(
+ pluginStaged(fixture, 'agc-demo-editor/panels/panel.html'),
+ ),
+ '源码里已删除的文件不得留在随包目录',
+ );
+ assert.ok(
+ fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/src/entry.mjs')),
+ '同一插件的其他声明内容必须还在',
+ );
+ // 重建后的目录必须自洽:紧接着一次准备应命中缓存而不是反复重建。
+ assert.match(prepare(fixture)[1], /命中缓存/u);
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('clears staged prepared payloads when their feature is disabled', () => {
+ const fixture = buildFixture();
+ try {
+ prepare(fixture, { features: ALL_WINDOWS_FEATURES });
+ for (const relative of [
+ 'agc-cocos-editor/native/payload/cocos-editor-bridge.dll',
+ 'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe',
+ 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll',
+ 'agc-godot-editor/native/gdextension/vendor/provenance.json',
+ ]) {
+ assert.ok(
+ fs.existsSync(pluginStaged(fixture, relative)),
+ `前置条件:feature 全开时 ${relative} 必须随包`,
+ );
+ }
+
+ prepare(fixture, { features: new Set() });
+ for (const relative of [
+ 'agc-cocos-editor/native/payload',
+ 'agc-unity-editor/dotnet',
+ 'agc-godot-editor/native',
+ ]) {
+ assert.ok(
+ !fs.existsSync(pluginStaged(fixture, relative)),
+ `feature 关闭后 ${relative} 不得作为残留继续随包`,
+ );
+ }
+ for (const plugin of [
+ 'agc-cocos-editor',
+ 'agc-unity-editor',
+ 'agc-godot-editor',
+ ]) {
+ assert.ok(
+ fs.existsSync(pluginStaged(fixture, `${plugin}/plugin.json`)),
+ `feature 关闭不得动到 ${plugin} 的声明内容`,
+ );
+ assert.ok(
+ fs.existsSync(pluginStaged(fixture, `${plugin}/src/entry.mjs`)),
+ `feature 关闭不得动到 ${plugin} 的源码内容`,
+ );
+ }
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('treats declared prepared and library-staging artifacts as owned on a second run', () => {
+ const fixture = buildFixture();
+ try {
+ prepare(fixture, { features: ALL_WINDOWS_FEATURES });
+ const destination = path.join(
+ fixture.destinationRoot,
+ fixture.declaration.plugins.destinationDirectory,
+ );
+ const before = snapshot(destination);
+
+ const summaries = prepare(fixture, { features: ALL_WINDOWS_FEATURES });
+ assert.match(
+ summaries[1],
+ /plugins 命中缓存(未写入/u,
+ 'prepared 子目录与 libraryStaging 产物属于本工具,不得被误判成外来内容而反复重建',
+ );
+ assert.deepEqual(
+ snapshot(destination),
+ before,
+ '命中缓存时一个字节、一个时间戳都不许动',
+ );
+ for (const relative of [
+ 'agc-cocos-editor/native/payload/cocos-editor-bridge.dll',
+ 'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe',
+ 'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll',
+ 'agc-godot-editor/native/gdextension/vendor/provenance.json',
+ ]) {
+ assert.ok(
+ fs.existsSync(pluginStaged(fixture, relative)),
+ `已声明产物 ${relative} 不得因为缓存判定被删掉`,
+ );
+ }
+ } finally {
+ fixture.cleanup();
+ }
+});
diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs
index e619c7123..7cc06d82a 100644
--- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs
+++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs
@@ -1,8 +1,10 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs';
import {
- defaultEditorFeatures,
+ readCargoTarget,
+ resolveEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
import {
@@ -10,6 +12,10 @@ import {
resolveAgcDevEndpoint,
withAgcDevEndpointEnv,
} from './dev-port.mjs';
+import {
+ prepareBundledResources,
+ supportedHostTarget,
+} from './prepare-bundled-resources.mjs';
import {
isAiGameCreatorServer,
preflightExistingVite,
@@ -51,22 +57,51 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
}
// 开发和发行构建使用同一平台编辑器 feature 集合。
-// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。
-function readDevCargoFeatures(env = process.env) {
- const override = env.AGC_DEV_CARGO_FEATURES;
- if (override !== undefined) {
- return override
- .split(',')
- .map((value) => value.trim())
- .filter(Boolean);
- }
- return defaultEditorFeatures(process.platform);
+// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭;
+// 命令行显式 `--features` / `-f` 优先于环境变量(cargo 实际收到的是命令行参数)。
+// 入参是「最终交给 Tauri 的参数」:默认平台由其中的显式 `--target` 决定,未指定才是宿主平台。
+function readDevCargoFeatures(argv = []) {
+ return resolveEditorFeatures({
+ argv,
+ target: readCargoTarget(argv) ?? process.platform,
+ });
}
-function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
+function withDevCargoFeatures(argv, features = readDevCargoFeatures(argv)) {
return withDefaultCargoFeatures(argv, features);
}
+/// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。
+/// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。
+/// `argv` 是「最终交给 Tauri 的参数」;staging 目标与本次 cargo 构建目标同源:
+/// 显式 `--target` 优先,未指定时回落宿主目标;宿主平台不受声明覆盖(如 Linux)时保持跳过,
+/// 不为其新增随包资源准备。`features` 省略时按同一份参数解析,不允许「cargo 一套、staging 另一套」。
+function prepareBundledResourcesBeforeTauri(
+ features,
+ argv = [],
+ {
+ prepare = prepareBundledResources,
+ log = console.log,
+ hostTarget = supportedHostTarget(),
+ } = {},
+) {
+ const target = readCargoTarget(argv) ?? hostTarget;
+ if (!target) {
+ log(
+ '[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备',
+ );
+ return;
+ }
+ const summaries = prepare({
+ target,
+ features: new Set(features ?? readDevCargoFeatures(argv)),
+ log: (line) => log(`[ai-game-creator-shell] ${line}`),
+ });
+ for (const summary of summaries) {
+ log(`[ai-game-creator-shell] ${summary}`);
+ }
+}
+
function spawnTauriCli(argv, { env = process.env } = {}) {
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
cwd: appRoot,
@@ -75,6 +110,17 @@ function spawnTauriCli(argv, { env = process.env } = {}) {
});
}
+/// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的
+/// `rustc-wrapper`(本地常见为 sccache)会在这里生效。本地 sccache daemon 状态
+/// 一旦损坏,`cargo` 的首次 rustc 探测就会失败并阻断整个 AGC 启动;因此这里复用
+/// `npm run dev` 的本地 Rust 环境规则,由脚本而不是本机 Cargo 配置决定 wrapper。
+function buildTauriDevProcessEnv(endpoint, env = process.env) {
+ return buildLocalRustProcessEnv({
+ ...withAgcDevEndpointEnv(endpoint, env),
+ [AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
+ });
+}
+
async function runTauriDev(
argv = process.argv.slice(2),
{
@@ -84,6 +130,7 @@ async function runTauriDev(
spawnCli = spawnTauriCli,
waitForCli = waitForChildTermination,
terminateTree = terminateChildTree,
+ prepareResources = prepareBundledResourcesBeforeTauri,
} = {},
) {
const endpoint = await resolveDevEndpoint();
@@ -119,6 +166,12 @@ async function runTauriDev(
}
try {
+ // 目标与 feature 都从「最终交给 Tauri 的参数」解析:启动器自己的 `--` 之后是应用参数,
+ // 不能参与解析;注入默认 feature 也不改变这份解析结果。
+ const devTauriArguments = buildTauriArguments(argv, endpoint.url);
+ const devFeatures = readDevCargoFeatures(devTauriArguments);
+ prepareResources(devFeatures, devTauriArguments);
+ // Windows 下 Vite 会监听资源目录;必须在启动前完成目录替换,避免 rename 被占用。
const preparation = prepareFrontend(endpoint, {
signal: preparationAbort.signal,
onChild(frontend) {
@@ -131,14 +184,11 @@ async function runTauriDev(
]);
if (!prepared || shutdownSignal) return 1;
const tauriArguments = buildTauriArguments(
- withDevCargoFeatures(argv),
+ withDevCargoFeatures(argv, devFeatures),
endpoint.url,
);
child = spawnCli(tauriArguments, {
- env: {
- ...withAgcDevEndpointEnv(endpoint),
- [AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
- },
+ env: buildTauriDevProcessEnv(endpoint),
});
const childResult = waitForCli(child);
const outcome = await Promise.race([
@@ -225,7 +275,9 @@ function isDirectModuleExecution() {
export {
buildTauriArguments,
+ buildTauriDevProcessEnv,
isDirectModuleExecution,
+ prepareBundledResourcesBeforeTauri,
runTauriDev,
spawnTauriCli,
withDevCargoFeatures,
diff --git a/apps/ai-game-creator-shell/src-tauri/.taurignore b/apps/ai-game-creator-shell/src-tauri/.taurignore
deleted file mode 100644
index 8ec126556..000000000
--- a/apps/ai-game-creator-shell/src-tauri/.taurignore
+++ /dev/null
@@ -1,4 +0,0 @@
-# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
-# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
-# “构建 -> 监听 -> 再构建”的自触发循环。
-resources/plugins/
diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs
index 9238cb34c..d84e818a7 100644
--- a/apps/ai-game-creator-shell/src-tauri/build.rs
+++ b/apps/ai-game-creator-shell/src-tauri/build.rs
@@ -1,7 +1,8 @@
+// 构建脚本只用布局里的目录与校验入口(写入分支已移交准备步骤),
+// 其余字段与常量供运行期使用,因此这里不报构建上下文里的 dead_code。
+#[allow(dead_code)]
#[path = "build_support/codex_bundle.rs"]
mod codex_bundle;
-#[path = "build_support/codex_package_metadata.rs"]
-mod codex_package_metadata;
#[path = "build_support/frontend_dist_guard.rs"]
mod frontend_dist_guard;
#[path = "build_support/godot_bundle.rs"]
@@ -9,287 +10,12 @@ mod godot_bundle;
#[path = "build_support/runtime_prompt_bundle.rs"]
mod runtime_prompt_bundle;
-use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::env;
use std::fs;
use std::path::PathBuf;
-use std::io::{BufReader, Read};
-
-fn sha256_file(path: &std::path::Path) -> Result {
- let file = fs::File::open(path)?;
- let mut reader = BufReader::new(file);
- let mut hasher = Sha256::new();
- let mut buffer = [0_u8; 64 * 1024];
- loop {
- let read = reader.read(&mut buffer)?;
- if read == 0 {
- break;
- }
- hasher.update(&buffer[..read]);
- }
- Ok(format!("{:x}", hasher.finalize()))
-}
-
-fn claude_agent_platform(target: &str) -> Option<&'static str> {
- match target {
- "x86_64-pc-windows-msvc" => Some("win32-x64"),
- "aarch64-pc-windows-msvc" => Some("win32-arm64"),
- "x86_64-apple-darwin" => Some("darwin-x64"),
- "aarch64-apple-darwin" => Some("darwin-arm64"),
- "x86_64-unknown-linux-gnu" => Some("linux-x64"),
- "aarch64-unknown-linux-gnu" => Some("linux-arm64"),
- "x86_64-unknown-linux-musl" => Some("linux-x64-musl"),
- "aarch64-unknown-linux-musl" => Some("linux-arm64-musl"),
- _ => None,
- }
-}
-
-fn stage_bundled_claude_agent(manifest_dir: &std::path::Path) {
- let target = env::var("TARGET").expect("Cargo TARGET");
- let Some(platform) = claude_agent_platform(&target) else {
- println!("cargo:warning=Claude Agent SDK sidecar 暂不支持目标:{target}");
- return;
- };
- let app_root = manifest_dir.parent().expect("AGC Tauri manifest parent");
- let repo_root = app_root
- .parent()
- .and_then(|apps_dir| apps_dir.parent())
- .expect("AGC 应用必须位于仓库 apps 目录下");
- let roots = [app_root, repo_root];
- let sdk_source = roots
- .iter()
- .map(|root| root.join("node_modules/@anthropic-ai/claude-agent-sdk"))
- .find(|path| path.join("sdk.mjs").is_file())
- .unwrap_or_else(|| panic!("Claude Agent SDK 缺失;请先执行 npm ci"));
- let binary_package = format!("claude-agent-sdk-{platform}");
- let binary_source = roots
- .iter()
- .map(|root| {
- root.join("node_modules/@anthropic-ai")
- .join(&binary_package)
- })
- .find(|path| path.is_dir())
- .unwrap_or_else(|| panic!("Claude Agent SDK 原生运行时缺失:{binary_package}"));
- let entry_source = app_root.join("agent-sidecar/src/index.mjs");
- assert!(entry_source.is_file(), "Claude Agent SDK sidecar 入口缺失");
- let target_root = manifest_dir.join("resources/claude-agent");
- if target_root.exists() {
- fs::remove_dir_all(&target_root).expect("清理 Claude Agent SDK staging 失败");
- }
- fs::create_dir_all(target_root.join("node_modules/@anthropic-ai"))
- .expect("创建 Claude Agent SDK staging 目录失败");
- stage_plugin_file(&entry_source, &target_root.join("index.mjs"));
- copy_plugin_tree(
- &sdk_source,
- &target_root.join("node_modules/@anthropic-ai/claude-agent-sdk"),
- );
- copy_plugin_tree(
- &binary_source,
- &target_root
- .join("node_modules/@anthropic-ai")
- .join(&binary_package),
- );
- let binary_name = if target.contains("windows") {
- "claude.exe"
- } else {
- "claude"
- };
- let staged_binary = target_root
- .join("node_modules/@anthropic-ai")
- .join(&binary_package)
- .join(binary_name);
- if let Ok(metadata) = fs::metadata(binary_source.join(binary_name)) {
- fs::set_permissions(&staged_binary, metadata.permissions())
- .expect("保留 Claude Agent SDK 原生运行时权限失败");
- }
- println!("cargo:rerun-if-changed={}", entry_source.display());
- println!("cargo:rerun-if-changed={}", sdk_source.display());
- println!("cargo:rerun-if-changed={}", binary_source.display());
-}
-
-fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
- let target = env::var("TARGET").expect("Cargo TARGET");
- println!("cargo:rustc-env=AGC_BUILD_TARGET={target}");
- if target.contains("apple-darwin") {
- // Tauri 的 universal 两次 Cargo 编译共用 resource staging,
- // 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。
- let staging = manifest_dir.join("resources/codex/mac-native");
- if staging.exists() {
- fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败");
- }
- for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] {
- stage_codex_target(manifest_dir, target);
- }
- } else {
- stage_codex_target(manifest_dir, &target);
- }
-}
-
-fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
- let Some(layout) = codex_bundle::for_target(target) else {
- assert!(
- !target.contains("windows") && !target.contains("apple-darwin"),
- "不支持的 Codex 随包目标:{target}"
- );
- return;
- };
- {
- let app_root = manifest_dir
- .parent()
- .expect("AI 游戏创作 Tauri manifest 必须位于应用目录下");
- let repo_root = app_root
- .parent()
- .and_then(|apps_dir| apps_dir.parent())
- .expect("AI 游戏创作应用必须位于仓库 apps 目录下");
- let package = format!("codex-{}", layout.platform);
- let source_candidates = [app_root, repo_root]
- .into_iter()
- .flat_map(|root| {
- [
- root.join(format!("node_modules/@openai/{package}/vendor/{target}")),
- root.join(format!(
- "node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}"
- )),
- ]
- })
- .collect::>();
- let source = source_candidates
- .iter()
- .find(|path| {
- layout
- .files
- .iter()
- .all(|relative| path.join(relative).is_file())
- })
- .cloned()
- .unwrap_or_else(|| {
- panic!(
- "内置 Codex CLI 缺失;请先在仓库根目录执行 npm ci(已检查:{})",
- source_candidates
- .iter()
- .map(|path| path.display().to_string())
- .collect::>()
- .join(";")
- )
- });
- let metadata: serde_json::Value = serde_json::from_slice(
- &fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"),
- )
- .expect("Codex 原生包元数据无效");
- codex_package_metadata::validate_package_metadata(&metadata, target, layout)
- .unwrap_or_else(|error| panic!("{error}"));
- let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
- let notice = target_dir.join("NOTICE.md");
- if target.contains("apple-darwin") {
- let source_notice =
- manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md");
- stage_plugin_file(&source_notice, ¬ice);
- println!("cargo:rerun-if-changed={}", source_notice.display());
- }
- if !notice.is_file() {
- 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);
- let target_path = target_dir.join(relative);
- if let Some(parent) = target_path.parent() {
- fs::create_dir_all(parent).expect("创建内置 Codex CLI 资源子目录失败");
- }
- let source_sha256 = sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败");
- let target_matches_source = target_path.is_file()
- && sha256_file(&target_path)
- .map(|target_sha256| target_sha256 == source_sha256)
- .unwrap_or(false);
- let source_permissions = fs::metadata(&source_path)
- .expect("读取组件权限失败")
- .permissions();
- if !target_matches_source {
- fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败");
- fs::set_permissions(&target_path, source_permissions.clone())
- .expect("保留内置 Codex CLI 组件权限失败");
- } else if fs::metadata(&target_path)
- .expect("读取内置 Codex CLI 资源失败")
- .permissions()
- != source_permissions
- {
- // 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。
- // 权限已一致时不再写元数据:Windows 上这次写入会更新 change time,
- // 让 tauri dev 的文件监听把每次构建都当成 staging 变更而无限重建。
- fs::set_permissions(&target_path, source_permissions)
- .expect("保留内置 Codex CLI 组件权限失败");
- }
- file_hashes.insert(
- relative.to_string(),
- serde_json::Value::String(source_sha256),
- );
- }
- let manifest = serde_json::json!({
- "schemaVersion": codex_bundle::SCHEMA,
- "platform": layout.platform,
- "version": codex_bundle::CLI_VERSION,
- "files": file_hashes,
- });
- let manifest_path = target_dir.join("manifest.json");
- let manifest_payload = format!(
- "{}\n",
- serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败")
- );
- if fs::read_to_string(&manifest_path)
- .map(|current| current != manifest_payload)
- .unwrap_or(true)
- {
- fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败");
- }
- for relative in layout.files {
- println!("cargo:rerun-if-changed={}", source.join(relative).display());
- }
- println!("cargo:rerun-if-changed={}", notice.display());
- }
-}
-
-/// 删除 `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);
-}
+use codex_bundle::package_layout;
fn seed_task_group_id(
group: &shared_contracts::game_creation_app::GameCreationAppAgentGroup,
@@ -334,17 +60,128 @@ fn validate_seed_task_catalog(compiled: &runtime_prompt_bundle::CompiledPromptBu
}
}
+/// 只读校验:确认已经落盘的随包产物与声明一致。本函数不写任何文件。
+fn validate_staged_resources(manifest_dir: &std::path::Path) {
+ let target = env::var("TARGET").expect("Cargo TARGET");
+ for staged_target in package_layout::staged_targets(&target) {
+ let Some(layout) = codex_bundle::for_target(staged_target) else {
+ continue;
+ };
+ let target_dir = manifest_dir
+ .join(package_layout::codex().resource_directory)
+ .join(layout.directory);
+ package_layout::validate_staged_codex_bundle(&target_dir, staged_target).unwrap_or_else(
+ |error| panic!("内置 Codex CLI 随包资源校验失败({staged_target}):{error}"),
+ );
+ }
+ validate_staged_claude_agent(manifest_dir, &target);
+ validate_staged_plugin_workspace(manifest_dir, &target);
+ validate_prepared_payloads(manifest_dir, &target);
+}
+
+/// 只读校验 Claude Agent SDK sidecar:入口、SDK 与平台原生运行时必须在位。
+/// 未声明该目标时不随包 sidecar,直接放行(Linux 等平台不参与客户端打包)。
+fn validate_staged_claude_agent(manifest_dir: &std::path::Path, target: &str) {
+ if package_layout::claude_agent_target(target).is_none() {
+ return;
+ }
+ let app_root = manifest_dir
+ .parent()
+ .expect("AI 游戏创作 Tauri manifest 必须位于应用目录下");
+ let target_dir = manifest_dir.join(package_layout::claude_agent().resource_directory);
+ package_layout::validate_staged_claude_agent(&target_dir, app_root, target).unwrap_or_else(
+ |error| panic!("Claude Agent SDK sidecar 随包资源校验失败({target}):{error}"),
+ );
+}
+
+/// 只读校验插件随包工作区:声明的源码派生内容必须与仓库源码逐文件一致,整树无符号链接。
+/// 已准备产物与随包库在本机无法重建,构建期至少要确认它们已经就位。
+fn validate_prepared_payloads(manifest_dir: &std::path::Path, target: &str) {
+ if !package_layout::plugin_staging_applies(target) {
+ return;
+ }
+ let declared = package_layout::plugins();
+ let repo_root = manifest_dir
+ .parent()
+ .and_then(|app_root| app_root.parent())
+ .and_then(|apps_dir| apps_dir.parent())
+ .expect("AGC 应用必须位于仓库 apps 目录下");
+ let destination_root = manifest_dir.join(declared.destination_directory);
+ for plugin in package_layout::plugin_directories(
+ &repo_root.join(declared.source_directory),
+ declared.manifest_file_name,
+ )
+ .unwrap_or_else(|error| panic!("{error}"))
+ {
+ for subdirectory in declared.subdirectories {
+ if !package_layout::subdirectory_is_prepared(subdirectory)
+ || !package_layout::subdirectory_applies_to_plugin(subdirectory, &plugin.name)
+ || !package_layout::subdirectory_enabled(
+ subdirectory,
+ target,
+ package_layout::cargo_feature_enabled,
+ )
+ {
+ continue;
+ }
+ let relative = package_layout::declared_relative_path(subdirectory.path);
+ let staged = destination_root.join(&plugin.name).join(&relative);
+ if !staged.is_dir() {
+ panic!(
+ "随包已准备产物缺失:{}(请先执行随包资源准备步骤)",
+ staged.display()
+ );
+ }
+ }
+ for staging in declared.library_staging {
+ if plugin.name != staging.plugin
+ || !package_layout::library_staging_enabled(
+ staging,
+ target,
+ package_layout::cargo_feature_enabled,
+ )
+ {
+ continue;
+ }
+ let relative = package_layout::declared_relative_path(staging.source_subdirectory);
+ let staged = destination_root.join(&plugin.name).join(&relative);
+ godot_bundle::validate(&staged)
+ .unwrap_or_else(|error| panic!("Godot 随包资源校验失败:{error}"));
+ }
+ }
+}
+
+fn validate_staged_plugin_workspace(manifest_dir: &std::path::Path, target: &str) {
+ let declared = package_layout::plugins();
+ let repo_root = manifest_dir
+ .parent()
+ .and_then(|app_root| app_root.parent())
+ .and_then(|apps_dir| apps_dir.parent())
+ .expect("AGC 应用必须位于仓库 apps 目录下");
+ package_layout::validate_staged_plugins(
+ &repo_root.join(declared.source_directory),
+ &manifest_dir.join(declared.destination_directory),
+ target,
+ package_layout::cargo_feature_enabled,
+ )
+ .unwrap_or_else(|error| panic!("插件随包资源校验失败:{error}"));
+}
fn main() {
let manifest_dir = PathBuf::from(
env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"),
);
let manifest_path = manifest_dir.join("prompts/runtime/manifest.json");
- stage_bundled_claude_agent(&manifest_dir);
- stage_bundled_codex_cli(&manifest_dir);
- prepare_unity_editor_helper(&manifest_dir);
- prepare_godot_editor_extension(&manifest_dir);
- stage_plugin_workspace(&manifest_dir);
- stage_cocos_editor_payload(&manifest_dir);
+ // 运行期定位随包目录依赖该编译期常量,与是否跳过 staging 无关(见技术方案 §4.4)。
+ println!(
+ "cargo:rustc-env=AGC_BUILD_TARGET={}",
+ env::var("TARGET").expect("Cargo TARGET")
+ );
+ // Claude Agent SDK 的版本只声明一处,运行期的 sidecar 身份串由此取值。
+ println!(
+ "cargo:rustc-env=AGC_CLAUDE_AGENT_SDK_VERSION={}",
+ package_layout::claude_agent().version
+ );
+ validate_staged_resources(&manifest_dir);
let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path)
.unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}"));
validate_seed_task_catalog(&compiled);
@@ -366,315 +203,3 @@ fn main() {
}
tauri_build::build()
}
-
-#[cfg(windows)]
-fn stage_cocos_editor_payload(manifest_dir: &std::path::Path) {
- if std::env::var_os("CARGO_FEATURE_COCOS_EDITOR_INJECTION").is_none() {
- return;
- }
- let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR"));
- let profile_dir = out_dir
- .ancestors()
- .find(|path| path.file_name().is_some_and(|name| name == "build"))
- .and_then(|build_dir| build_dir.parent())
- .expect("AGC Cargo profile directory not found");
- let candidates = [
- profile_dir.join("deps/cocos_editor_bridge.dll"),
- profile_dir.join("cocos_editor_bridge.dll"),
- ];
- let source = candidates
- .iter()
- .find(|path| path.is_file())
- .unwrap_or_else(|| {
- panic!(
- "Cocos bridge native payload 未构建:{}",
- candidates
- .iter()
- .map(|p| p.display().to_string())
- .collect::>()
- .join(";")
- )
- });
- for destination in [
- // 插件工作区里的 payload 是开发态与打包态的唯一真源。
- manifest_dir
- .join("../../../plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"),
- // 随包资源目录与 tauri.windows.conf.json 的 `resources/plugins` 映射保持一致。
- manifest_dir
- .join("resources/plugins/agc-cocos-editor/native/payload/cocos-editor-bridge.dll"),
- ] {
- std::fs::create_dir_all(destination.parent().expect("payload resource parent"))
- .expect("创建 Cocos bridge payload 目录失败");
- std::fs::copy(source, &destination).expect("复制 Cocos bridge native payload 失败");
- }
- println!("cargo:rerun-if-changed={}", source.display());
-}
-
-#[cfg(not(windows))]
-fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {}
-
-/// Unity helper 是插件的随包运行文件。内容指纹避免每次 Cargo 检查都重新发布 .NET。
-fn prepare_unity_editor_helper(manifest_dir: &std::path::Path) {
- println!("cargo:rerun-if-env-changed=CARGO_FEATURE_UNITY_EDITOR_EXECUTE");
- let target = env::var("TARGET").expect("Cargo TARGET");
- if env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none()
- || target != "x86_64-pc-windows-msvc"
- {
- return;
- }
- let root = manifest_dir.join("../../../plugins/agc-unity-editor/dotnet");
- let mut sources = Vec::new();
- collect_unity_helper_sources(&root, &mut sources);
- sources.sort();
- let mut fingerprint = Sha256::new();
- for source in &sources {
- println!("cargo:rerun-if-changed={}", source.display());
- fingerprint.update(
- source
- .strip_prefix(&root)
- .expect("helper source")
- .to_string_lossy()
- .as_bytes(),
- );
- fingerprint.update([0]);
- fingerprint.update(fs::read(source).expect("读取 Unity helper 源文件失败"));
- }
- let fingerprint = format!("{:x}", fingerprint.finalize());
- let publish = root.join("publish/win-x64");
- let executable = publish.join("Agc.Unity.Attach.exe");
- let stamp = publish.join(".agc-source.sha256");
- println!("cargo:rerun-if-changed={}", executable.display());
- if unity_helper_publish_complete(&publish)
- && fs::read_to_string(&stamp).ok().as_deref() == Some(&fingerprint)
- {
- return;
- }
- assert!(
- cfg!(windows),
- "构建 Unity 插件 helper 需要 Windows .NET 10 与 x64 C++ 工具链"
- );
- let status = std::process::Command::new("powershell.exe")
- .args([
- "-NoProfile",
- "-NonInteractive",
- "-ExecutionPolicy",
- "Bypass",
- "-File",
- ])
- .arg(root.join("build.ps1"))
- .current_dir(&root)
- .status()
- .expect("无法启动 Unity helper 构建脚本");
- assert!(
- status.success() && unity_helper_publish_complete(&publish),
- "Unity helper 构建失败或缺少运行文件/许可"
- );
- fs::write(stamp, fingerprint).expect("写入 Unity helper 构建指纹失败");
-}
-
-fn unity_helper_publish_complete(publish: &std::path::Path) -> bool {
- [
- "Agc.Unity.Attach.exe",
- "NOTICE",
- "THIRD-PARTY-NOTICES.txt",
- "licenses/DotCraft-Apache-2.0.txt",
- "licenses/Roslyn-MIT.txt",
- "licenses/upstream.json",
- "licenses/dotnet-LICENSE.TXT",
- "licenses/dotnet-THIRD-PARTY-NOTICES.TXT",
- "licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf",
- "licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf",
- ]
- .iter()
- .all(|name| {
- fs::symlink_metadata(publish.join(name)).is_ok_and(|metadata| {
- metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() > 0
- })
- })
-}
-
-fn collect_unity_helper_sources(root: &std::path::Path, sources: &mut Vec) {
- for entry in fs::read_dir(root)
- .expect("Unity helper 源码目录缺失")
- .flatten()
- {
- let kind = entry.file_type().expect("读取 Unity helper 源文件类型失败");
- assert!(!kind.is_symlink(), "Unity helper 源码不允许符号链接");
- let name = entry.file_name();
- if kind.is_dir() {
- if !matches!(
- name.to_str(),
- Some("bin" | "obj" | "publish" | "native-build")
- ) {
- collect_unity_helper_sources(&entry.path(), sources);
- }
- } else if kind.is_file() {
- sources.push(entry.path());
- }
- }
-}
-
-fn prepare_godot_editor_extension(manifest_dir: &std::path::Path) {
- println!("cargo:rerun-if-env-changed=CARGO_FEATURE_GODOT_EDITOR_EXECUTE");
- if env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_none()
- || env::var("TARGET").expect("Cargo TARGET") != "x86_64-pc-windows-msvc"
- {
- return;
- }
- let root = manifest_dir.join("../../../plugins/agc-godot-editor/native/gdextension");
- for source in godot_bundle::source_files(&root).unwrap_or_else(|error| panic!("{error}")) {
- println!("cargo:rerun-if-changed={}", source.display());
- }
- assert!(
- cfg!(windows),
- "构建 Godot 原生扩展需要 Windows x64 C 编译器"
- );
- let status = std::process::Command::new("powershell.exe")
- // Cargo 可能从 PowerShell 7 启动,Windows PowerShell 应使用自身模块目录。
- .env_remove("PSModulePath")
- .args([
- "-NoProfile",
- "-NonInteractive",
- "-ExecutionPolicy",
- "Bypass",
- "-File",
- ])
- .arg(root.join("build.ps1"))
- .current_dir(&root)
- .status()
- .expect("无法启动 Godot 原生扩展构建脚本");
- assert!(status.success(), "Godot 原生扩展构建失败");
- godot_bundle::validate(&root).unwrap_or_else(|error| panic!("{error}"));
-}
-
-/// 把 `plugins/` 工作区里的插件包随包映射到应用资源目录。
-///
-/// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、
-/// Cargo target 目录或 node_modules。
-fn stage_plugin_workspace(manifest_dir: &std::path::Path) {
- let target = env::var("TARGET").expect("Cargo TARGET");
- if !target.contains("windows") && !target.contains("apple-darwin") {
- return;
- }
- let repo_root = manifest_dir
- .parent()
- .and_then(|app_root| app_root.parent())
- .and_then(|apps_dir| apps_dir.parent())
- .expect("AGC 应用必须位于仓库 apps 目录下")
- .to_path_buf();
- let workspace = repo_root.join("plugins");
- let destination_root = manifest_dir.join("resources/plugins");
- // staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。
- if destination_root.exists() {
- std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败");
- }
- std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败");
- let entries = match std::fs::read_dir(&workspace) {
- Ok(entries) => entries,
- Err(_) => return,
- };
- for entry in entries.flatten() {
- let plugin_root = entry.path();
- assert!(
- !entry
- .file_type()
- .expect("读取插件目录类型失败")
- .is_symlink(),
- "插件工作区不允许符号链接"
- );
- if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() {
- continue;
- }
- let name = entry.file_name();
- let destination = destination_root.join(&name);
- copy_plugin_file(
- &plugin_root.join("plugin.json"),
- &destination.join("plugin.json"),
- );
- for relative in [
- std::path::PathBuf::from("src"),
- std::path::PathBuf::from("panels"),
- std::path::PathBuf::from("skills"),
- std::path::PathBuf::from("native/payload"),
- std::path::PathBuf::from("dotnet/publish/win-x64"),
- ] {
- if (relative == std::path::Path::new("native/payload") && !target.contains("windows"))
- || (relative == std::path::Path::new("dotnet/publish/win-x64")
- && (target != "x86_64-pc-windows-msvc"
- || env::var_os("CARGO_FEATURE_UNITY_EDITOR_EXECUTE").is_none()))
- {
- continue;
- }
- copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative));
- }
- if name == "agc-godot-editor" {
- godot_bundle::stage(
- &plugin_root.join("native/gdextension"),
- &destination.join("native/gdextension"),
- &target,
- env::var_os("CARGO_FEATURE_GODOT_EDITOR_EXECUTE").is_some(),
- )
- .unwrap_or_else(|error| panic!("{error}"));
- }
- println!("cargo:rerun-if-changed={}", plugin_root.display());
- }
-}
-
-fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
- let bytes = std::fs::read(source)
- .unwrap_or_else(|error| panic!("读取随包资源失败 {}:{error}", source.display()));
- if std::fs::read(destination).is_ok_and(|existing| existing == bytes) {
- return;
- }
- if let Some(parent) = destination.parent() {
- std::fs::create_dir_all(parent).expect("创建插件资源目录失败");
- }
- std::fs::write(destination, bytes).expect("复制插件资源失败");
-}
-
-fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) {
- let entries = match std::fs::read_dir(source) {
- Ok(entries) => entries,
- Err(_) => return,
- };
- for entry in entries.flatten() {
- let target = destination.join(entry.file_name());
- let path = entry.path();
- assert!(
- !entry
- .file_type()
- .expect("读取插件文件类型失败")
- .is_symlink(),
- "插件资源不允许符号链接"
- );
- if path.is_dir() {
- let name = entry.file_name();
- let name = name.to_string_lossy();
- if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") {
- continue;
- }
- std::fs::create_dir_all(&target).expect("创建插件资源目录失败");
- copy_plugin_tree(&path, &target);
- } else {
- // 测试文件不随包分发。
- let name = entry.file_name();
- let name = name.to_string_lossy();
- if name.contains(".test.") {
- continue;
- }
- if name.starts_with('.') {
- continue;
- }
- stage_plugin_file(&path, &target);
- }
- }
-}
-
-fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) {
- if !source.is_file() {
- return;
- }
- std::fs::create_dir_all(destination.parent().expect("插件资源父目录"))
- .expect("创建插件资源目录失败");
- std::fs::copy(source, destination).expect("复制插件资源失败");
-}
diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs
index e2064d28d..d802866df 100644
--- a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs
+++ b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs
@@ -1,8 +1,18 @@
//! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。
+//!
+//! 布局、组件白名单与版本常量来自唯一声明 `build_support/package-layout.json`
+//! (Rust 侧经 `build_support/package-layout.generated.rs` 取得编译期常量,
+//! 由 `scripts/check-package-layout.mjs` 生成并在门禁中校验一致)。
+//! 本模块只读声明,不写任何随包资源。
-pub const VERSION: &str = "0.155.1";
-pub const CLI_VERSION: &str = "codex-cli 0.155.1";
-pub const SCHEMA: &str = "genarrative-codex-sidecar.v2";
+// 共享声明模块:构建脚本、运行期与测试各自只用到其中一部分,未用到的入口不算缺陷。
+#[allow(dead_code)]
+#[path = "package_layout.rs"]
+pub(crate) mod package_layout;
+
+pub const VERSION: &str = package_layout::CODEX_VERSION;
+pub const CLI_VERSION: &str = package_layout::CODEX_CLI_VERSION;
+pub const SCHEMA: &str = package_layout::CODEX_MANIFEST_SCHEMA;
#[derive(Clone, Copy, Debug)]
pub struct Layout {
@@ -12,46 +22,13 @@ pub struct Layout {
pub files: &'static [&'static str],
}
-const WINDOWS_FILES: &[&str] = &[
- "bin/codex.exe",
- "bin/codex-code-mode-host.exe",
- "codex-path/rg.exe",
- "codex-resources/codex-command-runner.exe",
- "codex-resources/codex-windows-sandbox-setup.exe",
- "codex-package.json",
-];
-const MAC_FILES: &[&str] = &[
- "bin/codex",
- "bin/codex-code-mode-host",
- "codex-path/rg",
- "codex-resources/zsh/bin/zsh",
- "codex-package.json",
-];
-
pub fn for_target(target: &str) -> Option {
- match target {
- "x86_64-pc-windows-msvc" => Some(Layout {
- platform: "win32-x64",
- directory: "win-x64",
- executable: "bin/codex.exe",
- files: WINDOWS_FILES,
- }),
- "aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout {
- platform: if target.starts_with("aarch64") {
- "darwin-arm64"
- } else {
- "darwin-x64"
- },
- directory: if target.starts_with("aarch64") {
- "mac-native/darwin-arm64"
- } else {
- "mac-native/darwin-x64"
- },
- executable: "bin/codex",
- files: MAC_FILES,
- }),
- _ => None,
- }
+ package_layout::codex_target(target).map(|declared| Layout {
+ platform: declared.platform,
+ directory: declared.directory,
+ executable: declared.executable,
+ files: declared.files,
+ })
}
#[cfg(test)]
@@ -80,4 +57,11 @@ mod tests {
assert!(for_target("aarch64-pc-windows-msvc").is_none());
assert!(for_target("x86_64-unknown-linux-gnu").is_none());
}
+
+ #[test]
+ fn constants_come_from_the_shared_declaration() {
+ assert_eq!(VERSION, "0.155.1");
+ assert_eq!(CLI_VERSION, format!("codex-cli {VERSION}"));
+ assert_eq!(SCHEMA, "genarrative-codex-sidecar.v2");
+ }
}
diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs
deleted file mode 100644
index 04b9b6a41..000000000
--- a/apps/ai-game-creator-shell/src-tauri/build_support/codex_package_metadata.rs
+++ /dev/null
@@ -1,57 +0,0 @@
-//! 随包阶段的原生包元数据校验,不进入运行时生产模块。
-
-use super::codex_bundle::{Layout, VERSION};
-
-pub fn validate_package_metadata(
- metadata: &serde_json::Value,
- target: &str,
- layout: Layout,
-) -> Result<(), String> {
- if metadata["layoutVersion"] == 1
- && metadata["version"] == VERSION
- && metadata["target"] == target
- && metadata["entrypoint"] == layout.executable
- && metadata["resourcesDir"] == "codex-resources"
- && metadata["pathDir"] == "codex-path"
- {
- Ok(())
- } else {
- Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}"))
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::super::codex_bundle::for_target;
- use super::*;
-
- #[test]
- fn metadata_rejects_version_architecture_and_layout_drift() {
- let target = "aarch64-apple-darwin";
- let layout = for_target(target).unwrap();
- let valid = serde_json::json!({
- "layoutVersion": 1,
- "version": VERSION,
- "target": target,
- "entrypoint": "bin/codex",
- "resourcesDir": "codex-resources",
- "pathDir": "codex-path",
- });
- assert!(validate_package_metadata(&valid, target, layout).is_ok());
- for (key, value) in [
- ("layoutVersion", serde_json::json!(2)),
- ("version", serde_json::json!("0.0.0")),
- ("target", serde_json::json!("x86_64-apple-darwin")),
- ("entrypoint", serde_json::json!("bin/codex.exe")),
- ("resourcesDir", serde_json::json!("../private")),
- ("pathDir", serde_json::json!(null)),
- ] {
- let mut invalid = valid.clone();
- invalid[key] = value;
- assert!(
- validate_package_metadata(&invalid, target, layout).is_err(),
- "{key}"
- );
- }
- }
-}
diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs
index 8a326ac98..0c0061d5b 100644
--- a/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs
+++ b/apps/ai-game-creator-shell/src-tauri/build_support/godot_bundle.rs
@@ -1,13 +1,13 @@
use sha2::{Digest, Sha256};
use std::fs;
-use std::path::{Path, PathBuf};
+use std::path::Path;
-pub const BUNDLE_FILES: [&str; 4] = [
- "bin/win-x64/agc_godot_editor.dll",
- "bin/win-x64/metadata.json",
- "vendor/LICENSE.txt",
- "vendor/provenance.json",
-];
+// 共享随包资源声明:Godot 随包文件清单与构建期校验共用同一份来源。
+#[allow(dead_code)]
+#[path = "package_layout.rs"]
+mod package_layout;
+
+pub const BUNDLE_FILES: &[&str] = package_layout::GODOT_BUNDLE_FILES;
fn plain_metadata(path: &Path) -> Result {
let metadata = fs::symlink_metadata(path)
@@ -74,39 +74,6 @@ pub fn validate(root: &Path) -> Result)>, String> {
Ok(files)
}
-pub fn stage(root: &Path, destination: &Path, target: &str, enabled: bool) -> Result<(), String> {
- if target != "x86_64-pc-windows-msvc" || !enabled {
- return Ok(());
- }
- for (relative, bytes) in validate(root)? {
- let path = destination.join(relative);
- fs::create_dir_all(path.parent().expect("Godot resource parent"))
- .map_err(|error| format!("创建 Godot 资源目录失败:{error}"))?;
- fs::write(&path, bytes).map_err(|error| format!("写入 Godot 资源失败:{error}"))?;
- }
- Ok(())
-}
-
-pub fn source_files(root: &Path) -> Result, String> {
- plain_metadata(root)?;
- let mut sources = Vec::new();
- for entry in fs::read_dir(root).map_err(|error| format!("读取 Godot 源码失败:{error}"))?
- {
- let entry = entry.map_err(|error| format!("读取 Godot 源码目录项失败:{error}"))?;
- if matches!(entry.file_name().to_str(), Some("bin" | ".build")) {
- continue;
- }
- let metadata = plain_metadata(&entry.path())?;
- if metadata.is_dir() {
- sources.extend(source_files(&entry.path())?);
- } else if metadata.is_file() {
- sources.push(entry.path());
- }
- }
- sources.sort();
- Ok(sources)
-}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -134,82 +101,20 @@ mod tests {
}
#[test]
- fn stage_only_verified_windows_runtime_and_not_build_inputs() {
- let source = tempfile::tempdir().unwrap();
- let destination = tempfile::tempdir().unwrap();
- fixture(source.path());
- fs::write(source.path().join("bridge.gd"), "source").unwrap();
- fs::write(source.path().join("bin/win-x64/extra.dll"), "excluded").unwrap();
- stage(
- source.path(),
- destination.path(),
- "x86_64-pc-windows-msvc",
- true,
- )
- .unwrap();
+ fn validate_rejects_incomplete_bundle() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let error = validate(temp.path()).expect_err("空目录必须被拒绝");
+ assert!(!error.is_empty(), "失败原因不能为空");
+
+ // 只有文件名、内容不合法的「像样」目录也必须被拒绝。
for relative in BUNDLE_FILES {
- assert_eq!(
- fs::read(source.path().join(relative)).unwrap(),
- fs::read(destination.path().join(relative)).unwrap()
- );
+ let file = temp.path().join(relative);
+ fs::create_dir_all(file.parent().expect("parent")).expect("create dir");
+ fs::write(&file, b"not a real artifact").expect("write file");
}
- assert!(!destination.path().join("bridge.gd").exists());
- assert!(!destination.path().join("bin/win-x64/extra.dll").exists());
- }
-
- #[test]
- fn unsupported_or_disabled_targets_need_no_native_artifacts() {
- let destination = tempfile::tempdir().unwrap();
- for (target, enabled) in [
- ("aarch64-apple-darwin", true),
- ("x86_64-apple-darwin", true),
- ("x86_64-unknown-linux-gnu", true),
- ("aarch64-pc-windows-msvc", true),
- ("x86_64-pc-windows-msvc", false),
- ] {
- stage(
- Path::new("missing-godot-native"),
- destination.path(),
- target,
- enabled,
- )
- .unwrap();
- assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0);
- }
- }
-
- #[test]
- fn incomplete_or_tampered_bundle_fails_before_copying() {
- let source = tempfile::tempdir().unwrap();
- let destination = tempfile::tempdir().unwrap();
- fixture(source.path());
- fs::write(source.path().join(BUNDLE_FILES[0]), b"tampered").unwrap();
- assert!(stage(
- source.path(),
- destination.path(),
- "x86_64-pc-windows-msvc",
- true
- )
- .unwrap_err()
- .contains("SHA256"));
- assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0);
- fixture(source.path());
- fs::remove_file(source.path().join("vendor/LICENSE.txt")).unwrap();
- assert!(validate(source.path()).is_err());
- }
-
- #[test]
- fn source_watch_list_excludes_build_outputs() {
- let source = tempfile::tempdir().unwrap();
- fixture(source.path());
- fs::create_dir(source.path().join(".build")).unwrap();
- fs::write(source.path().join(".build/bridge.obj"), "generated").unwrap();
- fs::write(source.path().join("bridge.gd"), "source").unwrap();
- let sources = source_files(source.path()).unwrap();
- assert_eq!(sources.len(), 3);
- assert!(sources.contains(&source.path().join("bridge.gd")));
- assert!(!sources.iter().any(|path| path
- .components()
- .any(|component| component.as_os_str() == "bin" || component.as_os_str() == ".build")));
+ assert!(
+ validate(temp.path()).is_err(),
+ "内容不合法的随包库必须被拒绝",
+ );
}
}
diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs
new file mode 100644
index 000000000..931290eb7
--- /dev/null
+++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs
@@ -0,0 +1,163 @@
+// @generated by apps/ai-game-creator-shell/scripts/check-package-layout.mjs
+// 来源:build_support/package-layout.json。不要手工编辑本文件。
+// 修改随包资源布局请编辑声明文件,然后运行
+// npm run agc:bundled-resources:sync(在仓库根目录)
+// 门禁会校验两者一致(npm run agc:typecheck 链内含 check-package-layout.mjs)。
+
+pub const DECLARATION_SCHEMA: &str = "agc-package-layout.v1";
+pub const LAYOUT_VERSION: u64 = 2;
+
+pub const CODEX_VERSION: &str = "0.155.1";
+pub const CODEX_CLI_VERSION: &str = "codex-cli 0.155.1";
+pub const CODEX_MANIFEST_SCHEMA: &str = "genarrative-codex-sidecar.v2";
+
+pub const CLAUDE_AGENT_SDK_VERSION: &str = "0.3.285";
+
+pub const GODOT_BUNDLE_FILES: &[&str] = &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"];
+
+pub const CODEX: Codex = Codex {
+ package_metadata: PackageMetadata {
+ layout_version: 1,
+ resources_dir: "codex-resources",
+ path_dir: "codex-path",
+},
+ resource_directory: "resources/codex",
+ manifest_file_name: "manifest.json",
+ package_metadata_file_name: "codex-package.json",
+ notice_file_name: "NOTICE.md",
+ source_roots: &["app", "repo"],
+ source_relative_paths: &["node_modules/@openai/codex-/vendor/", "node_modules/@openai/codex/node_modules/@openai/codex-/vendor/"],
+ notice_sources: &[
+NoticeSource {
+ targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"],
+ source: "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md",
+ preserve: false,
+ },
+NoticeSource {
+ targets: &["x86_64-pc-windows-msvc"],
+ source: "resources/codex/win-x64/NOTICE.md",
+ preserve: true,
+ }
+],
+ universal_groups: &[
+UniversalGroup {
+ name: "mac-native",
+ directory: "mac-native",
+ targets: &["aarch64-apple-darwin", "x86_64-apple-darwin"],
+ }
+],
+ targets: &[
+CodexTarget {
+ target: "x86_64-pc-windows-msvc",
+ platform: "win32-x64",
+ directory: "win-x64",
+ executable: "bin/codex.exe",
+ files: &["bin/codex.exe", "bin/codex-code-mode-host.exe", "codex-path/rg.exe", "codex-resources/codex-command-runner.exe", "codex-resources/codex-windows-sandbox-setup.exe", "codex-package.json"],
+ },
+CodexTarget {
+ target: "aarch64-apple-darwin",
+ platform: "darwin-arm64",
+ directory: "mac-native/darwin-arm64",
+ executable: "bin/codex",
+ files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"],
+ },
+CodexTarget {
+ target: "x86_64-apple-darwin",
+ platform: "darwin-x64",
+ directory: "mac-native/darwin-x64",
+ executable: "bin/codex",
+ files: &["bin/codex", "bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh", "codex-package.json"],
+ }
+],
+};
+
+pub const CLAUDE_AGENT: ClaudeAgent = ClaudeAgent {
+ version: "0.3.285",
+ resource_directory: "resources/claude-agent",
+ entry_relative_path: "agent-sidecar/src/index.mjs",
+ entry_file_name: "index.mjs",
+ node_modules_directory: "node_modules",
+ sdk_package_name: "@anthropic-ai/claude-agent-sdk",
+ sdk_entry_file_name: "sdk.mjs",
+ sdk_package_metadata_file_name: "package.json",
+ targets: &[
+ClaudeAgentTarget {
+ target: "x86_64-pc-windows-msvc",
+ runtime_package: "claude-agent-sdk-win32-x64",
+ runtime_file_name: "claude.exe",
+ },
+ClaudeAgentTarget {
+ target: "aarch64-apple-darwin",
+ runtime_package: "claude-agent-sdk-darwin-arm64",
+ runtime_file_name: "claude",
+ },
+ClaudeAgentTarget {
+ target: "x86_64-apple-darwin",
+ runtime_package: "claude-agent-sdk-darwin-x64",
+ runtime_file_name: "claude",
+ }
+],
+};
+
+pub const PLUGINS: Plugins = Plugins {
+ source_directory: "plugins",
+ destination_directory: "resources/plugins",
+ manifest_file_name: "plugin.json",
+ target_contains_any: &["windows", "apple-darwin"],
+ subdirectories: &[
+Subdirectory {
+ path: "src",
+ plugin: "",
+ origin: "source",
+ target_contains: &[],
+ targets: &[],
+ features: &[],
+ },
+Subdirectory {
+ path: "panels",
+ plugin: "",
+ origin: "source",
+ target_contains: &[],
+ targets: &[],
+ features: &[],
+ },
+Subdirectory {
+ path: "skills",
+ plugin: "",
+ origin: "source",
+ target_contains: &[],
+ targets: &[],
+ features: &[],
+ },
+Subdirectory {
+ path: "native/payload",
+ plugin: "agc-cocos-editor",
+ origin: "prepared",
+ target_contains: &["windows"],
+ targets: &[],
+ features: &["cocos-editor-injection"],
+ },
+Subdirectory {
+ path: "dotnet/publish/win-x64",
+ plugin: "agc-unity-editor",
+ origin: "prepared",
+ target_contains: &[],
+ targets: &["x86_64-pc-windows-msvc"],
+ features: &["unity-editor-execute"],
+ }
+],
+ library_staging: &[
+LibraryStaging {
+ plugin: "agc-godot-editor",
+ source_subdirectory: "native/gdextension",
+ targets: &["x86_64-pc-windows-msvc"],
+ features: &["godot-editor-execute"],
+ layout: "godot-bundle",
+ files: &["bin/win-x64/agc_godot_editor.dll", "bin/win-x64/metadata.json", "vendor/LICENSE.txt", "vendor/provenance.json"],
+ }
+],
+ skip_directory_names: &["target", "node_modules"],
+ skip_directory_name_prefixes: &["."],
+ skip_file_name_prefixes: &["."],
+ skip_file_name_fragments: &[".test."],
+};
diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json
new file mode 100644
index 000000000..1729262db
--- /dev/null
+++ b/apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json
@@ -0,0 +1,234 @@
+{
+ "schema": "agc-package-layout.v1",
+ "description": "AGC 随包资源布局与复制规则的唯一声明。Rust 侧构建期校验与 Node 侧准备步骤共用本文件,任何一侧都不得再写第二份布局或组件白名单。含 、 占位符的字段由调用方按目标三元展开。修改布局时同步递增 layoutVersion(准备步骤的缓存 key 组成部分)。",
+ "layoutVersion": 2,
+ "codex": {
+ "version": "0.155.1",
+ "cliVersionPrefix": "codex-cli ",
+ "manifestSchema": "genarrative-codex-sidecar.v2",
+ "packageMetadata": {
+ "layoutVersion": 1,
+ "resourcesDir": "codex-resources",
+ "pathDir": "codex-path"
+ },
+ "resourceDirectory": "resources/codex",
+ "manifestFileName": "manifest.json",
+ "packageMetadataFileName": "codex-package.json",
+ "noticeFileName": "NOTICE.md",
+ "sourceRoots": ["app", "repo"],
+ "sourceRelativePaths": [
+ "node_modules/@openai/codex-/vendor/",
+ "node_modules/@openai/codex/node_modules/@openai/codex-/vendor/"
+ ],
+ "noticeSources": [
+ {
+ "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"],
+ "source": "resources/codex/【声明】Mac内置Codex组件-2026-09-18.md",
+ "preserve": false
+ },
+ {
+ "targets": ["x86_64-pc-windows-msvc"],
+ "source": "resources/codex/win-x64/NOTICE.md",
+ "preserve": true
+ }
+ ],
+ "universalGroups": [
+ {
+ "name": "mac-native",
+ "directory": "mac-native",
+ "targets": ["aarch64-apple-darwin", "x86_64-apple-darwin"]
+ }
+ ],
+ "targets": [
+ {
+ "target": "x86_64-pc-windows-msvc",
+ "platform": "win32-x64",
+ "directory": "win-x64",
+ "executable": "bin/codex.exe",
+ "files": [
+ "bin/codex.exe",
+ "bin/codex-code-mode-host.exe",
+ "codex-path/rg.exe",
+ "codex-resources/codex-command-runner.exe",
+ "codex-resources/codex-windows-sandbox-setup.exe",
+ "codex-package.json"
+ ]
+ },
+ {
+ "target": "aarch64-apple-darwin",
+ "platform": "darwin-arm64",
+ "directory": "mac-native/darwin-arm64",
+ "executable": "bin/codex",
+ "files": [
+ "bin/codex",
+ "bin/codex-code-mode-host",
+ "codex-path/rg",
+ "codex-resources/zsh/bin/zsh",
+ "codex-package.json"
+ ]
+ },
+ {
+ "target": "x86_64-apple-darwin",
+ "platform": "darwin-x64",
+ "directory": "mac-native/darwin-x64",
+ "executable": "bin/codex",
+ "files": [
+ "bin/codex",
+ "bin/codex-code-mode-host",
+ "codex-path/rg",
+ "codex-resources/zsh/bin/zsh",
+ "codex-package.json"
+ ]
+ }
+ ]
+ },
+ "claudeAgent": {
+ "version": "0.3.285",
+ "resourceDirectory": "resources/claude-agent",
+ "entryRelativePath": "agent-sidecar/src/index.mjs",
+ "entryFileName": "index.mjs",
+ "nodeModulesDirectory": "node_modules",
+ "sdkPackageName": "@anthropic-ai/claude-agent-sdk",
+ "sdkEntryFileName": "sdk.mjs",
+ "sdkPackageMetadataFileName": "package.json",
+ "sourceRoots": ["app", "repo"],
+ "skipDirectoryNames": ["target", "node_modules"],
+ "skipDirectoryNamePrefixes": ["."],
+ "skipFileNamePrefixes": ["."],
+ "skipFileNameFragments": [".test."],
+ "targets": [
+ {
+ "target": "x86_64-pc-windows-msvc",
+ "runtimePackage": "claude-agent-sdk-win32-x64",
+ "runtimeFileName": "claude.exe"
+ },
+ {
+ "target": "aarch64-apple-darwin",
+ "runtimePackage": "claude-agent-sdk-darwin-arm64",
+ "runtimeFileName": "claude"
+ },
+ {
+ "target": "x86_64-apple-darwin",
+ "runtimePackage": "claude-agent-sdk-darwin-x64",
+ "runtimeFileName": "claude"
+ }
+ ]
+ },
+ "plugins": {
+ "sourceDirectory": "plugins",
+ "destinationDirectory": "resources/plugins",
+ "manifestFileName": "plugin.json",
+ "targetContainsAny": ["windows", "apple-darwin"],
+ "subdirectories": [
+ {
+ "path": "src",
+ "origin": "source"
+ },
+ {
+ "path": "panels",
+ "origin": "source"
+ },
+ {
+ "path": "skills",
+ "origin": "source"
+ },
+ {
+ "path": "native/payload",
+ "origin": "prepared",
+ "targetContains": ["windows"],
+ "plugin": "agc-cocos-editor",
+ "prepare": "cocos-bridge-build",
+ "features": ["cocos-editor-injection"]
+ },
+ {
+ "path": "dotnet/publish/win-x64",
+ "origin": "prepared",
+ "prepare": "unity-helper-publish",
+ "targets": ["x86_64-pc-windows-msvc"],
+ "features": ["unity-editor-execute"],
+ "plugin": "agc-unity-editor"
+ }
+ ],
+ "libraryStaging": [
+ {
+ "plugin": "agc-godot-editor",
+ "sourceSubdirectory": "native/gdextension",
+ "prepare": "godot-extension-build",
+ "targets": ["x86_64-pc-windows-msvc"],
+ "features": ["godot-editor-execute"],
+ "layout": "godot-bundle",
+ "files": [
+ "bin/win-x64/agc_godot_editor.dll",
+ "bin/win-x64/metadata.json",
+ "vendor/LICENSE.txt",
+ "vendor/provenance.json"
+ ]
+ }
+ ],
+ "nativePayloads": [
+ {
+ "plugin": "agc-cocos-editor",
+ "prepare": "cocos-bridge-build",
+ "sourceFileName": "cocos_editor_bridge.dll",
+ "destinationSubdirectory": "native/payload",
+ "targets": ["x86_64-pc-windows-msvc"],
+ "features": ["cocos-editor-injection"],
+ "destinationFileName": "cocos-editor-bridge.dll"
+ }
+ ],
+ "prepareSteps": [
+ {
+ "name": "unity-helper-publish",
+ "kind": "powershell",
+ "workingDirectory": "plugins/agc-unity-editor/dotnet",
+ "scriptFileName": "build.ps1",
+ "fingerprint": {
+ "roots": ["."],
+ "excludeDirectoryNames": ["bin", "obj", "publish", "native-build"],
+ "stampRelativePath": "publish/win-x64/.agc-source.sha256"
+ },
+ "requiredOutputs": [
+ "plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe",
+ "plugins/agc-unity-editor/dotnet/publish/win-x64/NOTICE",
+ "plugins/agc-unity-editor/dotnet/publish/win-x64/THIRD-PARTY-NOTICES.txt",
+ "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/DotCraft-Apache-2.0.txt",
+ "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/Roslyn-MIT.txt",
+ "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/upstream.json",
+ "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-LICENSE.TXT",
+ "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/dotnet-THIRD-PARTY-NOTICES.TXT",
+ "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.common-ThirdPartyNotices.rtf",
+ "plugins/agc-unity-editor/dotnet/publish/win-x64/licenses/microsoft.codeanalysis.csharp-ThirdPartyNotices.rtf"
+ ]
+ },
+ {
+ "name": "godot-extension-build",
+ "kind": "powershell",
+ "workingDirectory": "plugins/agc-godot-editor/native/gdextension",
+ "scriptFileName": "build.ps1",
+ "removeEnvironment": ["PSModulePath"],
+ "requiredOutputs": [
+ "plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll",
+ "plugins/agc-godot-editor/native/gdextension/bin/win-x64/metadata.json",
+ "plugins/agc-godot-editor/native/gdextension/vendor/LICENSE.txt",
+ "plugins/agc-godot-editor/native/gdextension/vendor/provenance.json"
+ ],
+ "fingerprint": {
+ "roots": ["."],
+ "excludeDirectoryNames": ["bin", ".build", "native-build"],
+ "stampRelativePath": "bin/win-x64/.agc-source.sha256"
+ }
+ },
+ {
+ "name": "cocos-bridge-build",
+ "kind": "cargo",
+ "packageDirectory": "plugins/agc-cocos-editor/native/cocos-editor-bridge",
+ "features": ["windows-injection"],
+ "requiredOutputs": []
+ }
+ ],
+ "skipDirectoryNames": ["target", "node_modules"],
+ "skipDirectoryNamePrefixes": ["."],
+ "skipFileNamePrefixes": ["."],
+ "skipFileNameFragments": [".test."]
+ }
+}
diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs
new file mode 100644
index 000000000..ddd54ee55
--- /dev/null
+++ b/apps/ai-game-creator-shell/src-tauri/build_support/package_layout.rs
@@ -0,0 +1,1641 @@
+//! 随包资源布局与复制规则的单一声明读取层,以及随包产物的只读校验。
+//!
+//! 人工声明只有一处:`build_support/package-layout.json`。Rust 侧使用由
+//! `scripts/check-package-layout.mjs` 从该声明生成的编译期常量
+//! (`package-layout.generated.rs`,门禁校验两者一致),Node 侧准备步骤直接读声明本身。
+//! 本模块**只读**:既不解析 JSON,也不写任何随包资源。
+
+use std::collections::BTreeSet;
+use std::io::{BufReader, Read};
+use std::path::{Path, PathBuf};
+
+use sha2::{Digest, Sha256};
+
+/// 目标三元 → 平台包与组件白名单。
+#[derive(Debug)]
+pub struct CodexTarget {
+ pub target: &'static str,
+ pub platform: &'static str,
+ pub directory: &'static str,
+ pub executable: &'static str,
+ pub files: &'static [&'static str],
+}
+
+/// 一次构建共用的整目录(例如 macOS 的 `mac-native` 双架构)。
+#[derive(Debug)]
+pub struct UniversalGroup {
+ pub name: &'static str,
+ pub directory: &'static str,
+ pub targets: &'static [&'static str],
+}
+
+/// 第三方声明的来源;`preserve` 表示该文件受版本控制、只允许原地保留。
+#[derive(Debug)]
+pub struct NoticeSource {
+ pub targets: &'static [&'static str],
+ pub source: &'static str,
+ pub preserve: bool,
+}
+
+/// 上游原生包元数据里必须与声明一致的字段。
+#[derive(Debug)]
+pub struct PackageMetadata {
+ pub layout_version: u64,
+ pub resources_dir: &'static str,
+ pub path_dir: &'static str,
+}
+
+/// 插件工作区的随包子目录及其生效条件。
+#[derive(Debug)]
+pub struct Subdirectory {
+ pub path: &'static str,
+ /// 该子目录归属的插件名;空串表示适用于所有插件。
+ pub plugin: &'static str,
+ /// `source`:由声明与仓库源码就能生成(准备步骤负责);`build`:构建期工具链产出(构建脚本负责)。
+ pub origin: &'static str,
+ pub target_contains: &'static [&'static str],
+ pub targets: &'static [&'static str],
+ pub features: &'static [&'static str],
+}
+
+/// 由外部工具链另行产出的随包库(如 Godot gdextension)的归位规则。
+///
+/// `files` 是相对 `source_subdirectory` 的随包文件清单:源码工作区同位目录里还有构建输入与
+/// 测试,只有这些文件会归位到随包目录,校验也以这份清单为准。
+#[derive(Debug)]
+pub struct LibraryStaging {
+ pub plugin: &'static str,
+ pub source_subdirectory: &'static str,
+ pub targets: &'static [&'static str],
+ pub features: &'static [&'static str],
+ pub layout: &'static str,
+ pub files: &'static [&'static str],
+}
+
+#[derive(Debug)]
+pub struct Codex {
+ pub package_metadata: PackageMetadata,
+ pub resource_directory: &'static str,
+ pub manifest_file_name: &'static str,
+ pub package_metadata_file_name: &'static str,
+ pub notice_file_name: &'static str,
+ pub source_roots: &'static [&'static str],
+ pub source_relative_paths: &'static [&'static str],
+ pub notice_sources: &'static [NoticeSource],
+ pub universal_groups: &'static [UniversalGroup],
+ pub targets: &'static [CodexTarget],
+}
+
+#[derive(Debug)]
+pub struct Plugins {
+ pub source_directory: &'static str,
+ pub destination_directory: &'static str,
+ pub manifest_file_name: &'static str,
+ pub target_contains_any: &'static [&'static str],
+ pub subdirectories: &'static [Subdirectory],
+ pub library_staging: &'static [LibraryStaging],
+ pub skip_directory_names: &'static [&'static str],
+ pub skip_directory_name_prefixes: &'static [&'static str],
+ pub skip_file_name_prefixes: &'static [&'static str],
+ pub skip_file_name_fragments: &'static [&'static str],
+}
+
+/// Claude Agent SDK sidecar 在某个目标上的原生运行时包。
+#[derive(Debug)]
+pub struct ClaudeAgentTarget {
+ pub target: &'static str,
+ pub runtime_package: &'static str,
+ pub runtime_file_name: &'static str,
+}
+
+/// Claude Agent SDK sidecar 的随包布局:应用仓库入口 + 两个上游 npm 包。
+#[derive(Debug)]
+pub struct ClaudeAgent {
+ pub version: &'static str,
+ pub resource_directory: &'static str,
+ pub entry_relative_path: &'static str,
+ pub entry_file_name: &'static str,
+ pub node_modules_directory: &'static str,
+ pub sdk_package_name: &'static str,
+ pub sdk_entry_file_name: &'static str,
+ pub sdk_package_metadata_file_name: &'static str,
+ pub targets: &'static [ClaudeAgentTarget],
+}
+
+include!("package-layout.generated.rs");
+
+const PLATFORM_PLACEHOLDER: &str = "";
+const TARGET_PLACEHOLDER: &str = "";
+
+pub fn codex() -> &'static Codex {
+ &CODEX
+}
+
+pub fn plugins() -> &'static Plugins {
+ &PLUGINS
+}
+
+pub fn claude_agent() -> &'static ClaudeAgent {
+ &CLAUDE_AGENT
+}
+
+/// 声明里的 Claude Agent SDK sidecar 目标;未声明的目标返回 `None`(该目标不随包 sidecar)。
+pub fn claude_agent_target(target: &str) -> Option<&'static ClaudeAgentTarget> {
+ CLAUDE_AGENT
+ .targets
+ .iter()
+ .find(|entry| entry.target == target)
+}
+
+/// 声明里的目标布局;未声明的目标返回 `None`。
+pub fn codex_target(target: &str) -> Option<&'static CodexTarget> {
+ CODEX.targets.iter().find(|entry| entry.target == target)
+}
+
+/// 目标所属的整目录分组(macOS 双架构共用 `mac-native`)。
+pub fn codex_universal_group(target: &str) -> Option<&'static UniversalGroup> {
+ CODEX
+ .universal_groups
+ .iter()
+ .find(|group| group.targets.contains(&target))
+}
+
+/// 目标对应的第三方声明来源。
+pub fn codex_notice_source(target: &str) -> Option<&'static NoticeSource> {
+ CODEX
+ .notice_sources
+ .iter()
+ .find(|entry| entry.targets.contains(&target))
+}
+
+/// 单次构建需要校验的平台目录:属于整目录分组时是该组全部三元,否则是自身;不支持的目标为空。
+pub fn staged_targets(target: &str) -> Vec<&'static str> {
+ if let Some(group) = codex_universal_group(target) {
+ return group.targets.to_vec();
+ }
+ codex_target(target)
+ .map(|declared| vec![declared.target])
+ .unwrap_or_default()
+}
+
+/// 上游平台包的候选路径,顺序与声明一致(先 app 目录后仓库根,各自按声明顺序)。
+pub fn codex_source_candidates(
+ app_root: &Path,
+ repo_root: &Path,
+ target: &str,
+) -> Result, String> {
+ let layout =
+ codex_target(target).ok_or_else(|| format!("声明不含目标 {target} 的 Codex 布局"))?;
+ let mut candidates = Vec::new();
+ for root in CODEX.source_roots {
+ let base = match *root {
+ "app" => app_root,
+ "repo" => repo_root,
+ other => return Err(format!("声明中的 sourceRoots 取值无效:{other}")),
+ };
+ for relative in CODEX.source_relative_paths {
+ let expanded = relative
+ .replace(PLATFORM_PLACEHOLDER, layout.platform)
+ .replace(TARGET_PLACEHOLDER, target);
+ candidates.push(base.join(expanded));
+ }
+ }
+ Ok(candidates)
+}
+
+/// 插件随包 staging 是否适用于该目标(与声明里的平台门槛一致)。
+pub fn plugin_staging_applies(target: &str) -> bool {
+ PLUGINS
+ .target_contains_any
+ .iter()
+ .any(|needle| target.contains(needle))
+}
+
+/// 子目录是否归属该插件(空串表示通用)。
+pub fn subdirectory_applies_to_plugin(subdirectory: &Subdirectory, plugin_name: &str) -> bool {
+ subdirectory.plugin.is_empty() || subdirectory.plugin == plugin_name
+}
+
+/// 子目录在当前目标与已启用 feature 下是否随包。
+pub fn subdirectory_enabled(
+ subdirectory: &Subdirectory,
+ target: &str,
+ feature_enabled: impl Fn(&str) -> bool,
+) -> bool {
+ (subdirectory.target_contains.is_empty()
+ || subdirectory
+ .target_contains
+ .iter()
+ .any(|needle| target.contains(needle)))
+ && (subdirectory.targets.is_empty() || subdirectory.targets.contains(&target))
+ && subdirectory
+ .features
+ .iter()
+ .all(|name| feature_enabled(name))
+}
+
+/// 随包库归位在当前目标与已启用 feature 下是否生效。
+pub fn library_staging_enabled(
+ staging: &LibraryStaging,
+ target: &str,
+ feature_enabled: impl Fn(&str) -> bool,
+) -> bool {
+ (staging.targets.is_empty() || staging.targets.contains(&target))
+ && staging.features.iter().all(|name| feature_enabled(name))
+}
+
+/// Cargo build script 上下文里该 feature 是否启用(`CARGO_FEATURE_`)。
+pub fn cargo_feature_enabled(name: &str) -> bool {
+ let variable = format!("CARGO_FEATURE_{}", name.to_uppercase().replace('-', "_"));
+ std::env::var_os(variable).is_some()
+}
+
+/// 目录是否被跳过(构建产物目录与隐藏目录)。
+pub fn skip_directory(name: &str) -> bool {
+ PLUGINS.skip_directory_names.contains(&name)
+ || PLUGINS
+ .skip_directory_name_prefixes
+ .iter()
+ .any(|prefix| name.starts_with(prefix))
+}
+
+/// 文件是否被跳过(测试文件与隐藏文件)。
+pub fn skip_file_name(name: &str) -> bool {
+ PLUGINS
+ .skip_file_name_prefixes
+ .iter()
+ .any(|prefix| name.starts_with(prefix))
+ || PLUGINS
+ .skip_file_name_fragments
+ .iter()
+ .any(|fragment| name.contains(fragment))
+}
+
+/// 逐块 sha256;构建期校验与 staging 比对共用同一实现。
+pub fn sha256_file(path: &Path) -> Result {
+ let file = std::fs::File::open(path)?;
+ let mut reader = BufReader::new(file);
+ let mut hasher = Sha256::new();
+ let mut buffer = [0_u8; 64 * 1024];
+ loop {
+ let read = reader.read(&mut buffer)?;
+ if read == 0 {
+ break;
+ }
+ hasher.update(&buffer[..read]);
+ }
+ Ok(format!("{:x}", hasher.finalize()))
+}
+
+/// 声明里的相对路径一律使用 `/`,落盘时转换为平台分隔符。
+pub fn declared_relative_path(relative: &str) -> PathBuf {
+ relative.split('/').collect()
+}
+
+/// 只读校验插件随包工作区。
+///
+/// 校验分两层:先按当前目标与已启用 feature 构造允许文件集合(插件清单、生效的随包子目录、
+/// 生效的随包库),再要求随包目录由这些文件恰好组成——清单与源码派生的子目录逐文件 sha256
+/// 一致,构建期派生的子目录与源码工作区同位目录逐文件对齐(内容由产出它的构建步骤负责),
+/// 随包库的声明文件齐备;集合之外的任何文件一律拒绝(未声明的根条目、源码子目录里的残留文件、
+/// 未启用 feature 的产物)。整树不得出现符号链接,本函数不做任何写入。
+pub fn validate_staged_plugins(
+ source_root: &Path,
+ destination_root: &Path,
+ target: &str,
+ feature_enabled: impl Fn(&str) -> bool,
+) -> Result<(), String> {
+ if !plugin_staging_applies(target) {
+ return Ok(());
+ }
+ let metadata = std::fs::symlink_metadata(destination_root).map_err(|error| {
+ format!(
+ "插件随包资源目录不可读 {}:{error}",
+ destination_root.display()
+ )
+ })?;
+ if metadata.file_type().is_symlink() || !metadata.is_dir() {
+ return Err(format!(
+ "插件随包资源目录缺失或不允许符号链接:{}",
+ destination_root.display()
+ ));
+ }
+
+ let declared_plugins = plugin_directories(source_root, plugins().manifest_file_name)?;
+ let declared_names = declared_plugins
+ .iter()
+ .map(|plugin| plugin.name.as_str())
+ .collect::>();
+ for entry in std::fs::read_dir(destination_root).map_err(|error| {
+ format!(
+ "插件随包资源目录不可读 {}:{error}",
+ destination_root.display()
+ )
+ })? {
+ let entry = entry.map_err(|error| format!("插件随包目录项不可读:{error}"))?;
+ let file_type = entry
+ .file_type()
+ .map_err(|error| format!("插件随包目录项类型不可读:{error}"))?;
+ if file_type.is_symlink() {
+ return Err(format!(
+ "插件随包目录不允许符号链接:{}",
+ entry.path().display()
+ ));
+ }
+ let name = entry.file_name().to_string_lossy().to_string();
+ if !declared_names.contains(name.as_str()) {
+ return Err(format!(
+ "插件随包目录存在未声明条目:{}",
+ entry.path().display()
+ ));
+ }
+ }
+ for plugin in &declared_plugins {
+ let staged = destination_root.join(&plugin.name);
+ let source_manifest = plugin.path.join(plugins().manifest_file_name);
+ let staged_manifest = staged.join(plugins().manifest_file_name);
+ if !staged_manifest.is_file() {
+ return Err(format!("随包插件缺少清单:{}", staged_manifest.display()));
+ }
+ let source_manifest_digest = sha256_file(&source_manifest)
+ .map_err(|error| format!("读取插件清单失败 {}:{error}", source_manifest.display()))?;
+ let staged_manifest_digest = sha256_file(&staged_manifest).map_err(|error| {
+ format!(
+ "读取随包插件清单失败 {}:{error}",
+ staged_manifest.display()
+ )
+ })?;
+ if source_manifest_digest != staged_manifest_digest {
+ return Err(format!(
+ "随包插件清单与源码不一致:{}",
+ staged_manifest.display()
+ ));
+ }
+ let mut allowed = BTreeSet::new();
+ allowed.insert(plugins().manifest_file_name.to_string());
+ for subdirectory in plugins().subdirectories {
+ if !subdirectory_applies_to_plugin(subdirectory, &plugin.name)
+ || !subdirectory_enabled(subdirectory, target, &feature_enabled)
+ {
+ continue;
+ }
+ let relative = declared_relative_path(subdirectory.path);
+ let source_directory = plugin.path.join(&relative);
+ let staged_directory = staged.join(&relative);
+ for file in source_subdirectory_files(&source_directory)? {
+ allowed.insert(format!("{}/{}", subdirectory.path, file));
+ let staged_file = staged_directory.join(declared_relative_path(&file));
+ if !staged_file.is_file() {
+ return Err(if subdirectory_is_prepared(subdirectory) {
+ format!(
+ "随包已准备产物缺少文件:{}(请先执行随包资源准备步骤)",
+ staged_file.display()
+ )
+ } else {
+ format!("随包插件缺少文件:{}", staged_file.display())
+ });
+ }
+ if !subdirectory_is_source_derived(subdirectory) {
+ continue;
+ }
+ let source_file = source_directory.join(declared_relative_path(&file));
+ let source_digest = sha256_file(&source_file).map_err(|error| {
+ format!("读取插件文件失败 {}:{error}", source_file.display())
+ })?;
+ let staged_digest = sha256_file(&staged_file).map_err(|error| {
+ format!("读取随包插件文件失败 {}:{error}", staged_file.display())
+ })?;
+ if source_digest != staged_digest {
+ return Err(format!(
+ "随包插件文件与源码不一致:{}",
+ staged_file.display()
+ ));
+ }
+ }
+ }
+ for staging in plugins().library_staging {
+ if staging.plugin != plugin.name.as_str()
+ || !library_staging_enabled(staging, target, &feature_enabled)
+ {
+ continue;
+ }
+ for file in staging.files {
+ let relative = format!("{}/{}", staging.source_subdirectory, file);
+ let staged_file = staged.join(declared_relative_path(&relative));
+ if !staged_file.is_file() {
+ return Err(format!(
+ "随包库缺少声明文件:{}(请先执行随包资源准备步骤)",
+ staged_file.display()
+ ));
+ }
+ allowed.insert(relative);
+ }
+ }
+ for relative in collect_tree_files(&staged)? {
+ if !allowed.contains(&relative) {
+ return Err(format!(
+ "随包插件存在未声明文件:{}(目标 {target} 与当前 feature 组合不允许;请先执行随包资源准备步骤)",
+ staged.join(declared_relative_path(&relative)).display()
+ ));
+ }
+ }
+ }
+ collect_tree_files(destination_root)?;
+ Ok(())
+}
+
+/// 源码工作区同位子目录里的文件集合;该目录不存在时为空集。
+fn source_subdirectory_files(source_directory: &Path) -> Result, String> {
+ if !source_directory.is_dir() {
+ return Ok(BTreeSet::new());
+ }
+ collect_sources(source_directory)
+}
+
+/// 声明为「由准备步骤按源码派生」的子目录。
+pub fn subdirectory_is_source_derived(subdirectory: &Subdirectory) -> bool {
+ subdirectory.origin == "source"
+}
+
+/// 声明为「需要先由准备步骤运行构建命令产出」的子目录。
+pub fn subdirectory_is_prepared(subdirectory: &Subdirectory) -> bool {
+ subdirectory.origin == "prepared"
+}
+
+/// 仓库插件目录:含清单文件的普通目录,按名字排序。
+pub fn plugin_directories(
+ source_root: &Path,
+ manifest_file_name: &str,
+) -> Result, String> {
+ let mut plugins = Vec::new();
+ let entries = std::fs::read_dir(source_root)
+ .map_err(|error| format!("插件工作区不可读 {}:{error}", source_root.display()))?;
+ for entry in entries {
+ let entry = entry.map_err(|error| format!("插件目录项不可读:{error}"))?;
+ let name = entry.file_name().to_string_lossy().to_string();
+ let path = entry.path();
+ let file_type = entry
+ .file_type()
+ .map_err(|error| format!("插件目录项类型不可读:{error}"))?;
+ if file_type.is_symlink() {
+ continue;
+ }
+ if file_type.is_dir() && path.join(manifest_file_name).is_file() {
+ plugins.push(PluginDirectory { name, path });
+ }
+ }
+ plugins.sort_by(|left, right| left.name.cmp(&right.name));
+ Ok(plugins)
+}
+
+pub struct PluginDirectory {
+ pub name: String,
+ pub path: PathBuf,
+}
+
+/// 按声明的跳过规则收集目录下的文件(相对路径,`/` 分隔);遇到符号链接即失败。
+pub fn collect_sources(root: &Path) -> Result, String> {
+ let mut files = BTreeSet::new();
+ let mut stack = vec![(root.to_path_buf(), String::new())];
+ while let Some((directory, prefix)) = stack.pop() {
+ let entries = std::fs::read_dir(&directory)
+ .map_err(|error| format!("随包资源源码不可读 {}:{error}", directory.display()))?;
+ for entry in entries {
+ let entry = entry.map_err(|error| format!("随包资源目录项不可读:{error}"))?;
+ let file_type = entry
+ .file_type()
+ .map_err(|error| format!("随包资源目录项类型不可读:{error}"))?;
+ let name = entry.file_name().to_string_lossy().to_string();
+ let relative = if prefix.is_empty() {
+ name.clone()
+ } else {
+ format!("{prefix}/{name}")
+ };
+ if file_type.is_symlink() {
+ return Err(format!("随包资源不允许符号链接:{relative}"));
+ }
+ if file_type.is_dir() {
+ if !skip_directory(&name) {
+ stack.push((entry.path(), relative));
+ }
+ } else if !skip_file_name(&name) {
+ files.insert(relative);
+ }
+ }
+ }
+ Ok(files)
+}
+
+/// 只读校验一份已经落盘的 Codex 随包目录(本函数不写任何文件)。
+///
+/// 校验项:目录存在且非链接、清单存在且 schema/平台/版本一致、清单文件集合与组件白名单完全一致、
+/// 每个组件存在且摘要一致、第三方声明存在、可执行组件具备可执行位、目录内不存在白名单外的文件。
+pub fn validate_staged_codex_bundle(target_dir: &Path, target: &str) -> Result<(), String> {
+ let layout =
+ codex_target(target).ok_or_else(|| format!("声明不含目标 {target} 的 Codex 布局"))?;
+ let metadata = std::fs::symlink_metadata(target_dir)
+ .map_err(|error| format!("随包目录不可读 {}:{error}", target_dir.display()))?;
+ if !metadata.is_dir() {
+ return Err(format!("随包目录不是目录:{}", target_dir.display()));
+ }
+ if metadata.file_type().is_symlink() {
+ return Err(format!("随包目录不允许符号链接:{}", target_dir.display()));
+ }
+
+ let manifest_path = target_dir.join(CODEX.manifest_file_name);
+ let manifest_raw = std::fs::read_to_string(&manifest_path)
+ .map_err(|error| format!("随包清单不可读 {}:{error}", manifest_path.display()))?;
+ let manifest: serde_json::Value = serde_json::from_str(&manifest_raw)
+ .map_err(|error| format!("随包清单不是合法 JSON {}:{error}", manifest_path.display()))?;
+ let schema = manifest["schemaVersion"].as_str().unwrap_or_default();
+ if schema != CODEX_MANIFEST_SCHEMA {
+ return Err(format!(
+ "随包清单 schema 不受支持:{schema}(期望 {CODEX_MANIFEST_SCHEMA})"
+ ));
+ }
+ let platform = manifest["platform"].as_str().unwrap_or_default();
+ if platform != layout.platform {
+ return Err(format!(
+ "随包清单平台与目标不一致:{platform}(目标 {target} 期望 {})",
+ layout.platform
+ ));
+ }
+ let version = manifest["version"].as_str().unwrap_or_default();
+ if version != CODEX_CLI_VERSION {
+ return Err(format!(
+ "随包清单版本与声明不一致:{version}(期望 {CODEX_CLI_VERSION})"
+ ));
+ }
+ let hashes = manifest["files"]
+ .as_object()
+ .ok_or_else(|| format!("随包清单缺少 files 映射:{}", manifest_path.display()))?;
+ let declared = layout
+ .files
+ .iter()
+ .map(|relative| (*relative).to_string())
+ .collect::>();
+ let listed = hashes.keys().cloned().collect::>();
+ if declared != listed {
+ let missing = declared.difference(&listed).cloned().collect::>();
+ let unexpected = listed.difference(&declared).cloned().collect::>();
+ return Err(format!(
+ "随包清单文件集合与组件白名单不一致(缺少 {missing:?},多出 {unexpected:?})"
+ ));
+ }
+
+ for relative in layout.files {
+ let path = target_dir.join(declared_relative_path(relative));
+ let file_metadata = std::fs::symlink_metadata(&path)
+ .map_err(|error| format!("随包组件缺失 {relative}:{error}"))?;
+ if file_metadata.file_type().is_symlink() {
+ return Err(format!("随包组件不允许符号链接:{relative}"));
+ }
+ if !file_metadata.is_file() {
+ return Err(format!("随包组件不是普通文件:{relative}"));
+ }
+ if file_metadata.len() == 0 {
+ return Err(format!("随包组件为空:{relative}"));
+ }
+ let expected = hashes
+ .get(*relative)
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or_default();
+ if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+ return Err(format!("随包清单摘要格式无效:{relative} = {expected}"));
+ }
+ let actual =
+ sha256_file(&path).map_err(|error| format!("读取随包组件失败 {relative}:{error}"))?;
+ if !actual.eq_ignore_ascii_case(expected) {
+ return Err(format!(
+ "随包组件摘要与清单不一致:{relative}(清单 {expected},实际 {actual})"
+ ));
+ }
+ #[cfg(unix)]
+ if *relative == layout.executable {
+ use std::os::unix::fs::PermissionsExt;
+ if file_metadata.permissions().mode() & 0o111 == 0 {
+ return Err(format!("随包可执行组件缺少可执行位:{relative}"));
+ }
+ }
+ }
+
+ let notice = target_dir.join(CODEX.notice_file_name);
+ if !notice.is_file() {
+ return Err(format!("随包第三方声明缺失:{}", notice.display()));
+ }
+
+ let mut allowed = declared.clone();
+ allowed.insert(CODEX.notice_file_name.to_string());
+ allowed.insert(CODEX.manifest_file_name.to_string());
+ let actual = collect_tree_files(target_dir)?;
+ let unexpected = actual.difference(&allowed).cloned().collect::>();
+ if !unexpected.is_empty() {
+ return Err(format!(
+ "随包目录存在白名单外的文件:{unexpected:?}({})",
+ target_dir.display()
+ ));
+ }
+ Ok(())
+}
+
+/// 只读校验 Claude Agent SDK sidecar 的随包产物(由准备步骤写入)。
+///
+/// 上游 npm 包由应用依赖与 lockfile 锁定,准备步骤整目录替换,因此构建期校验的是:
+/// 入口与仓库源码逐字节一致、SDK 版本与声明一致、平台原生运行时在位、目录里没有白名单外的内容。
+pub fn validate_staged_claude_agent(
+ target_dir: &Path,
+ app_root: &Path,
+ target: &str,
+) -> Result<(), String> {
+ let declared = claude_agent_target(target)
+ .ok_or_else(|| format!("声明不含目标 {target} 的 Claude Agent SDK sidecar 布局"))?;
+ let metadata = std::fs::symlink_metadata(target_dir)
+ .map_err(|error| format!("随包目录不可读 {}:{error}", target_dir.display()))?;
+ if !metadata.is_dir() {
+ return Err(format!("随包目录不是目录:{}", target_dir.display()));
+ }
+ if metadata.file_type().is_symlink() {
+ return Err(format!("随包目录不允许符号链接:{}", target_dir.display()));
+ }
+
+ let entry = target_dir.join(CLAUDE_AGENT.entry_file_name);
+ let entry_source = app_root.join(declared_relative_path(CLAUDE_AGENT.entry_relative_path));
+ let staged_entry = std::fs::read(&entry).map_err(|error| {
+ format!(
+ "Claude Agent SDK sidecar 入口不可读 {}:{error}",
+ entry.display()
+ )
+ })?;
+ if staged_entry.is_empty() {
+ return Err(format!(
+ "Claude Agent SDK sidecar 入口为空:{}",
+ entry.display()
+ ));
+ }
+ let repository_entry = std::fs::read(&entry_source).map_err(|error| {
+ format!(
+ "Claude Agent SDK sidecar 源码入口不可读 {}:{error}",
+ entry_source.display()
+ )
+ })?;
+ if staged_entry != repository_entry {
+ return Err(format!(
+ "Claude Agent SDK sidecar 入口与仓库源码不一致:{}(源码 {})",
+ entry.display(),
+ entry_source.display()
+ ));
+ }
+
+ let scope = package_scope(CLAUDE_AGENT.sdk_package_name);
+ let sdk_package = claude_agent_node_modules(target_dir)
+ .join(declared_relative_path(CLAUDE_AGENT.sdk_package_name));
+ validate_upstream_package_version(
+ &sdk_package,
+ CLAUDE_AGENT.sdk_package_metadata_file_name,
+ CLAUDE_AGENT.version,
+ "Claude Agent SDK",
+ )?;
+ let sdk_entry = sdk_package.join(CLAUDE_AGENT.sdk_entry_file_name);
+ if !is_staged_file(&sdk_entry) {
+ return Err(format!(
+ "Claude Agent SDK sidecar 缺少 SDK 入口:{}",
+ sdk_entry.display()
+ ));
+ }
+
+ let runtime_package = claude_agent_node_modules(target_dir)
+ .join(declared_relative_path(scope))
+ .join(declared_relative_path(declared.runtime_package));
+ validate_upstream_package_version(
+ &runtime_package,
+ CLAUDE_AGENT.sdk_package_metadata_file_name,
+ CLAUDE_AGENT.version,
+ "Claude Agent SDK 原生运行时",
+ )?;
+ let runtime_file = runtime_package.join(declared_relative_path(declared.runtime_file_name));
+ if !is_staged_file(&runtime_file) {
+ return Err(format!(
+ "Claude Agent SDK native runtime 缺失:{}",
+ runtime_file.display()
+ ));
+ }
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ if std::fs::metadata(&runtime_file)
+ .map(|metadata| metadata.permissions().mode() & 0o111 == 0)
+ .unwrap_or(true)
+ {
+ return Err(format!(
+ "Claude Agent SDK native runtime 缺少可执行位:{}",
+ runtime_file.display()
+ ));
+ }
+ }
+
+ let allowed_prefixes = [
+ format!(
+ "{}/{}",
+ CLAUDE_AGENT.node_modules_directory, CLAUDE_AGENT.sdk_package_name
+ ),
+ format!(
+ "{}/{}/{}",
+ CLAUDE_AGENT.node_modules_directory, scope, declared.runtime_package
+ ),
+ ];
+ for relative in collect_tree_files(target_dir)? {
+ if relative == CLAUDE_AGENT.entry_file_name
+ || allowed_prefixes
+ .iter()
+ .any(|prefix| relative.starts_with(&format!("{prefix}/")))
+ {
+ continue;
+ }
+ return Err(format!(
+ "Claude Agent SDK sidecar 随包目录存在白名单外的文件:{relative}({})",
+ target_dir.display()
+ ));
+ }
+ Ok(())
+}
+
+/// 随包 sidecar 的 `node_modules` 目录。
+fn claude_agent_node_modules(target_dir: &Path) -> PathBuf {
+ target_dir.join(declared_relative_path(CLAUDE_AGENT.node_modules_directory))
+}
+
+/// `@scope/pkg` 的 scope 段;声明门禁保证 SDK 包名带 scope。
+fn package_scope(package_name: &str) -> &str {
+ package_name.split('/').next().unwrap_or(package_name)
+}
+
+/// 普通文件(非符号链接)且非空。
+fn is_staged_file(path: &Path) -> bool {
+ std::fs::symlink_metadata(path).is_ok_and(|metadata| {
+ metadata.is_file() && !metadata.file_type().is_symlink() && metadata.len() > 0
+ })
+}
+
+/// 上游包元数据里的版本必须与声明一致:上游换版本而声明未同步时,构建期立即失败。
+fn validate_upstream_package_version(
+ package_dir: &Path,
+ metadata_file_name: &str,
+ expected: &str,
+ label: &str,
+) -> Result<(), String> {
+ let metadata_path = package_dir.join(metadata_file_name);
+ let raw = std::fs::read_to_string(&metadata_path)
+ .map_err(|error| format!("{label} 元数据不可读 {}:{error}", metadata_path.display()))?;
+ let metadata: serde_json::Value = serde_json::from_str(&raw).map_err(|error| {
+ format!(
+ "{label} 元数据不是合法 JSON {}:{error}",
+ metadata_path.display()
+ )
+ })?;
+ let version = metadata["version"].as_str().unwrap_or_default();
+ if version != expected {
+ return Err(format!(
+ "{label} 版本与声明不一致:{version}(期望 {expected},{});请同步更新 build_support/package-layout.json",
+ metadata_path.display()
+ ));
+ }
+ Ok(())
+}
+
+/// 递归收集目录下的普通文件(相对路径,`/` 分隔);遇到符号链接即失败。
+pub fn collect_tree_files(root: &Path) -> Result, String> {
+ let mut files = BTreeSet::new();
+ let mut stack = vec![(root.to_path_buf(), String::new())];
+ while let Some((directory, prefix)) = stack.pop() {
+ let entries = std::fs::read_dir(&directory)
+ .map_err(|error| format!("随包目录不可读 {}:{error}", directory.display()))?;
+ for entry in entries {
+ let entry = entry.map_err(|error| format!("随包目录项不可读:{error}"))?;
+ let file_type = entry
+ .file_type()
+ .map_err(|error| format!("随包目录项类型不可读:{error}"))?;
+ let name = entry.file_name().to_string_lossy().to_string();
+ let relative = if prefix.is_empty() {
+ name.clone()
+ } else {
+ format!("{prefix}/{name}")
+ };
+ if file_type.is_symlink() {
+ return Err(format!("随包资源不允许符号链接:{relative}"));
+ }
+ if file_type.is_dir() {
+ stack.push((entry.path(), relative));
+ } else {
+ files.insert(relative);
+ }
+ }
+ }
+ Ok(files)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+
+ fn declared_target() -> &'static CodexTarget {
+ codex_target("aarch64-apple-darwin").expect("mac layout")
+ }
+
+ /// 在临时目录里生成一份合规的 Codex 随包目录。
+ fn write_bundle(root: &Path, target: &str) -> serde_json::Value {
+ let layout = codex_target(target).expect("layout");
+ let mut hashes = serde_json::Map::new();
+ for relative in layout.files {
+ let path = root.join(declared_relative_path(relative));
+ fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
+ fs::write(&path, format!("component {relative}")).expect("write component");
+ #[cfg(unix)]
+ if *relative == layout.executable {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
+ }
+ hashes.insert(
+ relative.to_string(),
+ serde_json::Value::String(sha256_file(&path).expect("hash")),
+ );
+ }
+ fs::write(root.join(CODEX.notice_file_name), "notice").expect("write notice");
+ let manifest = serde_json::json!({
+ "schemaVersion": CODEX_MANIFEST_SCHEMA,
+ "platform": layout.platform,
+ "version": CODEX_CLI_VERSION,
+ "files": hashes,
+ });
+ fs::write(
+ root.join(CODEX.manifest_file_name),
+ serde_json::to_string_pretty(&manifest).expect("serialize"),
+ )
+ .expect("write manifest");
+ manifest
+ }
+
+ #[test]
+ fn valid_bundle_passes() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ write_bundle(temp.path(), "aarch64-apple-darwin");
+ validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin").expect("valid bundle");
+ }
+
+ #[test]
+ fn rejects_manifest_schema_platform_and_version_drift() {
+ for (key, value) in [
+ ("schemaVersion", serde_json::json!("other-sidecar.v9")),
+ ("platform", serde_json::json!("darwin-x64")),
+ ("version", serde_json::json!("codex-cli 0.0.0")),
+ ] {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let mut manifest = write_bundle(temp.path(), "aarch64-apple-darwin");
+ manifest[key] = value;
+ fs::write(
+ temp.path().join(CODEX.manifest_file_name),
+ serde_json::to_string_pretty(&manifest).expect("serialize"),
+ )
+ .expect("write manifest");
+ let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin")
+ .expect_err("must reject");
+ assert!(error.contains("清单"), "{key}: {error}");
+ }
+ }
+
+ #[test]
+ fn rejects_missing_component_and_digest_drift() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ write_bundle(temp.path(), "aarch64-apple-darwin");
+ let executable = declared_target().executable;
+ fs::remove_file(temp.path().join(declared_relative_path(executable))).expect("remove");
+ let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin")
+ .expect_err("must reject missing component");
+ assert!(error.contains(executable), "{error}");
+
+ let temp = tempfile::tempdir().expect("tempdir");
+ write_bundle(temp.path(), "aarch64-apple-darwin");
+ let path = temp.path().join("codex-package.json");
+ fs::write(&path, "tampered").expect("tamper");
+ let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin")
+ .expect_err("must reject digest drift");
+ assert!(error.contains("摘要"), "{error}");
+ }
+
+ #[test]
+ fn rejects_undeclared_file_and_missing_notice() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ write_bundle(temp.path(), "aarch64-apple-darwin");
+ fs::write(temp.path().join("stray.bin"), "stray").expect("write stray");
+ let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin")
+ .expect_err("must reject stray file");
+ assert!(error.contains("白名单外"), "{error}");
+
+ let temp = tempfile::tempdir().expect("tempdir");
+ write_bundle(temp.path(), "aarch64-apple-darwin");
+ fs::remove_file(temp.path().join(CODEX.notice_file_name)).expect("remove notice");
+ let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin")
+ .expect_err("must reject missing notice");
+ assert!(error.contains("第三方声明"), "{error}");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn rejects_symlinked_component_and_missing_executable_bit() {
+ use std::os::unix::fs::{symlink, PermissionsExt};
+
+ let temp = tempfile::tempdir().expect("tempdir");
+ write_bundle(temp.path(), "aarch64-apple-darwin");
+ let executable = temp.path().join("bin/codex");
+ fs::remove_file(&executable).expect("remove");
+ symlink("codex-code-mode-host", &executable).expect("symlink");
+ let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin")
+ .expect_err("must reject symlink");
+ assert!(error.contains("符号链接"), "{error}");
+
+ let temp = tempfile::tempdir().expect("tempdir");
+ write_bundle(temp.path(), "aarch64-apple-darwin");
+ let executable = temp.path().join("bin/codex");
+ fs::set_permissions(&executable, fs::Permissions::from_mode(0o644)).expect("chmod");
+ let error = validate_staged_codex_bundle(temp.path(), "aarch64-apple-darwin")
+ .expect_err("must reject missing executable bit");
+ assert!(error.contains("可执行位"), "{error}");
+ }
+
+ #[test]
+ fn unsupported_target_is_rejected() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let error = validate_staged_codex_bundle(temp.path(), "x86_64-unknown-linux-gnu")
+ .expect_err("must reject unsupported target");
+ assert!(error.contains("声明不含目标"), "{error}");
+ }
+
+ #[test]
+ fn declaration_pins_codex_layout() {
+ assert_eq!(DECLARATION_SCHEMA, "agc-package-layout.v1");
+ assert_eq!(LAYOUT_VERSION, 2);
+ assert_eq!(CODEX_VERSION, "0.155.1");
+ assert_eq!(CODEX_CLI_VERSION, "codex-cli 0.155.1");
+ assert_eq!(CODEX.targets.len(), 3);
+ let windows = codex_target("x86_64-pc-windows-msvc").expect("windows layout");
+ assert_eq!(windows.platform, "win32-x64");
+ assert_eq!(windows.directory, "win-x64");
+ assert_eq!(windows.executable, "bin/codex.exe");
+ assert_eq!(windows.files.len(), 6);
+ assert_eq!(declared_target().files.len(), 5);
+ assert!(declared_target()
+ .files
+ .contains(&"codex-resources/zsh/bin/zsh"));
+ assert!(codex_target("universal-apple-darwin").is_none());
+ assert!(codex_target("x86_64-unknown-linux-gnu").is_none());
+ }
+
+ #[test]
+ fn universal_group_covers_both_darwin_targets() {
+ let group = codex_universal_group("aarch64-apple-darwin").expect("darwin group");
+ assert_eq!(group.directory, "mac-native");
+ assert_eq!(
+ group.targets,
+ ["aarch64-apple-darwin", "x86_64-apple-darwin"]
+ );
+ assert_eq!(staged_targets("aarch64-apple-darwin").len(), 2);
+ assert_eq!(
+ staged_targets("x86_64-pc-windows-msvc"),
+ ["x86_64-pc-windows-msvc"]
+ );
+ assert!(staged_targets("x86_64-unknown-linux-gnu").is_empty());
+ }
+
+ #[test]
+ fn notice_sources_cover_every_declared_target() {
+ for target in ["x86_64-pc-windows-msvc", "aarch64-apple-darwin"] {
+ assert!(
+ codex_notice_source(target).is_some(),
+ "缺少声明来源:{target}"
+ );
+ }
+ assert!(
+ codex_notice_source("x86_64-pc-windows-msvc")
+ .expect("windows notice")
+ .preserve
+ );
+ assert!(
+ !codex_notice_source("x86_64-apple-darwin")
+ .expect("mac notice")
+ .preserve
+ );
+ }
+
+ #[test]
+ fn source_candidates_follow_declared_order() {
+ let rendered = codex_source_candidates(
+ Path::new("/app"),
+ Path::new("/repo"),
+ "aarch64-apple-darwin",
+ )
+ .expect("candidates")
+ .iter()
+ .map(|path| path.to_string_lossy().to_string())
+ .collect::>();
+ // 期望值与实现同形:实现是「根目录 join 整条声明的相对路径」,路径内部的 `/` 不会被
+ // Windows 改写;按组件逐段 join 会在 Windows 上产出纯反斜杠,让该用例必红。
+ let expected = |root: &str, nested: bool| {
+ let relative = if nested {
+ "node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin"
+ } else {
+ "node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin"
+ };
+ Path::new(root).join(relative).to_string_lossy().to_string()
+ };
+ assert_eq!(
+ rendered,
+ [
+ expected("/app", false),
+ expected("/app", true),
+ expected("/repo", false),
+ expected("/repo", true),
+ ]
+ );
+ assert!(codex_source_candidates(
+ Path::new("/app"),
+ Path::new("/repo"),
+ "x86_64-unknown-linux-gnu"
+ )
+ .is_err());
+ }
+
+ #[test]
+ fn plugin_rules_match_declared_whitelist() {
+ assert_eq!(PLUGINS.subdirectories.len(), 5);
+ assert_eq!(PLUGINS.subdirectories[0].path, "src");
+ let payload = PLUGINS
+ .subdirectories
+ .iter()
+ .find(|entry| entry.path == "native/payload")
+ .expect("native/payload");
+ assert!(subdirectory_enabled(
+ payload,
+ "x86_64-pc-windows-msvc",
+ |_| true
+ ));
+ assert!(!subdirectory_enabled(
+ payload,
+ "aarch64-apple-darwin",
+ |_| true
+ ));
+ let unity = PLUGINS
+ .subdirectories
+ .iter()
+ .find(|entry| entry.path == "dotnet/publish/win-x64")
+ .expect("unity publish");
+ assert!(subdirectory_enabled(
+ unity,
+ "x86_64-pc-windows-msvc",
+ |name| name == "unity-editor-execute"
+ ));
+ assert!(!subdirectory_enabled(
+ unity,
+ "x86_64-pc-windows-msvc",
+ |_| false
+ ));
+ assert!(!subdirectory_enabled(unity, "aarch64-apple-darwin", |_| {
+ true
+ }));
+ assert!(plugin_staging_applies("x86_64-pc-windows-msvc"));
+ assert!(plugin_staging_applies("aarch64-apple-darwin"));
+ assert!(!plugin_staging_applies("x86_64-unknown-linux-gnu"));
+ assert_eq!(PLUGINS.source_directory, "plugins");
+ assert_eq!(PLUGINS.destination_directory, "resources/plugins");
+ assert_eq!(PLUGINS.manifest_file_name, "plugin.json");
+ }
+
+ #[test]
+ fn library_staging_rules_are_declared() {
+ let staging = PLUGINS
+ .library_staging
+ .iter()
+ .find(|entry| entry.layout == "godot-bundle")
+ .expect("godot staging");
+ assert_eq!(staging.plugin, "agc-godot-editor");
+ assert_eq!(staging.source_subdirectory, "native/gdextension");
+ assert!(library_staging_enabled(
+ staging,
+ "x86_64-pc-windows-msvc",
+ |name| name == "godot-editor-execute"
+ ));
+ assert!(!library_staging_enabled(
+ staging,
+ "x86_64-pc-windows-msvc",
+ |_| false
+ ));
+ assert!(!library_staging_enabled(
+ staging,
+ "aarch64-apple-darwin",
+ |_| true
+ ));
+ }
+
+ #[test]
+ fn skip_rules_match_copy_semantics() {
+ assert!(skip_directory("target"));
+ assert!(skip_directory("node_modules"));
+ assert!(skip_directory(".git"));
+ assert!(!skip_directory("src"));
+ assert!(skip_file_name(".env"));
+ assert!(skip_file_name("runner.test.mjs"));
+ assert!(!skip_file_name("runner.mjs"));
+ }
+
+ #[test]
+ fn plugin_subdirectories_declare_their_origin() {
+ let source = PLUGINS
+ .subdirectories
+ .iter()
+ .filter(|entry| subdirectory_is_source_derived(entry))
+ .map(|entry| entry.path)
+ .collect::>();
+ let prepared = PLUGINS
+ .subdirectories
+ .iter()
+ .filter(|entry| subdirectory_is_prepared(entry))
+ .map(|entry| entry.path)
+ .collect::>();
+ assert_eq!(source, ["src", "panels", "skills"]);
+ assert_eq!(prepared, ["native/payload", "dotnet/publish/win-x64"]);
+ }
+
+ #[test]
+ fn package_metadata_expectations_come_from_the_declaration() {
+ assert_eq!(CODEX.package_metadata.layout_version, 1);
+ assert_eq!(CODEX.package_metadata.resources_dir, "codex-resources");
+ assert_eq!(CODEX.package_metadata.path_dir, "codex-path");
+ }
+
+ /// 源码与随包目录各写一份插件夹具:随包侧缺测试文件、带一个构建期产物目录。
+ fn write_plugin_fixture(source_root: &Path, destination_root: &Path) {
+ let plugin_source = source_root.join("agc-demo-editor");
+ fs::create_dir_all(plugin_source.join("src")).expect("create src");
+ fs::write(
+ plugin_source.join("plugin.json"),
+ "{\"name\":\"agc-demo-editor\"}\n",
+ )
+ .expect("write manifest");
+ fs::write(plugin_source.join("src/entry.mjs"), "export const a = 1;\n")
+ .expect("write entry");
+ fs::write(plugin_source.join("src/entry.test.mjs"), "test\n").expect("write test file");
+ fs::create_dir_all(plugin_source.join("native/gdextension")).expect("create gdextension");
+
+ let staged = destination_root.join("agc-demo-editor");
+ fs::create_dir_all(staged.join("src")).expect("create staged src");
+ fs::write(
+ staged.join("plugin.json"),
+ "{\"name\":\"agc-demo-editor\"}\n",
+ )
+ .expect("write staged manifest");
+ fs::write(staged.join("src/entry.mjs"), "export const a = 1;\n")
+ .expect("write staged entry");
+ }
+
+ /// 在源码与随包目录各写一份同名同内容的插件目录(随包侧即准备步骤的复制结果)。
+ fn write_plugin_copy(
+ source_root: &Path,
+ destination_root: &Path,
+ plugin_name: &str,
+ relative_files: &[&str],
+ ) {
+ for root in [source_root, destination_root] {
+ let plugin = root.join(plugin_name);
+ fs::create_dir_all(&plugin).expect("create plugin");
+ fs::write(
+ plugin.join("plugin.json"),
+ format!("{{\"name\":\"{plugin_name}\"}}\n"),
+ )
+ .expect("write manifest");
+ for relative in relative_files {
+ let path = plugin.join(declared_relative_path(relative));
+ fs::create_dir_all(path.parent().expect("path parent")).expect("create parent");
+ fs::write(&path, format!("{relative}\n")).expect("write file");
+ }
+ }
+ }
+
+ #[test]
+ fn staged_plugins_are_checked_against_repository_sources() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let source_root = temp.path().join("plugins");
+ let destination_root = temp.path().join("resources/plugins");
+ write_plugin_fixture(&source_root, &destination_root);
+ validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "aarch64-apple-darwin",
+ |_| true,
+ )
+ .expect("valid plugin workspace");
+
+ let entry = destination_root.join("agc-demo-editor/src/entry.mjs");
+ fs::write(&entry, "tampered\n").expect("tamper");
+ let error = validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "aarch64-apple-darwin",
+ |_| true,
+ )
+ .expect_err("must reject digest drift");
+ assert!(error.contains("不一致"), "{error}");
+
+ fs::write(&entry, "export const a = 1;\n").expect("restore");
+ fs::remove_file(&entry).expect("remove");
+ let error = validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "aarch64-apple-darwin",
+ |_| true,
+ )
+ .expect_err("must reject missing file");
+ assert!(error.contains("缺少文件"), "{error}");
+
+ fs::write(&entry, "export const a = 1;\n").expect("restore");
+ fs::remove_file(destination_root.join("agc-demo-editor/plugin.json"))
+ .expect("remove manifest");
+ let error = validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "aarch64-apple-darwin",
+ |_| true,
+ )
+ .expect_err("must reject missing manifest");
+ assert!(error.contains("缺少清单"), "{error}");
+
+ validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "x86_64-unknown-linux-gnu",
+ |_| true,
+ )
+ .expect("不支持的目标直接放行");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn staged_plugins_reject_symlinks() {
+ use std::os::unix::fs::symlink;
+
+ let temp = tempfile::tempdir().expect("tempdir");
+ let source_root = temp.path().join("plugins");
+ let destination_root = temp.path().join("resources/plugins");
+ write_plugin_fixture(&source_root, &destination_root);
+ symlink(
+ "entry.mjs",
+ destination_root.join("agc-demo-editor/src/link.mjs"),
+ )
+ .expect("symlink");
+ let error = validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "aarch64-apple-darwin",
+ |_| true,
+ )
+ .expect_err("must reject symlink");
+ assert!(error.contains("符号链接"), "{error}");
+ }
+
+ /// 随包目录里出现源码侧不存在的文件(源码子目录残留、插件根目录未知文件、未声明的根条目)必须被拒绝。
+ #[test]
+ fn staged_plugins_reject_undeclared_files() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let source_root = temp.path().join("plugins");
+ let destination_root = temp.path().join("resources/plugins");
+ write_plugin_fixture(&source_root, &destination_root);
+
+ let leftover = destination_root.join("agc-demo-editor/src/leftover.mjs");
+ fs::write(&leftover, "stale\n").expect("write leftover");
+ let error = validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "aarch64-apple-darwin",
+ |_| true,
+ )
+ .expect_err("必须拒绝源码子目录里的残留文件");
+ assert!(error.contains("未声明文件"), "{error}");
+ assert!(error.contains("leftover.mjs"), "{error}");
+ fs::remove_file(&leftover).expect("remove leftover");
+
+ let root_file = destination_root.join("agc-demo-editor/README.md");
+ fs::write(&root_file, "stale\n").expect("write plugin root file");
+ let error = validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "aarch64-apple-darwin",
+ |_| true,
+ )
+ .expect_err("必须拒绝插件根目录的未知文件");
+ assert!(error.contains("未声明文件"), "{error}");
+ fs::remove_file(&root_file).expect("remove plugin root file");
+
+ let stray = destination_root.join("stray.txt");
+ fs::write(&stray, "stale\n").expect("write stray entry");
+ let error = validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "aarch64-apple-darwin",
+ |_| true,
+ )
+ .expect_err("必须拒绝未声明的根条目");
+ assert!(error.contains("未声明条目"), "{error}");
+ }
+
+ /// 源码侧删掉文件后,随包目录里的旧副本属于未声明文件(随包目录不接受比源码多出的内容)。
+ #[test]
+ fn staged_plugins_reject_files_removed_from_sources() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let source_root = temp.path().join("plugins");
+ let destination_root = temp.path().join("resources/plugins");
+ write_plugin_fixture(&source_root, &destination_root);
+ fs::remove_file(source_root.join("agc-demo-editor/src/entry.mjs")).expect("remove source");
+ let error = validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "aarch64-apple-darwin",
+ |_| true,
+ )
+ .expect_err("源码删除后的随包副本必须被拒绝");
+ assert!(error.contains("未声明文件"), "{error}");
+ assert!(error.contains("entry.mjs"), "{error}");
+ }
+
+ /// Cocos 的 `native/payload` 由构建产出:只在 windows 且 feature 开启时随包,其余组合下其产物必须被拒绝。
+ #[test]
+ fn staged_plugins_reject_prepared_artifacts_of_disabled_features() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let source_root = temp.path().join("plugins");
+ let destination_root = temp.path().join("resources/plugins");
+ write_plugin_copy(
+ &source_root,
+ &destination_root,
+ "agc-cocos-editor",
+ &["src/entry.mjs", "native/payload/cocos-editor-bridge.dll"],
+ );
+ validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "x86_64-pc-windows-msvc",
+ |name| name == "cocos-editor-injection",
+ )
+ .expect("feature 开启时 prepared 产物合法");
+
+ let error = validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "x86_64-pc-windows-msvc",
+ |_| false,
+ )
+ .expect_err("feature 关闭后必须拒绝 prepared 产物");
+ assert!(error.contains("未声明文件"), "{error}");
+ assert!(error.contains("cocos-editor-bridge.dll"), "{error}");
+
+ let error = validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "aarch64-apple-darwin",
+ |_| true,
+ )
+ .expect_err("目标不含 windows 时必须拒绝 prepared 产物");
+ assert!(error.contains("未声明文件"), "{error}");
+ }
+
+ /// 随包库只归位声明里的文件:源码工作区的构建输入不进随包目录,随包目录多出或缺少文件都必须被拒绝。
+ #[test]
+ fn staged_plugins_follow_declared_library_staging_files() {
+ let staging = PLUGINS
+ .library_staging
+ .iter()
+ .find(|entry| entry.layout == "godot-bundle")
+ .expect("godot staging");
+ let temp = tempfile::tempdir().expect("tempdir");
+ let source_root = temp.path().join("plugins");
+ let destination_root = temp.path().join("resources/plugins");
+ let mut relative_files = vec!["src/entry.mjs".to_string()];
+ relative_files.extend(
+ staging
+ .files
+ .iter()
+ .map(|file| format!("{}/{}", staging.source_subdirectory, file)),
+ );
+ let relative_files = relative_files
+ .iter()
+ .map(String::as_str)
+ .collect::>();
+ write_plugin_copy(
+ &source_root,
+ &destination_root,
+ "agc-godot-editor",
+ &relative_files,
+ );
+ let source_only = source_root.join("agc-godot-editor/native/gdextension/src/native.cpp");
+ fs::create_dir_all(source_only.parent().expect("path parent")).expect("create source dir");
+ fs::write(&source_only, "void build_input() {}\n").expect("write source-only file");
+
+ validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "x86_64-pc-windows-msvc",
+ |name| name == "godot-editor-execute",
+ )
+ .expect("声明文件齐备时随包库合法");
+
+ let stale =
+ destination_root.join("agc-godot-editor/native/gdextension/bin/win-x64/stale.dll");
+ fs::write(&stale, "stale\n").expect("write stale library file");
+ let error = validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "x86_64-pc-windows-msvc",
+ |name| name == "godot-editor-execute",
+ )
+ .expect_err("必须拒绝随包库里未声明的文件");
+ assert!(error.contains("未声明文件"), "{error}");
+ fs::remove_file(&stale).expect("remove stale library file");
+
+ let declared = destination_root
+ .join("agc-godot-editor")
+ .join(declared_relative_path(&format!(
+ "{}/{}",
+ staging.source_subdirectory, staging.files[0]
+ )));
+ fs::remove_file(&declared).expect("remove declared library file");
+ let error = validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "x86_64-pc-windows-msvc",
+ |name| name == "godot-editor-execute",
+ )
+ .expect_err("必须拒绝缺少声明文件的随包库");
+ assert!(error.contains("缺少声明文件"), "{error}");
+
+ let error = validate_staged_plugins(
+ &source_root,
+ &destination_root,
+ "x86_64-pc-windows-msvc",
+ |_| false,
+ )
+ .expect_err("feature 关闭后必须拒绝随包库");
+ assert!(error.contains("未声明文件"), "{error}");
+ }
+
+ #[test]
+ fn collect_sources_applies_declared_skip_rules() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let root = temp.path();
+ fs::create_dir_all(root.join("target")).expect("create target");
+ fs::create_dir_all(root.join("node_modules")).expect("create node_modules");
+ fs::create_dir_all(root.join("src")).expect("create src");
+ fs::write(root.join("target/junk.rs"), "junk\n").expect("write junk");
+ fs::write(root.join("node_modules/pkg.js"), "pkg\n").expect("write pkg");
+ fs::write(root.join("src/entry.mjs"), "entry\n").expect("write entry");
+ fs::write(root.join("src/entry.test.mjs"), "test\n").expect("write test");
+ fs::write(root.join(".env"), "secret\n").expect("write env");
+ let files = collect_sources(root).expect("collect");
+ assert_eq!(files.into_iter().collect::>(), ["src/entry.mjs"]);
+ }
+
+ #[test]
+ fn declaration_pins_claude_agent_layout() {
+ assert_eq!(CLAUDE_AGENT_SDK_VERSION, "0.3.285");
+ assert_eq!(CLAUDE_AGENT.version, "0.3.285");
+ assert_eq!(CLAUDE_AGENT.resource_directory, "resources/claude-agent");
+ assert_eq!(CLAUDE_AGENT.entry_file_name, "index.mjs");
+ assert_eq!(
+ CLAUDE_AGENT.entry_relative_path,
+ "agent-sidecar/src/index.mjs"
+ );
+ assert_eq!(
+ CLAUDE_AGENT.sdk_package_name,
+ "@anthropic-ai/claude-agent-sdk"
+ );
+ assert_eq!(CLAUDE_AGENT.sdk_entry_file_name, "sdk.mjs");
+ assert_eq!(CLAUDE_AGENT.targets.len(), 3);
+ let windows = claude_agent_target("x86_64-pc-windows-msvc").expect("windows sidecar");
+ assert_eq!(windows.runtime_package, "claude-agent-sdk-win32-x64");
+ assert_eq!(windows.runtime_file_name, "claude.exe");
+ let mac = claude_agent_target("aarch64-apple-darwin").expect("mac sidecar");
+ assert_eq!(mac.runtime_package, "claude-agent-sdk-darwin-arm64");
+ assert_eq!(mac.runtime_file_name, "claude");
+ assert!(claude_agent_target("x86_64-unknown-linux-gnu").is_none());
+ }
+
+ fn write_package_version(directory: &Path, metadata_file_name: &str, version: &str) {
+ fs::write(
+ directory.join(metadata_file_name),
+ serde_json::json!({ "version": version }).to_string(),
+ )
+ .expect("write package metadata");
+ }
+
+ /// 写一份最小的 Claude Agent SDK sidecar 随包产物(入口 + SDK + 平台原生运行时)。
+ fn write_claude_agent_bundle(target_dir: &Path, app_root: &Path, target: &str) {
+ let declared = claude_agent_target(target).expect("claude agent layout");
+ let entry_source = app_root.join(declared_relative_path(CLAUDE_AGENT.entry_relative_path));
+ fs::create_dir_all(entry_source.parent().expect("entry parent")).expect("create entry dir");
+ fs::write(&entry_source, "console.log('sidecar');\n").expect("write entry source");
+ fs::create_dir_all(target_dir).expect("create target dir");
+ fs::write(
+ target_dir.join(CLAUDE_AGENT.entry_file_name),
+ "console.log('sidecar');\n",
+ )
+ .expect("write staged entry");
+
+ let sdk = target_dir
+ .join(declared_relative_path(CLAUDE_AGENT.node_modules_directory))
+ .join(declared_relative_path(CLAUDE_AGENT.sdk_package_name));
+ fs::create_dir_all(&sdk).expect("create sdk package");
+ fs::write(
+ sdk.join(CLAUDE_AGENT.sdk_entry_file_name),
+ "export const sdk = 1;\n",
+ )
+ .expect("write sdk entry");
+ write_package_version(
+ &sdk,
+ CLAUDE_AGENT.sdk_package_metadata_file_name,
+ CLAUDE_AGENT.version,
+ );
+
+ let runtime = target_dir
+ .join(declared_relative_path(CLAUDE_AGENT.node_modules_directory))
+ .join(declared_relative_path(package_scope(
+ CLAUDE_AGENT.sdk_package_name,
+ )))
+ .join(declared_relative_path(declared.runtime_package));
+ fs::create_dir_all(&runtime).expect("create runtime package");
+ let runtime_file = runtime.join(declared_relative_path(declared.runtime_file_name));
+ fs::write(&runtime_file, "runtime\n").expect("write runtime");
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(&runtime_file, fs::Permissions::from_mode(0o755))
+ .expect("chmod runtime");
+ }
+ write_package_version(
+ &runtime,
+ CLAUDE_AGENT.sdk_package_metadata_file_name,
+ CLAUDE_AGENT.version,
+ );
+ }
+
+ #[test]
+ fn staged_claude_agent_bundle_is_checked_against_repository_sources() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let target_dir = temp.path().join("resources/claude-agent");
+ let app_root = temp.path().join("app");
+ write_claude_agent_bundle(&target_dir, &app_root, "aarch64-apple-darwin");
+ validate_staged_claude_agent(&target_dir, &app_root, "aarch64-apple-darwin")
+ .expect("合规目录通过");
+
+ fs::write(
+ target_dir.join(CLAUDE_AGENT.entry_file_name),
+ "console.log('drift');\n",
+ )
+ .expect("write drifted entry");
+ let error = validate_staged_claude_agent(&target_dir, &app_root, "aarch64-apple-darwin")
+ .expect_err("入口漂移必须被拒绝");
+ assert!(error.contains("与仓库源码不一致"), "{error}");
+
+ write_claude_agent_bundle(&target_dir, &app_root, "aarch64-apple-darwin");
+ fs::write(target_dir.join("stray.mjs"), "stray\n").expect("write stray");
+ let error = validate_staged_claude_agent(&target_dir, &app_root, "aarch64-apple-darwin")
+ .expect_err("白名单外的文件必须被拒绝");
+ assert!(error.contains("白名单外"), "{error}");
+ }
+
+ #[test]
+ fn staged_claude_agent_rejects_missing_sdk_entry_and_version_drift() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let target_dir = temp.path().join("resources/claude-agent");
+ let app_root = temp.path().join("app");
+ write_claude_agent_bundle(&target_dir, &app_root, "x86_64-pc-windows-msvc");
+ let sdk = target_dir
+ .join(declared_relative_path(CLAUDE_AGENT.node_modules_directory))
+ .join(declared_relative_path(CLAUDE_AGENT.sdk_package_name));
+ fs::remove_file(sdk.join(CLAUDE_AGENT.sdk_entry_file_name)).expect("remove sdk entry");
+ let error = validate_staged_claude_agent(&target_dir, &app_root, "x86_64-pc-windows-msvc")
+ .expect_err("缺 SDK 入口必须被拒绝");
+ assert!(error.contains("缺少 SDK 入口"), "{error}");
+
+ write_claude_agent_bundle(&target_dir, &app_root, "x86_64-pc-windows-msvc");
+ write_package_version(&sdk, CLAUDE_AGENT.sdk_package_metadata_file_name, "0.0.0");
+ let error = validate_staged_claude_agent(&target_dir, &app_root, "x86_64-pc-windows-msvc")
+ .expect_err("版本漂移必须被拒绝");
+ assert!(error.contains("版本与声明不一致"), "{error}");
+ }
+
+ #[test]
+ fn claude_agent_layout_rejects_undeclared_target() {
+ let error = validate_staged_claude_agent(
+ Path::new("missing"),
+ Path::new("."),
+ "x86_64-unknown-linux-gnu",
+ )
+ .expect_err("未声明目标必须被拒绝");
+ assert!(error.contains("声明不含目标"), "{error}");
+ }
+}
diff --git a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore b/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore
deleted file mode 100644
index 6a7461313..000000000
--- a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-*.dll
diff --git a/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep b/apps/ai-game-creator-shell/src-tauri/resources/cocos-editor-bridge/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs
index a6ad51e1f..4dee3f105 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs
@@ -304,7 +304,8 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result testEndpoint;
const runTauriDev = (
argv: string[],
options: Parameters[1],
-) => runTauriDevImpl(argv, { prepareFrontend: async () => {}, ...options });
+) =>
+ runTauriDevImpl(argv, {
+ prepareFrontend: async () => {},
+ // 随包资源准备会读取真实上游包与仓库插件工作区;需要断言的用例自行注入。
+ prepareResources: () => {},
+ ...options,
+ });
async function waitForFile(path: string, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
@@ -152,6 +164,10 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
prepareFrontend: async () => {
order.push('frontend-ready');
},
+ prepareResources: (features) => {
+ expect(Array.isArray(features)).toBe(true);
+ order.push('resources');
+ },
spawnCli: () => {
order.push('spawn');
return child;
@@ -170,6 +186,7 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
expect(result).toBe(1);
expect(order).toEqual([
'preflight',
+ 'resources',
'frontend-ready',
'spawn',
'exit',
@@ -300,3 +317,311 @@ describe('AI 游戏创作 Tauri dev 生命周期', () => {
}
});
});
+
+describe('AI 游戏创作 Tauri dev 进程环境', () => {
+ const posixTest = process.platform === 'win32' ? test.skip : test;
+
+ // 本机 `~/.cargo/config.toml` 或仓库级 Cargo 配置里的 sccache wrapper 只有在环境变量
+ // 非空时才会被覆盖;这里必须显式写入要交给 Tauri Cargo 的 wrapper 决策结果。
+ posixTest('本地 dev 不把 sccache 交给 Tauri Cargo', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ try {
+ const env = buildTauriDevProcessEnv(testEndpoint, {
+ RUSTC_WRAPPER: 'sccache',
+ CARGO_BUILD_RUSTC_WRAPPER: 'sccache',
+ });
+
+ expect(env.RUSTC_WRAPPER).toBe('/usr/bin/env');
+ expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/usr/bin/env');
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ posixTest('未显式配置 wrapper 时清空两个变量', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ try {
+ const env = buildTauriDevProcessEnv(testEndpoint, {
+ CARGO_TERM_COLOR: 'never',
+ });
+
+ expect(env.RUSTC_WRAPPER).toBe('');
+ expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('');
+ expect(env.CARGO_TERM_COLOR).toBe('never');
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ posixTest('保留显式自定义 wrapper 且不改写调用方 env', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ try {
+ const input = { RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' };
+ const env = buildTauriDevProcessEnv(testEndpoint, input);
+
+ expect(env.RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper');
+ expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('/opt/custom/rustc-wrapper');
+ expect(env.GENARRATIVE_AGC_VITE_PORT).toBe(String(testEndpoint.port));
+ expect(input).toEqual({ RUSTC_WRAPPER: '/opt/custom/rustc-wrapper' });
+ } finally {
+ warn.mockRestore();
+ }
+ });
+});
+
+describe('AI 游戏创作 Tauri dev 随包资源准备目标', () => {
+ const windowsFeatures = [
+ 'cocos-editor-execute',
+ 'unity-editor-execute',
+ 'godot-editor-execute',
+ ];
+ // buildTauriArguments 注入的动态 devUrl 配置:最终交给 Tauri 的参数里长这样。
+ const devConfig =
+ '{"build":{"devUrl":"http://127.0.0.1:10005/","beforeDevCommand":""}}';
+
+ test('显式 --target 决定 staging 目标,未指定或属于应用参数时回落宿主目标', () => {
+ const calls: [string, string[]][] = [];
+ const hostTarget = 'aarch64-apple-darwin';
+ const prepare = (options: {
+ target: string;
+ features: Iterable;
+ }) => {
+ calls.push([options.target, [...options.features]]);
+ return [];
+ };
+
+ for (const argv of [
+ ['dev', '--config', devConfig],
+ ['dev', '--target', 'x86_64-apple-darwin', '--config', devConfig],
+ ['dev', '--target=x86_64-pc-windows-msvc', '--config', devConfig],
+ // 启动器把 `--` 之后的参数交给应用(补成第二段分隔符):不能拿它改随包资源目标。
+ [
+ 'dev',
+ '--config',
+ devConfig,
+ '--',
+ '--',
+ '--target=x86_64-pc-windows-msvc',
+ ],
+ ]) {
+ prepareBundledResourcesBeforeTauri(windowsFeatures, argv, {
+ hostTarget,
+ prepare,
+ log: () => {},
+ });
+ }
+
+ expect(calls.map(([target]) => target)).toEqual([
+ hostTarget,
+ 'x86_64-apple-darwin',
+ 'x86_64-pc-windows-msvc',
+ hostTarget,
+ ]);
+ expect(calls[0][1]).toEqual(windowsFeatures);
+ });
+
+ test('宿主平台不受声明覆盖时跳过准备,且不为其新增随包支持', () => {
+ const prepare = vi.fn();
+ const log = vi.fn();
+
+ prepareBundledResourcesBeforeTauri(
+ windowsFeatures,
+ ['dev', '--config', devConfig],
+ { hostTarget: null, prepare, log },
+ );
+
+ expect(prepare).not.toHaveBeenCalled();
+ expect(log.mock.calls.flat().join('\n')).toContain('跳过随包资源准备');
+ });
+
+ async function runCapturingDev(argv: string[]) {
+ const prepared: unknown[][] = [];
+ const spawned: string[][] = [];
+ const child = Object.assign(new EventEmitter(), {
+ pid: 1234,
+ exitCode: 0,
+ signalCode: null,
+ kill: vi.fn(),
+ });
+ const result = await runTauriDev(argv, {
+ resolveDevEndpoint: resolveTestEndpoint,
+ preflight: async () => {},
+ prepareResources: (...args: unknown[]) => {
+ prepared.push(args);
+ },
+ spawnCli: (args: string[]) => {
+ spawned.push(args);
+ return child;
+ },
+ waitForCli: async () => ({ type: 'exit', code: 0, signal: null }),
+ terminateTree: async () => ({ stopped: true, forced: false }),
+ });
+ return { result, prepared, spawned };
+ }
+
+ test('显式 -f 优先于环境变量,并同时决定随包资源 feature 与 cargo 参数', async () => {
+ vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures');
+ try {
+ const { result, prepared, spawned } = await runCapturingDev([
+ '-f',
+ 'custom-feature',
+ ]);
+
+ expect(result).toBe(0);
+ expect(prepared.map(([features]) => features)).toEqual([
+ ['custom-feature'],
+ ]);
+ expect(spawned[0].slice(0, 3)).toEqual(['dev', '-f', 'custom-feature']);
+ expect(spawned[0]).not.toContain(
+ `--features=${windowsFeatures.join(',')}`,
+ );
+ // 随包资源准备拿到的是最终交给 Tauri 的参数,不是启动器收到的原始参数。
+ expect(prepared[0][1]).toEqual(spawned[0]);
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
+ test('显式 --target 同时进入随包资源准备与 Tauri 参数', async () => {
+ vi.stubEnv('AGC_DEV_CARGO_FEATURES', '');
+ try {
+ const { result, prepared, spawned } = await runCapturingDev([
+ '--target',
+ 'x86_64-apple-darwin',
+ ]);
+
+ expect(result).toBe(0);
+ expect(prepared.map(([features]) => features)).toEqual([[]]);
+ expect(prepared[0][1]).toEqual([
+ 'dev',
+ '--target',
+ 'x86_64-apple-darwin',
+ '--config',
+ devConfig,
+ ]);
+ expect(spawned[0]).toEqual(prepared[0][1]);
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
+ test('启动器 `--` 之后的应用参数不参与 staging feature 解析', async () => {
+ vi.stubEnv('AGC_DEV_CARGO_FEATURES', '');
+ try {
+ const { result, prepared, spawned } = await runCapturingDev([
+ '--',
+ '--features=app-tool',
+ ]);
+
+ expect(result).toBe(0);
+ expect(prepared.map(([features]) => features)).toEqual([[]]);
+ expect(spawned[0]).toEqual([
+ 'dev',
+ '--config',
+ devConfig,
+ '--',
+ '--',
+ '--features=app-tool',
+ ]);
+ expect(prepared[0][1]).toEqual(spawned[0]);
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
+ test('空串 AGC_DEV_CARGO_FEATURES 关闭默认 feature,staging 与 cargo 都不带 feature', async () => {
+ vi.stubEnv('AGC_DEV_CARGO_FEATURES', '');
+ try {
+ const { result, prepared, spawned } = await runCapturingDev([]);
+
+ expect(result).toBe(0);
+ expect(prepared.map(([features]) => features)).toEqual([[]]);
+ expect(
+ spawned[0].some((argument) => argument.startsWith('--features')),
+ ).toBe(false);
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+
+ test('环境变量只在不与显式 CLI 冲突时生效', () => {
+ vi.stubEnv('AGC_DEV_CARGO_FEATURES', 'template-library-fixtures');
+ try {
+ expect(
+ resolveEditorFeatures({
+ argv: ['dev', '--config', devConfig],
+ target: 'win32',
+ }),
+ ).toEqual(['template-library-fixtures']);
+ expect(
+ resolveEditorFeatures({
+ argv: ['dev', '-f', 'cli-feature', '--config', devConfig],
+ target: 'win32',
+ }),
+ ).toEqual(['cli-feature']);
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
+});
+
+describe('AI 游戏创作 Tauri dev 目标与 feature 解析', () => {
+ test('目标只从构建参数区解析,取值缺失、取到选项或重复都失败关闭', () => {
+ expect(readCargoTarget([])).toBeUndefined();
+ expect(readCargoTarget(['--no-watch'])).toBeUndefined();
+ expect(readCargoTarget(['-t', 'x86_64-apple-darwin'])).toBe(
+ 'x86_64-apple-darwin',
+ );
+ expect(readCargoTarget(['--target=x86_64-apple-darwin'])).toBe(
+ 'x86_64-apple-darwin',
+ );
+ // runner 参数由 Tauri 追加给 cargo:仍然算构建目标。
+ expect(
+ readCargoTarget(['build', '--', '--target=aarch64-apple-darwin']),
+ ).toBe('aarch64-apple-darwin');
+ // 第二个 `--` 之后是应用参数。
+ expect(
+ readCargoTarget(['dev', '--', '--', '--target=aarch64-apple-darwin']),
+ ).toBeUndefined();
+ expect(() => readCargoTarget(['--target'])).toThrow('缺少有效目标');
+ expect(() => readCargoTarget(['--target', '--no-watch'])).toThrow(
+ '缺少有效目标',
+ );
+ expect(() => readCargoTarget(['--target', 'a', '-t', 'b'])).toThrow(
+ '不能重复指定',
+ );
+ });
+
+ test('feature 解析覆盖 -f 拼写、合并多个 flag 并止步于应用参数', () => {
+ const cases: [string[], string[]][] = [
+ [
+ ['-f', 'a,b'],
+ ['a', 'b'],
+ ],
+ [['-fa'], ['a']],
+ [['--features=explicit'], ['explicit']],
+ // Cargo 会合并多个 feature flag:staging 必须拿到同一集合。
+ [
+ ['--features', 'a', '-f', 'b'],
+ ['a', 'b'],
+ ],
+ // 显式给空集合即关闭默认,而不是回落默认值。
+ [['--features='], []],
+ // 第二个 `--` 之后是应用参数。
+ [
+ ['dev', '--config', '{}', '--', '--', '-f', 'app-feature'],
+ [
+ 'cocos-editor-execute',
+ 'unity-editor-execute',
+ 'godot-editor-execute',
+ ],
+ ],
+ ];
+
+ for (const [argv, expected] of cases) {
+ expect(resolveEditorFeatures({ argv, target: 'win32', env: {} })).toEqual(
+ expected,
+ );
+ }
+ });
+});
diff --git a/docs/README.md b/docs/README.md
index bd1606520..40edf3b5f 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -41,6 +41,7 @@
- [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。
- [AGC 后端框架整理与演进路线](./technical/【技术方案】AGC后端框架整理与演进路线-2026-09-18.md):共享 Runtime、本地执行宿主、云端控制面、领域/平台适配器及分阶段收口边界。
+- [AGC 随包资源 staging 归位](./technical/【技术方案】AGC随包资源staging归位-2026-09-26.md):随包资源改由准备步骤在 `tauri dev|build` 之前一次性生成、`build.rs` 退化为校验者;含缓存与原子性合同、入口接线、验收判据与里程碑拆分。
- [AGC 异步操作可恢复闭环](./【技术方案】AGC异步操作可恢复闭环-2026-09-14.md):认证响应体、最近项目检查和首页自动创建的超时、逐项恢复与跨页防重合同。
- [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。
- [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):历史方案,仅用于追溯 V2 的实现与退役过程,不作为当前实现依据。
diff --git a/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md
new file mode 100644
index 000000000..a200761ec
--- /dev/null
+++ b/docs/project-memory/plans/【实施计划】AGC随包资源改由校验器读入-2026-09-26.md
@@ -0,0 +1,64 @@
+# 【实施计划】AGC 随包资源改由校验器读入
+
+| 字段 | 值 |
+| --------- | --------------------------------------------------------------- |
+| Milestone | `docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md` |
+| Status | ready(待里程碑规范评审通过后开工) |
+| Owner | suzmii / Agent |
+
+## 修改边界
+
+允许修改:
+
+- `apps/ai-game-creator-shell/src-tauri/build.rs`:新增只读校验调用点;本里程碑内保持现有写入分支不变(不改变既有构建行为)。
+- `apps/ai-game-creator-shell/src-tauri/build_support/**`:把平台布局、组件白名单、摘要校验整理为可被构建脚本之外的独立工具复用的一处声明。
+- 新增随包资源准备工具及其测试(位置见「待确认决策」)。
+- 需要时扩展 `apps/ai-game-creator-shell/scripts/check-config.mjs` 的断言。
+- 文档:主规范未决问题收口、开发运维文档对应段落。
+
+明确不修改:
+
+- 三份 tauri 配置的 `resources` 映射、包内路径与安装包形态。
+- 运行时资源解析与完整性校验(`codex_cli.rs`、`plugin_host.rs`、`editor_adapters.rs`、`environment_check.rs`)。
+- 发布脚本流程、版本号机制、签名与上传。
+- dev 启动器与发布入口的接线(下一里程碑)。
+- 编辑器分支产物(Unity/Godot/Cocos)的生成方式(最后一个里程碑)。
+
+## 实现顺序
+
+1. **共用能力可复用**:确认 `build_support` 内的平台布局与组件白名单能被独立工具引用(现状先例:`src/agent/codex_cli.rs` 与 `main.rs` 已通过 `#[path]` 复用同一模块),把「布局 + 白名单 + 摘要校验」收敛为单一入口,避免准备工具另写一份清单。
+2. **准备工具骨架**:目标目录与清单写出、缓存 key(上游 lockfile 的 `resolved` + `integrity` + 布局版本 + 目标三元)、临时目录 + 原子替换、所有权与符号链接校验、并发串行化、单行汇总日志。先实现纯复制两条路径(随包组件、插件工作区),编辑器分支产物本轮仍由构建脚本生成。
+3. **幂等与失败关闭**:重复执行不改变内容与时间戳;上游缺失、摘要不匹配、目录被非本工具占用、目标平台不支持四类场景各自失败并给出可定位原因。
+4. **校验路径上线**:构建脚本在既有产物上执行只读校验(默认不影响现有写入行为),校验失败以明确原因中止。
+5. **测试与证据**:按里程碑「证据要求」补齐用例与运行记录。
+
+## 验证命令
+
+1. 声明唯一性与门禁:`npm run agc:bundled-resources:check`(已进 `agc:typecheck` 链),不一致时用 `npm run agc:bundled-resources:sync` 重新生成。
+2. 准备工具用例(含幂等与失败关闭):`npm run agc:bundled-resources:test`。
+3. 校验路径独立运行(跳过写入分支):`AGC_SKIP_RESOURCE_STAGING=1 cargo check --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`。
+4. Rust 用例:`cargo test --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml package_layout` 与 `cargo test --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml codex_bundle`。
+5. 幂等(真实工作区):连续两次 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`,第二次必须全部「命中缓存」,且两次之后的目录快照(相对路径、大小、mtime、sha256)完全一致。
+6. 并存一致:准备步骤产物与构建脚本产物逐文件比对(相对路径、大小、sha256)一致。
+7. 行为不回归:`cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-default-features`(本里程碑不承诺构建变快,仅确认行为与改造前一致,并记录当前构建耗时作为后续里程碑基线)。
+8. 门禁:`node apps/ai-game-creator-shell/scripts/check-config.mjs`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`,以及改动范围内相关 vitest/Rust 测试。
+
+## 风险与回滚点
+
+| 风险 | 影响 | 处理 |
+| --- | --- | --- |
+| 校验器误判把构建卡死 | 影响所有本机构建 | 只读校验先以「不影响写入行为」的方式接入;出现误判可先关闭校验调用点回滚 |
+| 准备工具与构建脚本并存产生双写 | 两处结果漂移、时间戳变化 | 并存期以「准备工具产物 == 构建脚本产物」逐文件比对作为过渡判据;不一致视为失败 |
+| 缓存 key 漏掉上游变化 | 静默用旧组件 | key 含 lockfile `resolved` + `integrity` + 布局版本 + 三元;清单校验作为第二道闸 |
+| 准备工具实现形态选错 | 返工 | 见「待确认决策」,评审时一次定清 |
+
+回滚点:本里程碑不改变既有构建行为,回滚只需移除校验调用点与准备工具,不影响产物与发布流程。
+
+## 已定决策
+
+准备工具的实现形态(主规范未决问题 1)**已定为混合**(2026-09-27,机制见主规范 §4.8):
+
+- 上游获取、`integrity` 校验、归档安全与原子替换复用 Node 侧既有范式(`scripts/stage-node-runtime.mjs`、`scripts/prepare-macos-codex.mjs`);
+- 平台布局、组件白名单与逐文件摘要校验复用 Rust 侧既有声明(`build_support/codex_bundle.rs`、`build_support/godot_bundle.rs`),由准备工具与校验路径共用同一份声明文件承载,不再各写一份清单。(上游原生包元数据的期望值后来并入同一份声明;`build_support/codex_package_metadata.rs` 已在 M2 因失去调用方删除。)
+
+理由:避免出现第二份组件白名单,同时不必重写 registry 下载、`integrity` 与 tar 安全校验;缺点是声明需要经过一次生成步骤才能在 Rust 侧使用,由 `check-package-layout.mjs` 门禁保证两者一致。
diff --git a/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md
new file mode 100644
index 000000000..9048bfd04
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】AGC编辑器分支产物归位与症状层补丁清理-2026-09-26.md
@@ -0,0 +1,115 @@
+# 【里程碑】AGC 编辑器分支产物归位与症状层补丁清理
+
+| 字段 | 值 |
+| ----------- | --------------------------------------------------------------- |
+| Version | 1.0 |
+| Status | in-progress(2026-09-28 已过 Windows 真机核心评审;2026-09-29 完成打包一致性验收,客户端加载待有编辑器环境的机器;Cocos payload feature 集差异待裁决) |
+| Date | 2026-09-26 |
+| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` |
+
+## 已实现(2026-09-27)
+
+- 声明新增三类「准备步骤」产物:`subdirectories[origin=prepared]`(Unity publish 目录)、`libraryStaging[].prepare + files`(Godot gdextension)、`nativePayloads[]`(Cocos bridge dll);`plugins.prepareSteps` 描述每个准备步骤的程序、工作目录、指纹与必需产物。
+- 准备步骤(`scripts/prepare-bundled-resources.mjs`)按声明执行 `powershell.exe -File build.ps1` 与 `cargo build -p … --target …`,用内容指纹跳过未变化的步骤,校验必需产物齐全后才复制;命中指纹且产物齐全时零写入。
+- 构建脚本删除了三处产物生成与整棵树复制(`prepare_unity_editor_helper`、`prepare_godot_editor_extension`、`stage_cocos_editor_payload`、`stage_build_generated_plugin_payloads` 及其辅助函数,共减少约 220 行),只保留只读校验:源码派生内容逐文件比对、已准备产物存在性、Godot 随包库沿用既有深度校验(`godot_bundle::validate`)。Godot 随包文件清单改由声明提供(单一来源)。
+- `.taurignore` 的两份 staging 条目已删除(构建期不再写 `resources/plugins`,无需忽略)。
+- 准备步骤的 Windows 侧命令路径已随系统性审计在真机复跑(powershell/cargo 两条路径、产物归位与幂等均已验证)。
+
+## 目标
+
+编辑器分支(Unity/Godot/Cocos)的随包产物也由准备步骤生成,构建脚本不再调用外部工具链产出随包资源;此前为绕开自触发问题而加入的症状层补丁与说明全部删除,实现形态与主规范一致。
+
+## 范围
+
+- 三个编辑器分支产物的生成职责迁出构建脚本,包括需要外部工具链的两条路径。
+- 构建脚本内与资源生成相关的规避手段删除:残留清理逻辑、为幂等而设的辅助常量与判断、开发监听忽略条目中与随包资源相关的部分。
+- 平台与特性开关(哪些平台、哪些特性才需要这些产物)在新形态下保持既有语义。
+- 与发布打包、包内资源门禁、运行时解析的一致性核对。
+
+## 不在范围内
+
+- 编辑器分支本身的接入协议、宿主能力与运行时行为。
+- 外部工具链版本管理与安装流程(沿用现状)。
+- 随包组件与插件工作区的生成形态(上一里程碑已完成)。
+
+## 依赖与前置条件
+
+- 前两个里程碑验收通过。
+- Windows 环境具备 Unity/Godot 分支所需的工具链(.NET 与 CMake 等),以便验证产物生成与打包。
+
+## 验收标准
+
+- [ ] 构建脚本中不再存在向随包资源目录写入的分支,也不再调用产出随包资源的外部工具链。
+- [ ] 三个编辑器分支的随包产物路径、内容摘要、可执行位与迁移前逐项一致,且由准备步骤稳定产出。(2026-09-29 验收:路径集合与源码派生内容、Unity/Godot 产物逐字节一致;Cocos payload 因声明只构建 `windows-injection` 而与迁移前不同,且 MSVC 链接产物本身不可字节复现,见下方验收记录,口径待裁决)
+- [ ] 此前为规避自触发而加入的补丁(残留清理、幂等辅助、监听忽略条目)在代码与文档中全部移除,不再有「为了绕开构建问题」的说明。
+- [ ] 平台与特性开关语义不变:不支持的平台不产出这些资源,且不因此失败。
+- [ ] 全量门禁通过,且客户端在具备条件与不具备条件两种环境下都能给出明确结论(可用 / 缺组件及原因)。
+
+## 验收记录(2026-09-29,Windows 本机)
+
+### 打包一致性(验收标准第 2 条)
+
+准备步骤按发布入口同样的参数复跑(`prepareBundledResources({ target: 'x86_64-pc-windows-msvc', features: Set('unity-editor-execute','godot-editor-execute','cocos-editor-injection'), profile: 'release' })`):`cocos-bridge-build 已构建`、`unity-helper-publish` / `godot-extension-build 命中缓存`、`plugins 重新生成`;随后连续三次复跑,`resources/plugins` 的 32 个文件内容与 mtime 均不再变化(稳定产出)。
+
+出包走同一发布入口(`runTauriBuild` + `--bundles nsis`),并临时把 `bundle.createUpdaterArtifacts` 置 false——本机没有 updater 签名私钥,只出安装包;产物 `target/x86_64-pc-windows-msvc/release/bundle/nsis/陶泥儿开发版_0.1.67_x64-setup.exe`(166452206 B,sha256 `04a0be8ab8f54d8f032ce86d3e5c7a99c1dd6e81f71d49b63486827b11a01940`)。`tauri.windows.conf.json` 里 `resources/plugins → plugins` 是目录级映射。
+
+包内核对:7z 解包得 2108 个文件,包内 `plugins/` 的 32 个文件与准备步骤产出的 `resources/plugins` **逐文件 sha256 完全一致**;`editor_adapters.rs` 的三个运行时相对路径(`UNITY_ATTACH_HELPER_RELATIVE` / `GODOT_BRIDGE_PAYLOAD_RELATIVE` / `COCOS_BRIDGE_PAYLOAD_RELATIVE`)加上声明里的必需产物共 17 项在包内全部命中。
+
+迁移前对照取两份:本机已安装的 2026-09-24 包(`%LOCALAPPDATA%\陶泥儿开发版\plugins`,迁移前产品产物),以及把 `src-tauri` 整体切回合并基线 `8f59c034f` 后在同一个工作树里跑 `cargo build --release --target x86_64-pc-windows-msvc --features=unity-editor-execute,godot-editor-execute,cocos-editor-injection` 得到的 `resources/plugins`。三份对照路径集合一致,源码派生内容(JS/HTML/JSON/license/notice)逐字节一致,Unity helper、Godot 扩展逐字节一致。
+
+两处已定性的差异:
+
+1. **Cocos payload 的构建 feature 集不同(需裁决口径)**:迁移前由同一次应用构建产出(`windows-bootstrap` + `windows-injection`,345088 B);准备步骤按声明只构建 `windows-injection`(26112 B,见 2026-09-27 决策日志条)。两者导出面完全相同(`DllMain`、`cocos_editor_bridge_bootstrap_source`),差掉的是宿主侧 bootstrap 传输(`reqwest`/`tungstenite`/`inspector`),注入进程不使用;但按「内容摘要与迁移前逐项一致」的字面判据不成立。
+2. **MSVC 链接产物不可字节复现**:同一 source / feature / profile / target 连续构建的 payload 摘要不同(除 PE `TimeDateStamp` 外还有 22 字节 RSDS GUID 差异);.NET publish 相反是确定的(Unity helper 跨两次重新发布逐字节一致),Godot 因 `buildId` 早退未重链也保持逐字节一致。因此「摘要一致」只在源码派生物、.NET 产物与命中工具链内部缓存的产物上成立。
+
+新发现的风险(本轮未修,不影响上述结论):
+
+- 准备步骤的 `cocos-bridge-build` 与同一次应用构建写同一个输出路径 `target///deps/cocos_editor_bridge.dll`(两个 feature 单元同名产物),准备步骤的候选查找可能取到另一单元刚写下的文件;本轮实测两者交替后 cargo 会多一次重链。建议让准备步骤在独立 target 目录构建,或与应用的 feature 集对齐后从同一单元取产物。
+
+### 客户端编辑器分支(验收标准第 5 条)
+
+本机未安装 Unity / Godot / Cocos Creator(`Program Files`、`UnityHub`、scoop shims 均无),进入编辑器分支只会停在「未检测到编辑器进程」,拿不到「helper/扩展被加载」的真机结论,因此**本轮未执行**,需在有三种编辑器的机器上补做。
+
+已完成的自动化前段(本轮实测,用安装包解出的客户端、不安装):
+
+- 从 NSIS 包 7z 解出后直接运行 `genarrative-ai-game-creator-shell.exe`:窗口落在 `http://tauri.localhost/`(生产态嵌入前端,不是 `devUrl`),首页正常渲染,最近项目与模板库可见——说明包内 `plugins/`、`skills`、模板资源都被正确读取(对照:用 `cargo build --release` 直接编出来的 exe 没有 `custom-protocol`,会去连 `127.0.0.1:3080` 并落到 chrome 错误页,不能拿它当打包客户端)。
+- CDP 主世界(`page.target().createCDPSession()` + `Runtime.evaluate`)可读只读投影:`list_agc_plugins` 返回 `agc-cocos-editor` / `agc-unity-editor`(`builtin=true`、`hasRuntime=true`、`adapter` 正确),`list_agc_skill_catalog` 返回 8 条 —— 插件的 JS 入口与 Skill 包都按声明进包。
+- `agc-godot-editor` 插件不在该列表里符合现役语义:`plugin_host.rs::plugin_matches_project` 只在当前项目是 Godot 工程(根或一层子目录有 `project.godot`)时才让它可见。
+- 三种编辑器分支的判据入口:`require_plugin_adapter` 缺适配器时报「当前客户端不支持 X 编辑器桥接」,适配器在 payload/helper 缺失时报各自缺组件文案(如 Godot「插件缺少原生 DLL 资源」),编辑器没开时报探测失败——补做时要按这三类分开记录。
+
+### 顺带定性:CI 唯一红项与本 PR 无关
+
+run 2980(HEAD `0cf536b6`)唯一失败项是 `tests::sessions::background_agent_runtime_can_write_memory_and_project_files`(`src-tauri/src/tests/sessions.rs:405`,第二个 provider follow-up 请求 `recv_timeout(2s)` 超时):
+
+- 本 PR 对这条路径零改动:`src/` 下只有 `main.rs`(`#[cfg(test)]` 引入 `package_layout` 单测)与 `agent/codex_cli.rs`(去掉 `codex_package_metadata` 测试模块)两处**测试编译期**改动;`sessions.rs` 与 agent runtime 与合并基线逐字节相同。
+- 把 `src-tauri` 整体切回合并基线 `8f59c034f` 后,同一条命令在本机失败在同一断言(`second llm request: Timeout`)。
+- 本机实测第二个 follow-up 请求耗时 **5.18s / 4.87s**(两次),而测试预算 2s:该断言在本机裕量不足。master run 2979(lane 2 shard 3)也因另一条并发用例失败,属同一类时间预算抖动。
+- 结论:既有测试时间预算问题,不在本 PR 内顺手修。
+
+## 验证步骤(Windows 侧执行清单)
+
+准备:切到本里程碑分支,`npm ci`(需装上 `@openai/codex-win32-x64`),确认 `spacetime --version` 与 `server-rs` 锁定版本一致(仅本地 dev 需要)。
+
+1. **准备步骤单独跑(不打包)**
+ - `npm run agc:bundled-resources:prepare -- --target x86_64-pc-windows-msvc`
+ - 预期:一行汇总日志;`src-tauri/resources/plugins/agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe`、`agc-godot-editor/native/gdextension/` 下的扩展在位。
+ - Cocos payload 只在 injection 构建下交付(与迁移前一致):加 `--features=cocos-editor-execute,unity-editor-execute,godot-editor-execute,cocos-editor-injection` 再跑一次,确认 `agc-cocos-editor/native/payload/cocos-editor-bridge.dll` 同时出现在插件工作区与随包目录。
+ - 再跑一次:预期全部「命中缓存」,且 `resources/**` 的文件时间戳不变。
+2. **构建期不再写随包资源**
+ - 取 `src-tauri/resources` 全量快照(相对路径/大小/mtime/sha256)→ `touch apps/ai-game-creator-shell/src-tauri/build.rs` → 再 `cargo build` → 两次快照必须逐项一致。
+3. **构建新鲜度**:源码不变时连续两次 `cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,第二次应为秒级 `Finished`,且不再出现 `Compiling genarrative-ai-game-creator-shell`。
+4. **打包一致性**:出一次 Windows 安装包,核对包内 `plugins/` 下三种编辑器分支产物的路径与 sha256 与迁移前一致;`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --no-run` 必须通过(会触发只读校验,所以要先跑过准备步骤)。
+5. **客户端启动**:进入 Unity/Godot/Cocos 编辑器分支各一次,确认对应 helper/扩展被加载,没有「缺少组件」类提示。
+6. **边界(负例)**
+ - 删掉 `plugins/agc-unity-editor/dotnet/publish/` 且让工具链不可用后打包:准备步骤必须给出明确失败原因(缺工具链/缺产物),而不是静默产出缺组件的包。
+ - 删掉 `target/agc-resource-staging.json` 再跑准备步骤:预期重新生成,产物内容不变(丢缓存只多一次哈希)。
+ - 删掉 `plugins/agc-unity-editor/dotnet/publish/win-x64/.agc-source.sha256` 后重跑准备步骤:预期重新执行 dotnet publish,而不是复用旧产物。
+ - 手工改 `resources/**` 一个字节后 `cargo build`:只读校验必须失败(该规则覆盖 source 派生内容;prepared 产物只查存在性,见排障经验)。
+
+记录:把每步命令、关键输出与结论贴回本里程碑或对应 PR;未通过项回到主规范 §9 记为未决问题。
+
+## 证据要求
+
+- 自动化:编辑器分支产物的摘要对比、平台门槛用例、配置门禁与包内资源门禁。
+- 运行时:Windows 上一次完整打包与一次客户端启动,确认编辑器分支产物被读取。
+- 边界:缺少外部工具链、缺少组件、非目标平台三种情形下的失败与跳过语义。
diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md
new file mode 100644
index 000000000..9fb199b27
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】AGC随包资源改由校验器读入-2026-09-26.md
@@ -0,0 +1,51 @@
+# 【里程碑】AGC 随包资源改由校验器读入
+
+| 字段 | 值 |
+| ----------- | ----------------------------------------------------------- |
+| Version | 1.0 |
+| Status | completed(2026-09-27) |
+| Date | 2026-09-26 |
+| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` |
+
+## 已定决策(2026-09-27)
+
+- **实现形态:混合**——生成归 Node(`scripts/prepare-bundled-resources.mjs`),声明与校验归 Rust。依据与对比见主规范 §4.8。
+- **单一声明**:`build_support/package-layout.json` 是唯一人工声明;Rust 侧使用由 `scripts/check-package-layout.mjs` 生成的编译期常量(`package-layout.generated.rs`),门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链;运行期 `codex_bundle.rs` 的公开接口与取值不变。
+- **校验收口边界**:本里程碑对随包 Codex 目录做全量校验(清单 schema/平台/版本、文件集合、逐文件摘要、第三方声明、可执行位、白名单外文件);插件随包目录只校验必需组件与符号链接。插件产物的逐文件摘要校验在 M2 由准备步骤写入树内清单后启用——M1 期间构建脚本仍整体重建 `resources/plugins`,树内清单会被清掉。
+- **独立验证入口**:`AGC_SKIP_RESOURCE_STAGING=1` 让构建脚本只跑只读校验、跳过写入分支。
+
+## 目标
+
+构建脚本不再需要「自己写随包资源」才能成立:在约定目录已有合规资源时,构建只做只读校验并通过;校验失败时给出明确原因并拒绝继续,而不是静默重新生成。
+
+## 范围
+
+- 随包资源的合规性判定:平台目录存在、清单 schema 与平台一致、逐文件摘要一致、必需组件齐全、版本与上游锁定一致。
+- 资源生成能力的可复用化:同一份布局与摘要校验能力既能被构建期校验使用,也能被准备步骤使用,不得出现第二份组件白名单。
+- 生成结果的稳定性要求:同一输入重复生成时,产物内容与文件时间戳不发生变化。
+
+## 不在范围内
+
+- 接入 dev 与发布入口(下一里程碑)。
+- 移除构建脚本里的资源写入分支。
+- 编辑器分支产物(Unity/Godot/Cocos)的生成方式与外部工具链调用。
+- 运行时资源解析顺序、完整性校验语义与打包配置里的资源映射。
+- Linux 产物支持。
+
+## 依赖与前置条件
+
+- 主规范第 4.3 与第 4.4 节的合同(准备步骤合同、构建脚本退化后的职责边界)。
+- 现有随包资源与清单已由当前实现产出,可用于校验回归。
+
+## 验收标准
+
+- [ ] 资源合规时,校验路径可独立运行并通过,不依赖构建脚本的写入分支。
+- [ ] 上游锁定版本、平台、逐文件摘要、必需组件四类不一致各自被拒绝,并给出可定位的原因。
+- [ ] 重复执行资源生成,产物内容与文件时间戳不变(幂等)。
+- [ ] 同一份布局与组件白名单只有一处声明,构建期校验与准备步骤共用。
+
+## 证据要求
+
+- 自动化:资源校验的通过/拒绝用例;同输入重复生成后目录快照对比(内容 + 时间戳)。
+- 运行时:本机在既有随包资源上运行一次校验与一次构建,确认资源被正常读取且构建行为与改造前一致。
+- 边界:目标平台不支持、上游缺失、摘要不匹配、目录被非本工具内容占用四种场景各自的失败输出。
diff --git a/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md
new file mode 100644
index 000000000..9486a877f
--- /dev/null
+++ b/docs/project-memory/plans/【里程碑】AGC随包资源生成接入dev与发布入口-2026-09-26.md
@@ -0,0 +1,48 @@
+# 【里程碑】AGC 随包资源生成接入 dev 与发布入口
+
+| 字段 | 值 |
+| ----------- | --------------------------------------------------------------- |
+| Version | 1.0 |
+| Status | in-progress(2026-09-27 起实施) |
+| Date | 2026-09-26 |
+| Parent Spec | `docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md` |
+
+## 目标
+
+随包资源在客户端开发与发布两条链上,都由启动/打包之前的准备步骤一次性生成;构建脚本不再承担生成职责,源码不变时构建不再重复编译。
+
+## 范围
+
+- 客户端开发的启动流程:在拉起客户端之前完成资源准备,命中缓存时不重写任何文件。
+- 发布打包流程:Windows 与 macOS 两条链在拉起打包工具之前完成资源准备,包括既有的运行时资源准备点。
+- 纯复制型资源(随包组件与插件工作区)的生成职责从构建脚本迁出。
+- 不打包场景(仅校验、不产包)的放行口径。
+
+## 不在范围内
+
+- 编辑器分支产物(Unity/Godot/Cocos)的生成方式与外部工具链调用(下一里程碑)。
+- 打包配置里的资源映射、包内资源门禁与安装包形态。
+- 运行时资源解析与完整性校验语义。
+- 构建脚本中与资源无关的既有职责(配置能力、提示词产物、元数据)。
+
+## 依赖与前置条件
+
+- 上一里程碑的验收通过:资源校验可只读通过、生成幂等、白名单唯一。
+- M1 交付的准备步骤与声明门禁已在位:`apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`(Codex 与插件两条纯复制路径,写临时目录后原子替换,命中缓存不重写)与 `npm run agc:bundled-resources:check`(已进 `agc:typecheck` 链)。本里程碑需要让准备步骤改为在 `resources/plugins` 内写入自己的清单,并停止构建脚本对该目录的整体重建,插件产物的逐文件摘要校验才能启用。
+- 开发与发布两条链在拉起客户端/打包工具之前都有明确可插入的准备阶段。
+- Windows 与 macOS 均需具备可验证的开发环境(两个平台各自验收)。
+
+## 验收标准
+
+- [x] 客户端开发启动一次成功:不再出现因资源变更而触发的重复构建,客户端与运行器进程稳定存活。(证据:清理 `AGC` dev 探针——`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次、主 crate 仅编译 1 次,app 起来后持续处理项目;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(401 InvalidSignature / 502 Bad Gateway)阻断,属既有本机环境问题。)
+- [x] 源码不变时连续两次构建,第二次为秒级完成;构建脚本声明的输入中不再出现随包资源路径。(证据:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒;强制构建脚本重跑后 `resources/codex` 与 `resources/plugins` 快照逐项不变。)
+- [ ] Windows 与 macOS 打包产物中的随包资源,与迁移前逐项一致(路径、内容摘要、可执行位)。(macOS 侧 `check-macos-bundle.mjs` 待打包验证;Windows 待 M3 归位三处构建期产物后复验。)
+- [x] 准备步骤连续执行两次不改变产物内容与时间戳;缺少准备步骤时,打包与启动以明确错误失败,而不是静默产出缺组件的包。(证据:准备步骤 10 条用例含幂等、上游缺失、上游元数据漂移与失败关闭;`AGC_SKIP_RESOURCE_STAGING=1` 在既有产物上只读通过;构建脚本校验缺失组件时 fail closed。)
+- [ ] 本机 Rust 门禁(会触发构建脚本的测试入口)与不打包构建路径仍然可用。(macOS 侧已验;Windows 的 `check:rust:shell` 待真机确认。)
+
+## 证据要求
+
+- 自动化:构建新鲜度日志、构建脚本输入清单、准备步骤幂等快照、包内资源门禁脚本结果。
+- 运行时:macOS 与 Windows 各一次客户端启动,确认随包组件被读取而非回退到外部安装。
+- 边界:缺少准备步骤、缓存命中、上游锁定变化三种情形下的行为。
+1
\ No newline at end of file
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 9454a20a0..b563d250e 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -9096,6 +9096,35 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 验证(真实上游 smoke,本地 dev DB):清空 `agc_model_catalog` 后启动 api-server → 日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=6`;登录后 `GET /api/llm/models` 返回同一批模型、`displayName` 即上游原名、默认项为排序后第一项;上游不可达/非 2xx 时启动只记录 error、AGC 接口 `503` 且目录保持未初始化;目录已存在时重启不重写。
- 边界(未验证/残留):上游在售模型超过 32 条时同步会失败(目录项上限未改);`qwen-image-3.0` 这类图像模型会一起进入目录,是否对 AGC 隐藏由 owner 在后台停用;混合版本期间未升级的 api-server 会把自己的 AGC 接口打到 `503`,module 与 api-server 必须同批发布/回滚。
+## 2026-09-27 AGC 随包资源改为「单一声明 + 准备步骤生成 + 构建期只读校验」
+
+- 背景:随包资源(内置 Codex CLI、插件工作区)由 `build.rs` 在构建期写入 `src-tauri/resources/**`,而这些路径同时被 tauri 配置的 `bundle.resources` 登记成构建输入,cargo 因此永远判 stale:Windows/macOS 每次构建重编主 crate(41–87 秒),macOS dev 反复 `Rebuilding application`、客户端起不来(issue #519)。三轮症状层修复(内容比对、权限跳过、`.taurignore`)都只减少写入次数,没有改变「构建期写被登记文件」这一结构。
+- 决策(形态:混合):准备步骤用 Node(复用 `stage-node-runtime.mjs` / `prepare-macos-codex.mjs` 的下载、`integrity`、临时目录 + rename 原子替换),布局与摘要校验留在 Rust(复用 `codex_bundle.rs` / `godot_bundle.rs`),运行期模块公开接口与取值不变。
+- 决策(单一声明):唯一人工声明是 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`(Codex 三元表与组件白名单、上游候选路径、第三方声明来源、插件随包子目录与跳过规则、平台与 feature 门槛)。Node 直接读该 JSON;Rust 读由 `scripts/check-package-layout.mjs` 生成的 `package-layout.generated.rs` 编译期常量(不解析 JSON、不引入生命周期妥协)。门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,同时校验声明自身不变量:目标唯一、`executable` 属于白名单、每个目标恰有一条第三方声明来源,且 `codex.version` 与应用锁定的 `@openai/codex` 一致。
+- 决策(校验与独立入口):`build.rs` 新增只读校验——Codex 目录校验清单 schema/平台/版本、文件集合、逐文件 sha256、第三方声明、可执行位与白名单外文件;插件目录校验必需组件与整树符号链接。`AGC_SKIP_RESOURCE_STAGING=1` 可跳过写入分支、只跑校验,用于在既有产物上单独验证校验路径。插件产物的逐文件摘要校验留到 M2(届时准备步骤在树内写清单,不再被构建脚本整体重建覆盖)。
+- 影响面:`apps/ai-game-creator-shell/src-tauri/{build.rs,build_support/**}`、`apps/ai-game-creator-shell/scripts/{check-package-layout.mjs,prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs}`、`apps/ai-game-creator-shell/package.json`、根 `package.json`、`.gitignore`、AGC 技术方案 §4.8/§8/§9、M1 里程碑规范与实施计划、开发运维文档。三份 tauri 配置的 `resources` 映射与包内路径不变。
+- 验证:`npm run agc:bundled-resources:check`;`npm run agc:bundled-resources:test`(9 passed,含幂等、上游缺失、非本工具目录、目标不支持、dry-run);`AGC_SKIP_RESOURCE_STAGING=1 cargo check --no-default-features --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 在准备步骤产物上通过;准备步骤产物与构建脚本产物逐文件一致(相对路径、大小、sha256);连续两次运行准备步骤第二次全部命中缓存,目录快照(含 mtime)不变。
+- 边界(未验证):准备步骤尚未接入 dev 与发布入口(M2);Windows 真机的构建新鲜度与打包未验证;Unity/Godot/Cocos 产物仍由构建脚本生成(M3);Linux 上五条 staging 与校验均为 no-op。
+
+## 2026-09-27 AGC 随包资源准备步骤接入 dev 与发布入口,构建脚本退出写入
+
+- 背景:M1 只交付了单一声明、准备步骤与只读校验,构建脚本仍在写随包资源,所以 macOS 的 `npm run agc` 仍会因 `resources/codex/mac-native` 被重写而反复重建、`cargo build` 每次重编主 crate(41–87 秒)。
+- 决策(接线):`start-tauri-dev.mjs` 在前端与配套后端就绪之后、spawn Tauri CLI 之前调用准备步骤(命中缓存零写入,日志前缀 `[ai-game-creator-shell]`);`build-release.mjs` 的 `runTauriBuild` 与既有 `stageRuntime(target)` 并列调用 `stageBundledResources(target)`,`tauri build --no-bundle` 仍不强制 staging。两处都保留依赖注入,便于入口测试断言调用顺序与 no-bundle 行为。
+- 决策(写入边界,声明新增 `origin`):`origin: source`(Codex 组件、插件 `src`/`panels`/`skills`/`native/payload`)由准备步骤写;`origin: build`(Unity `dotnet/publish/win-x64`)与外部工具链产物(Godot `native/gdextension`、Cocos payload)由构建脚本在产物生成后写。构建脚本删除 codex 与插件白名单的写入分支及 `stage_plugin_file`/`copy_plugin_tree`/`copy_plugin_file`,改为 `stage_build_generated_plugin_payloads`。
+- 决策(契约收口):插件随包工作区改为与仓库源码逐文件比对(清单 + 逐文件 sha256 + 整树符号链接,构建期派生内容只查存在性),实现移入 `build_support/package_layout.rs` 以复用单测;上游原生包元数据(layoutVersion/version/target/entrypoint/resourcesDir/pathDir)改由准备步骤按声明校验,`build_support/codex_package_metadata.rs` 因失去调用方而删除。准备步骤改为同步实现(全部是本地同步 IO),入口可直接调用而无需子进程。
+- 影响面:`apps/ai-game-creator-shell/scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,start-tauri-dev.mjs,build-release.mjs,build-release.test.mjs}`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`src-tauri/build.rs`、`build_support/{package_layout.rs,package-layout.json,package-layout.generated.rs}`(`codex_package_metadata.rs` 删除)、`src/agent/codex_cli.rs`、技术方案 §4.9、M1/M2 里程碑与运维文档。
+- 验证:`cargo build --no-default-features` 连续三次 0.69 / 0.22 / 0.22 秒全程 fresh;强制构建脚本重跑(`touch build.rs`)后 `resources/codex` 与 `resources/plugins` 的快照(相对路径/大小/mtime/sha256)逐项不变;`cargo test --no-default-features … package_layout` 36 passed;`node --test scripts/prepare-bundled-resources.test.mjs` 10 passed(含上游元数据漂移被拒);`node --test scripts/build-release.test.mjs` 39 passed(含 `stage → bundled → build` 顺序与 no-bundle 不 staging);`npx vitest run tests/start-tauri-dev.test.ts` 12 passed(含「准备步骤先于 CLI 启动」)。
+- 边界(未验证):Windows 真机未验证,且 Unity publish 目录、Godot gdextension、Cocos payload 仍是构建期写入,Windows 构建新鲜度要等 M3 归位;完整 `npm run agc` 在本机被 SpacetimeDB `Pre-publish check`(先后 401 InvalidSignature 与 502 Bad Gateway,属既有本机环境问题)阻断,未跑通整条 dev 启动链路。
+
+## 2026-09-27 AGC 编辑器分支产物归位:构建脚本彻底退出写入
+
+- 背景:M2 之后构建脚本仍生成 Unity publish 目录、Godot gdextension 与 Cocos bridge payload,这三处写入落在 `resources/plugins/**`(`bundle.resources` 映射目录),Windows 上仍会触发每次重编,`.taurignore` 的 staging 条目也还不能删。
+- 决策(声明扩展):`subdirectories` 新增 `origin: prepared`;`libraryStaging` 增加 `prepare` 与 `files`;新增 `nativePayloads` 与 `plugins.prepareSteps`(程序类型、工作目录、指纹、必需产物)。Godot 随包文件清单改由声明提供——`godot_bundle::BUNDLE_FILES` 从生成的编译期常量取值,不再各写一份。
+- 决策(准备步骤执行器):准备步骤按声明运行 `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File build.ps1`(Unity/Godot,Godot 额外移除 `PSModulePath`)与 `cargo build -p cocos-editor-bridge --target … --features windows-injection`;内容指纹命中且必需产物齐全时零写入;命令执行器可注入,便于在 macOS 上用假执行器覆盖调度、指纹与失败关闭逻辑。
+- 决策(构建脚本瘦身):删除 `prepare_unity_editor_helper`、`prepare_godot_editor_extension`、`stage_cocos_editor_payload`、`stage_build_generated_plugin_payloads` 及其辅助函数(build.rs 415 → 193 行),只留只读校验,并新增「已准备产物存在性 + Godot 随包库深度校验」;`AGC_SKIP_RESOURCE_STAGING` 开关随写入分支一并删除;两份只含 staging 条目的 `.taurignore` 删除。
+- 影响面:`apps/ai-game-creator-shell/src-tauri/build_support/{package-layout.json,package-layout.generated.rs,package_layout.rs,godot_bundle.rs}`、`src-tauri/build.rs`、`scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,check-package-layout.mjs,build-release.mjs}`、两份 `.taurignore`、技术方案 §4.9/§8、M3 里程碑、运维文档、决策日志与排障经验。
+- 验证:准备步骤 13 条用例通过(含三类准备步骤调度、指纹跳过、缺产物失败关闭、幂等与失败关闭);`npm run agc:bundled-resources:check` 通过;`cargo check --no-default-features` 通过(构建脚本仅剩只读校验,且不再出现在随包资源的写入路径上)。
+- 边界(未验证):Windows 真机未验证——powershell/cargo 两条命令路径、Unity/Godot/Cocos 产物归位、包内容一致性与客户端加载,需按 M3 里程碑的验收清单在 Windows 上确认。
## 2026-09-24 命令入队化与待发消息队列归宿主:放行归 Thread Manager,CLI 直连入口退役
- 决策(词表):「接单 / 拒单」退役,命令边界的成功与失败改叫「入队 / 入队失败」;旧「接单」的语义角色
@@ -9275,7 +9304,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 列表排序(新增能力,同日):`AdminListPanel` 增加 `sortable` / `sortValue` / `sortDescription`——列头渲染与表查询一致的排序按钮(`admin-table-sort-button` + 升/降/双向图标),点击按「正序 → 倒序 → 不排序」循环,同步 `th[aria-sort]`,排序是稳定排序(值相同保持服务端原顺序)。已接入**一次取全**的 5 个列表:邀请码列表、操作记录、灰度 Gate 列表、可配置开关、任务配置列表(这些接口没有分页,前端排序语义正确)。
- 列表实现全量收口(同日续做):把剩余 **15 处** `children` 形态列表全部迁到 `columns` + `renderRow`(表体 JSX 原样搬进 `renderRow`,外壳/状态/分页槽位不变):`AdminRedeemCodePage`(2)、`AdminRechargeProductPage`、`AdminProjectSnapshotsPage`、`AdminErrorReportsPage`、`AdminAgcTrackingPage`、`AdminGameDistributionReviewPage`、`AdminGameManagementPage`(主列表 + 版本历史)、`AdminUserDetailDialog`(充值订单)、`AdminRechargeOrderPage`、`AdminAgcModelsPage`、`AdminEditorAssetQueryPage`、`AdminEditorShowcaseReviewPage`、`AdminAgcTemplatesPage`。迁移后全仓统计:`AdminListPanel` **26 处 / 21 个文件**,其中 **24 处 columns 形态**(含 2 处弹窗内 `surface="plain"`),仅剩 2 处非表格列表仍是 children 形态(账号管理卡片列表、账号配置的键值列表);`` 作为 children 的写法已归零。可排序列累计 **45 个**(新增写入 `AdminAgcTemplatesPage` 列头 4 个:模板/引擎版本/包大小/状态)。
- 迁移中顺带处理的形态差异:① `AdminErrorReportsPage` 的详情弹窗原本嵌在列表面板里,随表格一起搬到面板外(遮罩是 fixed,视觉不变);② `AdminAgcModelsPage` 的工具栏与状态行改为走 `toolbar` 槽位(列表面板按 toolbar → 加载行 → 表格渲染,顺序与原来一致);③ `AdminAgcTemplatesPage` 的列表从共享 `ui/Table`(`genarrative-ui-table*`)换成 `AdminTable`,与其它页签视觉统一——该页唯一的 ui Table 只剩「上传模板」弹窗里的待上传队列表(有逐行校验/进度状态的编辑态表格,未纳入列表组件,属有意保留)。
-- 排序能力边界(重要):后端目前**只有** `GET /admin/api/database/tables/{table}/rows` 与 `GET /admin/api/external-api-keys` 接受 `sortColumn`/`sortDirection`;其余列表在 handler 里写死顺序(例如埋点数据固定 `occurred_at desc`、错误报告按时间倒序)。因此埋点数据、客户端埋点、错误报告、项目工程、充值订单这类**分页明细列表暂时不能排序**——只在前端排「当前页」会给出错误结论,必须给对应接口加排序参数(DTO + handler + `adminApiTypes` + 契约/测试)后前端复用同一列头。账号管理是卡片列表(`children` 形态),本轮未加排序。
+- 排序能力边界(重要):后端目前**只有** `GET /admin/api/database/tables/{table}/rows` 与 `GET /admin/api/external-api-keys` 接受 `sortColumn`/`sortDirection`;其余列表在 handler 里写死顺序(例如埋点数据固定 `occurred_at desc`、错误报告按时间倒序)。埋点数据、客户端埋点、错误报告、充值订单等分页明细不能只在前端排「当前页」,必须先让对应接口支持全局排序,再复用列头。项目工程采用独立的主动全量读取按钮:当前渠道完整读取成功后按同步时间降序、用户/项目 ID 升序,本地分页;原目录接口及上传/下载契约保持不变,不新增 OSS 索引或数据库表。读取限制为 200 页、10,000 个唯一项目、60 秒,可取消;失败保留原列表,切换渠道/令牌取消请求并销毁临时集合。账号管理是卡片列表(`children` 形态),本轮未加排序。
- 本地假数据补齐:`scripts/admin-web-fake-api.mjs` 新增 `agc-models`、`game-distribution/games`、`profile/recharge-products`、`profile/redeem-codes`、`profile/tasks`、`agc/tracking-events` 夹具,并对 `profile/recharge-orders` 按 `AdminRechargeOrderEntryPayload` 的真实字段补齐(缺字段会让页面抛 `Cannot read properties of undefined`——后台没有 error boundary,整页会白屏)。**已知缺口**:充值管理页仍缺一处夹具字段(页面读 `undefined.find`),本轮没能出图;该页自身 21 条单测通过、类型检查通过,仅缺截图。
- 排序验证:共享组件新增用例覆盖「正序 / 倒序 / 取消 + aria-sort + 行序」;现场实测邀请码列表按「创建」排序:正序 `EXPIRED-CODE, BETA-CREATOR, TAONIER-VIP-2026`、倒序翻回 `TAONIER…, BETA…, EXPIRED…`,`th[aria-sort]` 依次为 `ascending` / `descending`。
- 边界(未完成):未跑生产后台构建与真实后台接口联调(截图用假数据);`apps/admin-web` 目前没有 error boundary,任何接口形状不符仍会把整页渲染清空(本次只加固了 `AdminAgcTrackingPage` 一处,其余页面同类写法未逐个排查);`AdminAgcTemplatesPage` 的列表面板是本次新增的外壳(原页面没有面板),视觉上多了白底卡片。
@@ -9293,3 +9322,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 后台 AGC 模型目录新增 `agentMode`,只允许 `codex` / `cc`,缺失的历史目录按 `codex` 兼容;模型选择返回的公开摘要同步携带该绑定。
- 客户端把后台 `codex` 映射到现有 Codex app-server,把 `cc` 映射到独立 Claude Code CLI adapter;不通过替换 Codex JSON-RPC 可执行文件实现。
- Claude Code 只使用隔离环境和 AGC loopback MCP,禁用原生工具;取消通过独立 Direct 回合进程树回收处理。Codex、provider 和自定义 Responses 链路保持原路径。
+
+## 2026-09-30 合入 master 时把 Claude Agent SDK sidecar 归位到随包资源准备步骤
+
+- 背景:master `fb130d184` 新增 `cc` 执行模式与 Claude Agent SDK sidecar,sidecar 的 staging 写在 `build.rs`(构建期 `remove_dir_all` + 从 `node_modules/@anthropic-ai/**` 复制 `resources/claude-agent`),同时把 `resources/claude-agent` 映射进**基线** `tauri.conf.json`。本分支的 M1–M3(issue #519)已把「构建期写随包资源」定性为结构问题,合入时必须按同一套架构落地,不能把写入分支带回来。
+- 决策(归位实现):新增声明 section `claudeAgent`(锁定版本、资源目录、sidecar 入口源码、上游 SDK 包与平台原生运行时包的目标表、复制跳过规则)。Node 准备步骤整目录原子替换 staging,缓存 key = `layoutVersion + target + 声明版本 + 入口摘要`,命中即零写入;`build.rs` 只读校验:入口与仓库源码逐字节一致、SDK 与原生运行时 `package.json` 版本等于声明、原生运行时在位(unix 还要求可执行位)、随包目录里没有白名单外的文件。
+- 决策(版本单一真源):`CLAUDE_AGENT_SDK_VERSION` 由声明生成;`claude_code_cli.rs` 的 sidecar 身份串改用 `cargo:rustc-env=AGC_CLAUDE_AGENT_SDK_VERSION`,门禁断言声明版本等于 `apps/ai-game-creator-shell/package.json` 与 `agent-sidecar/package.json` 锁定的 `@anthropic-ai/claude-agent-sdk`。
+- 决策(平台映射从基线配置移到平台配置):`resources/claude-agent` 由 `tauri.conf.json` 移入 `tauri.windows.conf.json` 与 `tauri.macos.conf.json`。基线配置同时服务 Linux——CI 只在那里编译壳 crate 且按设计不装 npm 依赖,而 `tauri-build` 会把 `bundle.resources` 的每个路径拷进 target、缺失即失败;留在基线等于要求一份只有 Windows/macOS 才产出的资源。`check-config.mjs` 增加「基线不得声明 `resources/**`」的守卫。
+- 决策(删掉自带的清理逻辑):不保留 master 的 `prune_stale_codex_components`。准备步骤对 staging 单元整目录原子替换已经清掉旧布局残留,构建期另有「白名单外的文件」断言;构建脚本不再删任何人的文件。
+- 影响面:`src-tauri/build_support/{package-layout.json,package-layout.generated.rs,package_layout.rs}`、`src-tauri/build.rs`、`scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,check-package-layout.mjs,check-config.mjs}`、三份 tauri 配置、`src/agent/claude_code_cli.rs`、技术方案 §4.5/§4.9、排障记录。
+- 验证:`npm run agc:bundled-resources:test`(18 passed,含 sidecar 归位、跳过规则、上游缺失、版本漂移、目录被占);`npm run agc:bundled-resources:check`、`check-config.mjs`、`cargo test --bin genarrative-ai-game-creator-shell package_layout::tests`、`cargo check --no-default-features`、`cargo fmt --check`、`check:encoding`、eslint/prettier 全部通过;Windows 真机准备步骤 staging 24 个文件(含 243MB `claude.exe`)后 `cargo check` 不再出现构建期写入。
+- 边界(未验证):macOS 真机的 sidecar 加载与 `check-macos-bundle.mjs` 包内容门禁未在本机验证;Linux 门禁按新配置不再要求 sidecar 资源,需 CI 实跑确认转绿。
+- 关联:issue #519、master `fb130d184`、`docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md`、CI run 3083。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index 5c093965c..e47120989 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -2,6 +2,14 @@
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
+## 2026-09-30 构建期 staging 撞上不装 npm 依赖的 Linux 门禁:AGC 壳 Rust lane 全红
+
+- **现象**:`Project CI` 的 AGC 壳 Rust 三条 lane(`npm run check:native-shells:agc-rust-shard-*`)在 `fb130d184` 之后全部失败,日志只有 `error: failed to run custom build command for genarrative-ai-game-creator-shell` 与 `thread 'main' panicked at build.rs:65:28: Claude Agent SDK 缺失;请先执行 npm ci`(run 3083 / job 17521 实测,1 分钟即失败)。
+- **原因**:Claude Agent SDK sidecar 的 staging 写在 `build.rs`(构建期 `remove_dir_all` + 从 `node_modules/@anthropic-ai/**` 复制),而这三条 lane 按设计**不装 npm 依赖**(`scripts/project-ci-workflow.test.ts` 的 `jobsWithoutNpmInstall` 显式允许它们没有 `node_modules`),构建脚本一跑就必 panic。同一批改动还把 `resources/claude-agent` 映射进**基线** `tauri.conf.json`:`tauri-build` 会把 `bundle.resources` 的每个路径拷进 target,缺失即 fail(`tauri-utils` 的 `ResourcePathNotFound`),所以即使绕开 panic,Linux 也会在资源解析处再红一次。
+- **处理(现行口径)**:随包资源一律由准备步骤在 `tauri dev|build` 之前 staging,`build.rs` 只读校验(sidecar 走声明 section `claudeAgent` + `scripts/prepare-bundled-resources.mjs`);平台专属资源只允许出现在 `tauri..conf.json`,基线 `tauri.conf.json` 里不得出现 `resources/**`——基线同时服务不产出客户端包的 Linux,`check-config.mjs` 已加该守卫。
+- **判据/取证**:`node --test apps/ai-game-creator-shell/scripts/prepare-bundled-resources.test.mjs`、`node apps/ai-game-creator-shell/scripts/check-config.mjs`;Linux 侧判据是三条 AGC Rust lane 转绿且构建期不再出现 `Claude Agent SDK 缺失`。
+- **关联**:`apps/ai-game-creator-shell/src-tauri/build.rs`、`apps/ai-game-creator-shell/scripts/{prepare-bundled-resources.mjs,check-config.mjs}`、`apps/ai-game-creator-shell/src-tauri/{tauri.conf.json,tauri.windows.conf.json,tauri.macos.conf.json}`、`.gitea/workflows/project-ci.yml`、CI run 3083。
+
## 2026-09-30 Jenkins release 渠道环境污染 AGC 构建单测
- **现象**:Jenkins `Genarrative-Agc-MacOS-Build` 的 release lane 在执行 `build-release.test.mjs` 时,`release stages Node before Tauri...` 用例报 `Cannot read properties of undefined (reading 'nsis')`。
@@ -112,6 +120,23 @@
- **leader 卡死的兜底**:闸门只有 follower 的有界等待(60s),若提权子进程真的挂死(`Start-Process -Wait` 无超时),`leader_deadline`(5 分钟)之前该 key 一直被占住,之后新调用会接管并按新 leader 执行;被接管后旧 leader 迟到的结果按令牌丢弃,不会覆盖接管者。`clear_game_creator_acl_elevation_denials` 只清「被拒绝」记忆,不清理 running。
- **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。
+## 2026-09-29 随包资源的编译产物摘要不可复现,且准备步骤与应用构建共用同一输出路径
+
+- **摘要不可复现**:同一 source / feature / profile / target 连续构建的 `cocos-editor-bridge` payload 摘要不同(除 PE `TimeDateStamp` 外还有 RSDS GUID 等 22 字节差异),所以「与迁移前逐项一致」只能对**源码派生物**(JS/HTML/JSON/license/notice,逐字节比对)、**.NET publish 产物**(Unity helper 跨两次重新发布逐字节一致)和**命中工具链内部缓存的产物**(Godot 走 `buildId` 早退,不重链)成立。核对打包一致性时不要用编译产物的 sha256 判回归,改比路径集合 + 导出面(`DllMain`、`cocos_editor_bridge_bootstrap_source`)+ 源码派生物摘要。
+- **共用输出路径**:准备步骤的 `cocos-bridge-build` 用 `cargo build -p cocos-editor-bridge --features windows-injection`,而应用构建带的是 `windows-bootstrap + windows-injection`(`cocos-editor-injection` 的闭包),两个单元写同一个 `target///deps/cocos_editor_bridge.dll`。后构建的单元覆盖先构建的产物时,准备步骤的候选查找会取到「上一次遗留的另一个单元」,交替构建还会多一次重链。要改就从这里改:让准备步骤用独立 target 目录,或与应用的 feature 集对齐。
+- **验证方式**:`runTauriBuild`(`scripts/build-release.mjs`)+ `--bundles nsis`,再 `7z x` 解包比 `plugins/**`;准备步骤连续三次复跑要求 `resources/plugins` 的 32 个文件内容与 mtime 全不变。
+
+## 2026-09-27 随包资源的写入方按产物来源分界:源码派生直接复制,需工具链的先由准备步骤产出
+
+- **写法**:新增随包内容先判断来源——能从仓库源码复制就写进 `build_support/package-layout.json` 的 `subdirectories`(`origin: source`);需要外部工具链或同一次 cargo 构建才能产出的,写成 `origin: prepared` / `libraryStaging` / `nativePayloads`,并在 `plugins.prepareSteps` 里声明要跑的程序、工作目录、指纹与必需产物——**不要写进构建脚本**(构建脚本自 M3 起只做只读校验,不再生成任何随包资源)。
+- **校验口径**:`origin: source` 的内容在构建期会与仓库源码逐文件比对(插件清单 + 逐文件 sha256 + 整树符号链接),手改这部分会被 `cargo build` 直接拒绝;`origin: prepared` 只查存在性(Godot 随包库额外跑 `godot_bundle::validate`),手改 prepared 产物不会被拒,要改就改准备步骤的来源或声明。
+- **准备步骤指纹**:声明了指纹的步骤(Unity)命中后不会重跑工具链,改 `plugins/**` 源码即失效;指纹戳文件(`publish/win-x64/.agc-source.sha256`)删掉只会多跑一次构建。`resources/plugins` 由准备步骤拥有,不要手工往里放文件。
+
+## 2026-09-27 AGC 随包资源的布局只能改声明文件,生成物由门禁锁死
+
+- **现象**:直接编辑 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs`,或另写一份组件白名单,`npm run agc:typecheck`(链内含 `npm run agc:bundled-resources:check`)会立刻失败并报「随包资源声明与 Rust 常量不一致」。
+- **正确做法**:改 `build_support/package-layout.json`,运行 `npm run agc:bundled-resources:sync` 重新生成;改布局同时递增 `layoutVersion`(参与准备步骤的缓存 key)。声明里的 `codex.version` 必须与应用锁定的 `@openai/codex` 一致,门禁会对照 `apps/ai-game-creator-shell/package.json` 校验。
+- **边界(M1 完成时)**:准备步骤 `scripts/prepare-bundled-resources.mjs` 尚未接入 dev / 发布入口,`npm run agc` 仍由构建脚本 staging;构建脚本当前既写资源又做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可只跑校验。构建脚本重建 `resources/plugins` 时会整体删除该目录,所以插件侧的准备步骤清单要等 M2 接管写入后才成立,插件目录现在只校验必需组件与符号链接。
## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析
- **现象**:AGC 对话里代码块解析错位——引言行被当成代码渲染(`…实现细节(game.js):```js`),或者代码块收不住、把后面的正文一起吞进去(`… return centerOn(projection); }````)。文本本身「看起来没问题」,容易被当成渲染器坏了。
@@ -2976,9 +3001,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 现象:Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`、`sccache: error: Timed out waiting for server startup`,或 `sccache: caused by: Failed to send data to or receive data from server / Failed to read response header / failed to fill whole buffer`;真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
- 原因:环境、Jenkinsfile 或 `server-rs/.cargo/config.toml` 启用了 `sccache` wrapper,但当前 agent 没有可执行的 `sccache`、PATH 中 shim 损坏,或本地 sccache server/client 通道状态损坏。Windows 本机若配置了 `SCCACHE_OSS_*`,sccache daemon 冷启动会先经 OSS/本机代理完成缓存读写检查,再监听 `127.0.0.1:4226`;代理或 OSS 链路慢时,Cargo 的 `sccache rustc -vV` 可能先超时。
-- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 在 Windows 下限时执行真实 wrapper 探测 `sccache rustc -vV`,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入空 wrapper,回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。
+- 处理:保留 `server-rs/.cargo/config.toml` 的 `rustc-wrapper = "sccache"`;本地 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` 在 Windows 下限时执行真实 wrapper 探测 `sccache rustc -vV`,成功才启用 sccache,缺少命令、daemon 启动超时或 wrapper 返回非零时立即给 Rust 子进程注入空 wrapper,回退到直接 rustc,避免损坏的 daemon 阻断启动;显式设置的非 sccache 自定义 wrapper 会被保留。`npm run agc` 的 Tauri Cargo 原先直接继承启动器环境,用户级 `~/.cargo/config.toml` 的 `rustc-wrapper` 会在这里生效并复现同一故障(表现为 `failed to run rustc to learn about target-specific information`,AGC 前端与配套后端已经起来、只有 Tauri 客户端退出);现在 `start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 的 `buildLocalRustProcessEnv`,把两个 wrapper 变量显式写进子进程环境——空环境变量同样能覆盖 Cargo 配置文件里的 wrapper,不能只依赖「本机没配 sccache」。Windows 本机优先在 `%APPDATA%\Mozilla\sccache\config\config` 写入 `server_startup_timeout_ms = 60000`,拉长 client 等待 daemon 完成 OSS 初始化的时间,然后删除 `server-rs/target/.rustc_info.json` 里缓存的失败探测结果并重跑原始 Cargo 命令。冷启动验证优先用 `sccache --stop-server`,不要在另一个 `cargo` / `rustc` 仍在编译时 `taskkill /F /IM sccache.exe /T`,否则 proc-macro crate 可能被打断并表现为 `serde_derive` / `spacetimedb-bindings-macro` 的 `sccache ... exit code: 1`。若只做临时排障,可在 Git Bash 中执行 `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build ...`,或在 PowerShell 用 `cargo check -p api-server --config "build.rustc-wrapper=''"` 一次性绕过 wrapper;生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`。
- 验证:`rustc -Vv` 能输出版本;本地 `npm run dev` 能完成 `spacetime publish`、`api-server` `/healthz`、主站 Vite 和后台 Vite 启动;冷启动后原始 `cargo check -p api-server` 和 `cargo check -p spacetime-module` 能通过;`sccache --show-stats` 显示 `Cache location oss, name: genarrative-sccache`,证明原始 Cargo/Jenkins 路径仍可使用 sccache/OSS 缓存;Jenkins 日志出现“未找到可用 sccache,改用 rustc 直接构建”后仍继续真实构建。
-- 关联:`scripts/dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
+- 关联:`scripts/dev.mjs`、`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/technical/SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本
@@ -6082,7 +6107,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **现状(正确)**:`src/services/appUpdate.ts` 的 `installAppUpdate` 先 `await update.downloadAndInstall(...)`、成功后才清空待装更新并 `restartAppAfterUpdate()`;失败时保留待装更新,重试走同一条链路。
- **判据**:`apps/ai-game-creator-shell/tests/appUpdate.test.ts` 新增「签名校验失败时拒绝安装、不重启进程,并保留待装更新供重试」——插件抛 `signature verification failed` 时断言 ①错误原样上抛 ②`restart_agc_app` 未被调用 ③再次安装仍会走插件调用并在成功后重启。变异验证:把 `restartAppAfterUpdate()` 挪到 `await` 之前,该用例立即以 `expected "spy" to not be called with arguments: [ 'restart_agc_app' ]` 变红。
- **边界**:真正的验签与临时文件清理都在官方插件原生实现里,本地只能证明"客户端不把失败当成功",真机安装闭环仍需已发布包与真实设备。
-- **顺带记一条环境陷阱(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` 被清掉、六个声明组件与清单/声明保留。
+- **顺带记一条环境陷阱(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」。根因是构建脚本只按布局拷贝、从不清理目录,旧布局的组件会永久留在随包资源目录里。**现行口径(2026-09-30 起)**:随包资源改由准备步骤整目录原子替换(`scripts/prepare-bundled-resources.mjs` 的 `stageAtomically`),旧布局残留随替换消失;构建脚本只剩只读校验,遇到白名单外的文件会立即失败,所以「本机留着旧组件」最多表现为一次可读的失败,不会再静默随包。(2026-09-28 加的构建期 `prune_stale_codex_components()` 已随 M3 退役,实现不再存在。)
## 2026-09-29 Vite dev 冷启动会让 web E2E 的首个 goto 超时,别当成页面回归
diff --git a/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md
new file mode 100644
index 000000000..6a6f157bd
--- /dev/null
+++ b/docs/technical/【技术方案】AGC随包资源staging归位-2026-09-26.md
@@ -0,0 +1,225 @@
+# AGC 随包资源 staging 归位技术方案
+
+状态:待评审(方案草案,评审通过前不进入实现)
+日期:2026-09-26
+范围:AGC 客户端(`apps/ai-game-creator-shell`)随包资源的生成、校验与打包链路
+关联:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md`
+
+## 1. 一句话目标
+
+把「随包资源」从 build script 的**产物**改回它的**输入**:由准备步骤在 `tauri dev|build` 之前一次性 staging,`build.rs` 退化为校验者,构建期不再向 `src-tauri/resources/**` 写任何文件。
+
+## 2. 目标与非目标
+
+### 2.1 目标
+
+1. `build.rs` 的输入集合里不再包含任何「本次构建会写」的文件,`cargo` 在源码不变时稳定 fresh。
+2. Tauri dev 的文件监听不再因为 staging 变更重启 `cargo run`(macOS 与 Windows 一致,不依赖 `.taurignore` 兜)。
+3. staging 与上游版本的绑定可验证:版本、平台、逐文件摘要由校验器 fail closed 检出,不会静默发旧二进制。
+4. 打包产物内容与当前口径一致(三份 tauri 配置的 `resources` 映射与包内资源门禁不变)。
+5. 删除症状层补丁(整目录重建的规避、为绕开自触发而加的 `.taurignore` staging 条目)。`prune_staging`、`STAGED_*` 一类命名只出现在未合入的症状层补丁里,主线从未有过,不需要清理。
+
+### 2.2 非目标
+
+1. 不改发布渠道、版本号机制、签名与上传流程。
+2. 不改运行时资源解析顺序与完整性校验语义(`codex_cli.rs`、`plugin_host.rs`、`environment_check.rs`、`editor_adapters.rs` 保持行为)。
+3. 不统一 `server-rs` 与 `src-tauri` 两个 workspace,不改 target 布局。
+4. 不为 Linux 增加客户端产物(Linux 上五条 staging 全为 no-op,见 §3.6)。
+5. 不改 Windows/macOS 之外的平台支持面。
+
+## 3. 现状与证据
+
+### 3.1 build script 目前负责五条 staging
+
+`apps/ai-game-creator-shell/src-tauri/build.rs` 的 `main()` 前段依次执行(`build.rs:225-229`):
+
+| # | 步骤 | 动作类型 | 平台门槛 | 目标路径 |
+|---|---|---|---|---|
+| 1 | `stage_bundled_codex_cli` | 纯复制(内容+权限比对) | Windows / macOS | `resources/codex/**` |
+| 2 | `prepare_unity_editor_helper` | **外部工具链**(`powershell -File build.ps1`,需 .NET 10 SDK + VS C++ x64) | Windows + unity feature | `resources/plugins/agc-unity-editor/**` |
+| 3 | `prepare_godot_editor_extension` | **外部工具链**(`powershell -File build.ps1`,需 CMake ≥ 3.25 + VS17 2022 + Python 3) | Windows + godot feature | `resources/plugins/agc-godot-editor/native/gdextension/**` |
+| 4 | `stage_plugin_workspace` | 复制 + 清残留 | Windows / macOS | `resources/plugins/**` |
+| 5 | `stage_cocos_editor_payload` | 复制(同一 cargo 构建产出的 cdylib) | Windows | `resources/plugins/agc-cocos-editor/native/payload/**` |
+
+(Prompt Bundle 生成代码写 `OUT_DIR`,属正确形态;Node 运行时已由 `scripts/stage-node-runtime.mjs` 在发布前一次性 staging,是本次改造的既有先例。)
+
+### 3.2 这些写入为什么会让 build script 永远失效
+
+`tauri-build` 会对 `bundle.resources` 里的**每个文件**发 `cargo:rerun-if-changed`,并把它拷进 target(`tauri-build-2.6.3/src/lib.rs:88-93`)。我们的三份 tauri 配置把 `resources/codex/**` 与 `resources/plugins` 列进了 `bundle.resources`(`tauri.windows.conf.json:7-15`、`tauri.macos.conf.json:8-22`;基线配置故意不含,见 `check-config.mjs:1377-1383`)。
+
+于是「构建期写的文件」与「build script 声明的输入」是同一批:
+
+```text
+staging 写 resources/** → tauri-build 把同一批文件登记成输入 → mtime 变化
+ ↑ ↓
+ └────────────── cargo 判 build script stale,重跑脚本 ←──────┘
+```
+
+判据是「输入文件 mtime 比 build script 的输出新」,与目录位置无关:把 staging 搬到 `target/` 也躲不开——登记的是配置里写的那些路径,只要构建期写它们,照样自触发。所以关键不是「产物放哪」,而是「**构建期有没有人写这些被登记的文件**」。
+
+### 3.3 已发生的故障
+
+| 症状 | 范围 | 观察证据 |
+|---|---|---|
+| 每次构建都重编主 crate(41–87 秒,从不变 fresh) | Windows + macOS(Linux 上五条 staging 全为 no-op,不受影响) | `CARGO_LOG=cargo::core::compiler::fingerprint=info cargo build --no-default-features` 打印 `stale: changed "resources/codex/mac-native/darwin-arm64/NOTICE.md"`,且 `.fingerprint/**/run-build-script-build-script-build.json` 的 `RerunIfChanged.paths` 含 staging 目标路径 |
+| `npm run agc` 反复 `Rebuilding application`,客户端起不来 | 仅 macOS | dev 日志一轮 28 次以上不收敛;原因是被重写的 `resources/codex/mac-native/**` 落在监听范围内且未被忽略 |
+| `remove_dir_all` 抛 `DirectoryNotEmpty`,整次启动中断 | 并发重建时 | `清理 macOS Codex staging 失败` |
+
+### 3.4 三轮症状层修复都没有收口
+
+| commit | 日期 | 修的是什么 | 结果 |
+|---|---|---|---|
+| `299877b19` | 2026-09-11 | 插件资源改成「内容变化才落盘」+ 引入 `.taurignore` | 只覆盖插件路径,且挡不住 cargo stale |
+| `710d6dddf` | 2026-09-23(PR #487) | Windows 上「权限一致就不写元数据」 | 只减少一类写入,未解决整目录重建 |
+| 本次未合入的 B 改动 | 2026-09-26 | 全部写入改幂等 + 按布局清残留 + 补 `.taurignore` | 实测可行(第二次 `cargo build` 0.25 秒 fresh),但规则靠人守:新增一条写 `resources/**` 的步骤漏改即回退(godot/cocos/plugin.json 正是三个漏点) |
+
+结论:**只要 build script 继续写这些受跟踪路径,就必须持续为每一处写入维护「无改动不写」,成本随写入点增长**;这是结构问题,不是疏漏问题。
+
+## 4. 方案设计
+
+### 4.1 原则
+
+build script 只写 `OUT_DIR`/`target`;随包资源是它的输入。凡需要「由本仓库生成、再随包分发」的内容,都由 `tauri dev|build` 之前的**准备步骤**生成,build script 只校验。
+
+### 4.2 目标形态
+
+```text
+准备步骤(新,唯一写入方) build.rs(退化为校验者)
+ fetch/校验上游 → 生成到 staging 目录 只读 resources/** → 校验 manifest/hash/版本/平台
+ → 原子替换到 resources/** → 不写任何随包资源
+ → 写 manifest.json(schema 化)
+ ↑ ↑
+ dev: start-tauri-dev 内、spawn tauri 之前 release: build-release/build-macos-ci 内
+```
+
+### 4.3 准备步骤的合同(必须成立的行为)
+
+1. **调用时机**:`tauri dev` / `tauri build` 之前完成;任何入口都不得依赖 build script 兜底生成。
+2. **缓存与幂等**:以「上游 lockfile `resolved` + `integrity` + 布局版本 + 目标三元」为 key;命中且 manifest 校验通过的 staging 目录不重写任何文件(避免把「产物」变成「每次构建都变」的新源头)。
+3. **原子性**:写入 staging 临时目录后 rename 替换;不得出现半成品目录(杜绝并发下的 `DirectoryNotEmpty`)。
+4. **所有权**:只允许替换由本工具创建并带 manifest 的目录;遇到非本工具目录、符号链接、越界路径必须 fail closed(沿用 `stage-node-runtime.mjs` 与 `prepare-macos-codex.mjs` 既有判定)。
+5. **清理语义**:准备步骤负责删除本轮布局不再产出的残留(否则旧组件会继续被打包),删除范围限于自己的 staging 目录,不得触碰受版本控制的文件(例如 `resources/codex/win-x64/NOTICE.md`)。
+6. **失败语义**:上游缺失、integrity 不匹配、目标平台不支持、外部工具链缺失 → 立即失败并给出可执行提示;不允许「跳过生成、继续打包」。
+7. **可观测**:输出一行汇总(命中缓存 / 重新生成 / 跳过原因),供本地与 CI 排障。
+8. **并发**:不做并发支持(无实际场景)。同一 staging 目录的并发调用未加锁,会以可读错误失败并保留已生成的 staging 目录;需要并发时由调用方外部串行化。
+9. **实际构建目标**:资源准备与 Cargo 必须使用同一目标和 feature 集。正式发布使用发布目标;无显式 `--target` 的 `--no-bundle` 使用宿主平台,Windows/macOS 仍须准备资源,Linux 编译 smoke 不准备桌面平台专属资源。feature 从最终 Tauri/Cargo 参数解析,不能被开发环境变量单独覆盖;dev 启动器转交的应用参数不参与构建参数解析。
+
+### 4.4 build.rs 退化后的职责
+
+保留:
+
+1. 读取 `TARGET` 并写 `cargo:rustc-env=AGC_BUILD_TARGET`(运行时定位随包目录依赖它)。
+2. 校验 `resources/**` 与清单一致:平台目录存在、manifest schema/平台/版本、逐文件 sha256、必需组件齐全(缺一即 fail closed)。
+3. 声明**真实输入**:上游源文件、布局表、受版本控制资源(含 macOS 声明文件)的 `rerun-if-changed`;不得声明任何由本脚本或准备步骤生成的文件。
+4. `tauri_build::build()` 与既有 Prompt Bundle、能力与配置校验。
+
+删除:
+
+1. 五条 staging 的写入逻辑(M2 删除了 codex 与插件工作区的写入分支,仍是构建期产物的三处留在 M3)、针对大目录的整目录重建。
+2. 为绕开自触发而加的 `.taurignore` staging 条目与说明(准备步骤在监听启动前完成,不再需要)。
+
+### 4.5 资源清单(谁生成、谁消费)
+
+| 资源 | 生成方式 | 运行时消费方 | dev 是否需要 |
+|---|---|---|---|
+| `resources/codex/**` | 纯复制(上游平台包 `vendor/`) | `codex_cli.rs`(内置 sidecar 优先,其次 npm 目录、PATH) | macOS dev 只接受真 `.app` 的 `Contents/Resources`,非 `.app` 场景走 npm/PATH 回退;Windows dev 从 exe 同级读取 |
+| `resources/plugins/**` | 复制 + 三个编辑器分支的产物 | `plugin_host.rs`(`AGC_PLUGIN_WORKSPACE` → `resource_dir/plugins` → dev 回退仓库 `plugins/`)、`editor_adapters.rs` | dev 有仓库回退,但 cocos/unity/godot payload 仍以随包路径为准 |
+| `resources/plugins/agc-unity-editor/**`、`agc-godot-editor/native/gdextension/**` | 外部工具链(Windows 专属) | `editor_adapters.rs` 候选链 | 仅 Windows |
+| `resources/node-runtime/**` | 已有:`stage-node-runtime.mjs` | `environment_check.rs`(`agc-node-runtime.v1` 全量 sha256) | 发布与需要随包 Node 的 dev |
+| `resources/claude-agent/**` | 纯复制(应用 `agent-sidecar/src/index.mjs` + 上游 `@anthropic-ai/claude-agent-sdk`、`@anthropic-ai/claude-agent-sdk-`) | `claude_code_cli.rs`(`exe_dir/claude-agent/index.mjs`、macOS `.app` 的 `Contents/Resources/claude-agent`) | Windows / macOS dev:准备步骤按声明 staging |
+| `design-agent`、`vendor/*` 许可 | 受版本控制 | `design_tools.rs` 等 | 无需 staging |
+
+### 4.6 入口接线
+
+| 入口 | 位置 | 现状 | 改造后 |
+|---|---|---|---|
+| AGC dev | `start-tauri-dev.mjs`(`runTauriDev` → 预检 → 前端 → `spawnCli`,准备点在前端就绪之后、`spawnCli` 之前) | 无准备步骤,依赖 build script | 在 `spawnCli` 之前调用准备步骤(命中缓存时秒退) |
+| Windows 发布 | `build-release.mjs`(`runTauriBuild`,现有 `stageRuntime(target)` 紧邻 spawn tauri) | 只有 Node 运行时走准备步骤 | 同一挂点串上全部 staging |
+| macOS 发布 | `build-macos-ci.mjs`(复用 `runTauriBuild`)→ `check-macos-bundle.mjs` | 同上 | 同上 |
+| 本机 Rust 门禁 | `ai-game-creator-shell:check:rust:shell`(Windows 上 `cargo test --no-run` 会跑 build script) | 依赖 build script 生成资源 | 校验器在该场景必须能只读通过;需要真实资源的用例沿用既有 fixture,不得依赖本机 staging 产物 |
+| `tauri build --no-bundle` | `build-release.mjs` 的 no-bundle 分支(当前跳过 `stageRuntime`) | 不生成 Node 运行时 | 改为:`--no-bundle` 也执行 staging(app 构建本身需要随包资源),只跳过总号发布;校验器在资源缺失时仍然 fail closed |
+| CI(Linux) | `.gitea/workflows/project-ci.yml` 的 AGC 分组 | 五条 staging 全为 no-op | 不需要新增准备步骤;分片与 smoke 命令不变 |
+
+### 4.7 与现有机制的关系
+
+1. **已有先例**:`stage-node-runtime.mjs` 已实现 schema 常量、目标平台失败关闭、staging 目录 + rename 原子替换、拒绝覆盖非本工具目录;`prepare-macos-codex.mjs` 已实现 lockfile `integrity` 驱动的下载、缓存与原子替换。准备步骤应复用这两套范式而不是另起一套。
+2. **打包侧不变**:三份 tauri 配置的 `resources` 映射与 `check-config.mjs:1356-1424` 的逐字断言保持不变;`check-macos-bundle.mjs` 对包内 `coding-agent/mac-native`、`game-runtime/node`、`plugins/agc-cocos-editor` 的存在性、架构与 sha256 断言继续作为发布后门禁。
+3. **fail closed 已有兜底**:`tauri-build` 在资源缺失时以 `ResourcePathNotFound` 直接失败;校验器应比它更早、更明确地报错。
+
+### 4.8 单一声明与实现形态(M1 定案)
+
+准备步骤与构建期校验共用一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。
+
+| 侧 | 读取方式 | 用途 |
+| --- | --- | --- |
+| Node 准备步骤(`scripts/prepare-bundled-resources.mjs`) | 直接读声明 JSON | 组件白名单、Codex 上游候选路径、插件随包子目录与跳过规则、平台与 feature 门槛、缓存 key 组成、manifest 序列化 |
+| Rust 构建期校验与运行期布局 | 读声明生成的 `build_support/package-layout.generated.rs`(编译期常量) | 只读校验既有产物、运行期定位随包组件(`codex_bundle.rs` 接口不变) |
+| 声明门禁 | `scripts/check-package-layout.mjs`(`npm run agc:bundled-resources:check`,已进 `agc:typecheck` 链) | 校验生成物与声明一致,并检查声明自身不变量:目标唯一、`executable` 属于白名单、每个目标恰有一条第三方声明来源、`codex.version` 与应用锁定的 `@openai/codex` 一致 |
+
+形态选择**混合**:生成归 Node(复用 `stage-node-runtime.mjs` / `prepare-macos-codex.mjs` 的下载、`integrity`、临时目录 + rename 原子替换范式),声明与校验归 Rust(复用 `codex_bundle.rs` / `godot_bundle.rs` 的布局与摘要校验,运行期模块不改公开接口)。理由:Rust 侧没有下载与 lockfile 解析能力(`[build-dependencies]` 无 HTTP 客户端),Node 侧没有 staging 能力;任选单一语言都要迁移另一侧既有资产。Rust 侧刻意不解析 JSON:声明经生成器变成编译期常量,避免运行期解析与生命周期妥协,也让 `&'static` 布局表与现有调用点保持不变。
+
+构建脚本自 M3 起只做只读校验(写入分支与 `AGC_SKIP_RESOURCE_STAGING` 开关一并删除),`cargo build/check` 本身就是对既有产物的校验。
+
+### 4.9 M2/M3 实况:构建期写入边界
+
+「谁写随包资源」按产物来源分界,声明里的 `origin` 字段表达同一口径:
+
+| 来源 | 例子 | 谁写 | 时机 |
+| --- | --- | --- | --- |
+| `source`:声明 + 仓库源码即可生成 | `resources/codex/**`、插件工作区的 `src`/`panels`/`skills`/`native/payload` | 准备步骤(Node) | `tauri dev` / `tauri build` 之前 |
+| `prepared` / `libraryStaging` / `nativePayloads`:需要外部工具链或同一次 cargo 构建 | Unity `dotnet/publish/win-x64`、Godot `native/gdextension`、Cocos `native/payload` | 准备步骤:先按声明运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …`,再复制产物 | 同上 |
+
+M2 之后构建脚本只做只读校验;M3 之后它也不再生成任何随包资源(连编辑器分支产物一并交给准备步骤),并在校验阶段确认源码派生内容逐文件一致、已准备产物在位、Godot 随包库通过既有深度校验。因此源码不变时 `cargo` 稳定 fresh(macOS 实测连续三次 `cargo build --no-default-features` 为 0.69 / 0.22 / 0.22 秒),`resources/plugins` 也不再需要在 `.taurignore` 里忽略(两份 staging 条目已删除)。
+
+准备步骤的 Windows 侧命令执行(powershell / cargo)只在本机无法验证,验收清单见 M3 里程碑规范。
+
+**2026-09-30(合并 master)**:新增随包组件 Claude Agent SDK sidecar(cc 执行模式)沿用同一口径——声明 `claudeAgent` section(上游 SDK 包与平台原生运行时包的目标表、复制跳过规则、锁定版本),staging 由准备步骤整目录原子替换,`build.rs` 只读校验(入口与仓库源码一致、SDK 与原生运行时版本等于声明、白名单外文件)。它的 `resources/claude-agent` 映射放在 `tauri.windows.conf.json` 与 `tauri.macos.conf.json`,**不得**回到基线 `tauri.conf.json`:基线同时服务 Linux(CI 只在那里编译壳 crate 且不装 npm 依赖),`tauri-build` 会因资源缺失直接失败。
+
+## 5. 兼容与迁移
+
+1. **产物兼容**:包内路径、manifest schema(`genarrative-codex-sidecar.v2`、`agc-node-runtime.v1`、插件 `plugin.json`)不变,安装包内容逐项对得上;升级路径不需要用户侧动作。
+2. **过渡期(已完成)**:M1 让准备步骤与构建脚本产物并存并逐文件比对一致,M2 移除了构建脚本里 codex 与插件工作区的写入分支;剩余在构建期写入的三处(Unity publish 目录、Godot gdextension、Cocos payload)随 M3 归位。删除与新增不跨里程碑混在一起。
+3. **本机残留**:M2 已删除 codex 与插件工作区的写入逻辑与整目录重建;`.taurignore` 的 staging 条目及其原因说明保留到 M3——Windows 上仍有三处构建期写入落在 `resources/plugins/**`,去掉忽略会重新引入监听自触发。`resources/**` 仍保持 gitignored。
+4. **回滚**:准备步骤与校验器保持独立可关闭(例如校验器只读、不写),回滚只需恢复 build script 的写入分支,不涉及数据迁移。
+
+## 6. 验收标准与证据
+
+| 项 | 判据 |
+|---|---|
+| 构建新鲜度 | 源码不变时连续两次 `cargo build --no-default-features` 第二次为秒级 `Finished`;IDE 的 `cargo check --all-targets` 同样不重复构建 |
+| 无自触发 | `cargo:rerun-if-changed` 输出与 fingerprint 记录里不出现任何 `src-tauri/resources/**` 路径 |
+| dev 可用 | macOS/Windows `npm run agc` 在准备步骤后一次成功:`Rebuilding application` 为 0 次、`Running DevCommand` 为 1 次,客户端与 Runner 进程稳定存活 |
+| 包内容 | `check-macos-bundle.mjs` 全绿;Windows 安装包内 `coding-agent`、`plugins`、`game-runtime/node` 与改造前逐项一致 |
+| 失败关闭 | 上游缺失 / integrity 不匹配 / 清单缺组件 / 目标平台不支持 四类场景各自返回明确错误且不产出包 |
+| 幂等 | 准备步骤连续执行两次,staging 目录内容与 mtime 不变(不改动受跟踪输入) |
+| 门禁 | `check-config.mjs`、`check:encoding`、`check:doc-index`、`git diff --check`、AGC 相关 vitest 与 Rust 测试全绿 |
+
+未验证项必须在交付记录中标注(例如 Windows 真机行为、真实上游包下载在受限网络下的表现)。
+
+## 7. 风险与回滚
+
+| 风险 | 影响 | 措施 |
+|---|---|---|
+| 忘记调用准备步骤(dev 或某个发布入口) | 资源缺失,打包或启动失败 | 校验器 fail closed + 入口测试断言「spawn tauri 前已调用准备步骤」 |
+| staging 缓存 key 不覆盖上游变化 | 静默发旧二进制 | key 含 lockfile `resolved`+`integrity`+布局版本+三元;manifest 校验作为第二道闸 |
+| 外部工具链步骤(unity/godot)搬出后顺序变化 | Windows 打包失败 | 准备步骤显式声明工具链前置检查;先在 Windows 上单独验证再合入 |
+| 并发调用同一 staging 目录 | 失败可读、不丢 staging | 不加锁也不支持并发:替换失败给出可执行提示并保留 staging 目录,需要并发时外部串行化 |
+| 迁移期两套生成并存 | 结果漂移 | 并存阶段以「准备步骤生成结果 == build script 生成结果」逐文件比对作为过渡判据 |
+
+回滚点:准备步骤上线但校验器未启用前,任一步失败都可直接恢复 build script 写入分支,无需数据迁移。
+
+## 8. 里程碑拆分(建议)
+
+| 里程碑 | 交付 | 停止条件 |
+|---|---|---|
+| M1 校验器化 | 单一声明 + 生成门禁、`build.rs` 只读校验路径、准备步骤脚本(codex + plugins 两条纯复制路径)、缓存与原子替换 | 校验器在既有 staging 产物上全绿且不改变现有构建行为(写入分支与 `AGC_SKIP_RESOURCE_STAGING` 开关在 M3 一并删除) |
+| M2 入口接线 | dev 与两个发布入口调用准备步骤;codex/plugins 的写入分支从 build.rs 移除;`cargo` 新鲜度与 dev 不再重建达标 | 已交付(2026-09-27):macOS 侧 §6 前三行达标(连续 `cargo build` 0.69 / 0.22 / 0.22 秒 fresh;`tauri dev` 全程 `Rebuilding application` 0 次、`Running DevCommand` 1 次);Windows 新鲜度待 M3 归位三处构建期产物后复验 |
+| M3 外部工具链归位与清理 | unity/godot/cocos 的产物生成移出 build.rs;删除 `.taurignore` 的 staging 条目与相关注释;文档收口 | 已实现(2026-09-27):三处产物改由准备步骤按声明运行 powershell/cargo 后复制,build.rs 只剩只读校验,两份 `.taurignore` 已删除;已通过第五轮 Windows 真机评审:构建脚本不再写资源(90 个文件 0 变化)、第二次 `cargo build` 1.13s fresh、三分支产物齐备、幂等与 fail-closed 通过;仍待验收的是「安装包内产物路径/sha256 比对」与「三个编辑器分支客户端加载」 |
+
+里程碑规范与单里程碑实现计划按 [`docs/【协作规范】规范驱动开发工作流-2026-09-12.md`](../【协作规范】规范驱动开发工作流-2026-09-12.md) 另立 `docs/project-memory/plans/` 下的临时文件;本方案是它们的主规范来源。
+
+## 9. 未决问题
+
+1. ~~准备步骤用 Rust bin 还是 Node 脚本?~~ **已定(2026-09-27):混合**——生成归 Node、声明与校验归 Rust,布局与白名单收敛为单一声明文件。机制、门禁与验证入口见 §4.8。
+2. unity/godot 的外部工具链步骤是否值得搬出 build script(它们本身是构建动作,搬出后需要显式前置顺序)——需在 Windows 上确认收益与风险。
+3. `resources/**` 是否需要继续保留在 crate 内(`bundle.resources` 相对路径解析要求),还是改用生成式配置指向 `target/` 下的 staging:前者改动小、后者更彻底,需与 Tauri 的资源解析规则一起评估。
diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
index f65a82ef2..21b0ba771 100644
--- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
+++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
@@ -1787,6 +1787,9 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面
- 清单补充可选 `projectName` 与 `pendingFiles`:名称来自本地 manifest;`pendingFiles` 是本轮失败、延后、并发变动与非策略排除的跳过文件数量。`0` 表示扫描范围已同步;大文件等被跳过不能标成完整。旧清单字段缺失表示完整性未知,维持可读取兼容,不反写旧清单。
- 项目名称或完整性发生变化时,即使文件内容没有差异也要提交新清单;本机索引记录上次已提交的这两个字段。实际客户端下一次正常同步可补齐历史清单元数据;后台只读访问不迁移旧清单。缺失 `pendingFiles` 不能默认成 0,临时跳过原因消失后允许无文件上传的 `partial → ready` 转换。
- 后台增加“项目工程”入口,仅 owner 及拥有 `project-snapshots` 页签权限的管理员可访问。`GET /admin/api/project-snapshots?cursor=&limit=20` 读取私有 OSS 清单并返回 `{items,nextCursor}`;单页最多 100 个,游标由服务端校验,目录与清单读取有界。条目为 `{userId,projectId,projectName,syncRevision,syncedAtMs,fileCount,totalBytes,status}`,状态为 `ready / partial / unverified`,名称缺失时显示 projectId。
+- 后台“按同步时间排序”是主动读取当前渠道全量项目的临时展示操作:从首游标开始,以每次 100 条沿 `nextCursor` 顺序读取,只有全部读取成功后才切换为 `syncedAtMs` 降序、`userId` 字符串升序、`projectId` 字符串升序;不能只排序当前远端页。成功后回到第一页,20/50/100 条分页及上一页/下一页复用浏览器中的完整列表,不再请求远端;“刷新”重新读取全量并回到第一页,“恢复默认顺序”在远端目录第一页读取成功后统一切换列表、排序模式和分页游标;读取失败保留原排序集合、当前页码和本地分页,支持重试。切换渠道、令牌或离开页面销毁临时结果并取消在途读取,不跨会话持久化。
+- 全量读取展示已取得项目数并允许取消;单次最多 200 个远端页、10,000 个唯一项目、60 秒,重复游标或超限明确报错。读取失败、鉴权失败、超时或取消保留原来的列表和分页,不能发布部分数据作为排序完成的结果。并发同步期间重复项目按用户/项目身份合并,保留同步时间较新的一份,相同时间取后读到的清单;该列表是遍历期间读到的数据集合,不承诺 OSS 全局事务快照,后续同步由管理员刷新读取。仍由原接口实施渠道和页签鉴权,不新增 OSS 索引、数据库表、API 参数或客户端迁移。
+- 排序验收覆盖远端多页与空中间页、时间相同的身份排序、本地翻页/页容量、刷新与恢复默认、失败/取消/超时/限额/重复游标、并发重复身份,以及渠道/令牌切换和卸载后的旧响应隔离;证据入口为 `apps/admin-web/src/pages/AdminProjectSnapshotsPage.test.tsx`、后台类型检查和编码/文档索引检查。
- `GET /admin/api/project-snapshots/{userId}/{projectId}/download` 只读取该用户/项目的固定清单与其引用对象,返回 `application/zip` 附件。ZIP 中路径直接使用原始相对路径,不包含 userId、摘要目录或 OSS 前缀;名称使用经过安全处理的项目名和 revision。下载固定本次读到的清单,远端并发回收导致对象缺失则整体失败,不能静默遗漏。
- `partial` 快照下载返回 409;`unverified` 历史快照可导出已同步文件,列表明确显示“完整性未知”,动作称“下载已存文件”。`ready` 才显示“下载完整工程”。ZIP 构建核验每一文件的长度与 fnv1a64 摘要,拒绝穿越、绝对路径、重复/大小写冲突路径、非法项目身份;缺失或损坏整体失败,不返回成功的残缺 ZIP。
- ZIP 使用服务端临时文件并限制并发,不将 2 GiB 工程整体驻留内存;成功、失败、客户端取消均清理临时文件。单文件、总量、文件数沿用上传上限,超限明确拒绝。零字节工程文件可以上传和导出。OSS 凭据与签名不下发浏览器,列表失败保留错误而非伪造空列表。
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index 7946645c1..10578f8af 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -99,10 +99,16 @@ AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creato
AGC 开发态还会按后台 Web 的端口约定额外拉起 `apps/admin-web`:Linux 用当前用户端口段的 `start + 3` 槽位,非 Linux 以 `3102` 为兼容首选并允许统一漂移,`ADMIN_WEB_PORT` 可显式指定且必须避开已解析的 AGC Vite 端口;设置 `AGC_DEV_ADMIN_WEB=0` 可关闭。后台 Vite 与 AGC Vite 一样由 `start-dev-stack.mjs` 直接持有并随启动器退出收束,不走 `npm run dev:admin-web`——后者会整体重写 `.app/dev-stack.json`,覆盖本次配套后端的归属状态;后台 Web 的端口解析、启动失败或运行中意外退出都只打印告警,不阻断也不连带停止 AGC 客户端与配套后端。前端与配套后端就绪后,启动器会打印一行 `[ai-game-creator-shell] 启动汇总:`,依次给出前端、后端、后台、数据库与 `bgfilter-worker` 的实际地址;端口漂移或默认端口被其它工作树占用时,以这一行为准。
-Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
+Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 默认不主动启用 sccache;只有用户通过 `RUSTC_WRAPPER` 或 `CARGO_BUILD_RUSTC_WRAPPER` 显式配置 wrapper 时才进入处理流程。配置为 sccache 时会限时执行真实 wrapper 探测,成功才使用缓存;未安装、不可执行、超时或两个变量冲突时设置为空值,回退到真实 `rustc`,不阻断启动。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`;Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志中的 wrapper 启用、冲突或回退提示。
+`npm run agc` 的 Tauri Cargo 走同一套本地 wrapper 规则:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 在启动 Tauri CLI 前调用 `scripts/dev.mjs` 导出的 `buildLocalRustProcessEnv`,把决定结果显式写进 `RUSTC_WRAPPER` 与 `CARGO_BUILD_RUSTC_WRAPPER`。用户级或仓库级 Cargo 配置里的 `rustc-wrapper`(本地常见为 `~/.cargo/config.toml` 的 sccache)只在环境变量非空时才会被覆盖,所以这两个变量必须由脚本写入而不能留空;否则本机 sccache daemon 状态损坏时,Tauri Cargo 的首次 rustc 探测(`failed to run rustc to learn about target-specific information`)就会中断整个 AGC 启动,而配套后端因为已经在用同一规则而能正常起来。AGC 启动日志出现 `[dev:rust]` 提示即为该规则生效。
+
+AGC 随包资源(内置 Codex CLI、插件工作区)的布局与组件白名单只有一份人工声明:`apps/ai-game-creator-shell/src-tauri/build_support/package-layout.json`。Node 侧准备步骤直接读它,Rust 侧读由 `node scripts/check-package-layout.mjs --write`(仓库根 `npm run agc:bundled-resources:sync`)生成的 `build_support/package-layout.generated.rs`;门禁 `npm run agc:bundled-resources:check` 已进 `agc:typecheck` 链,两者不一致直接失败。改布局只能改声明文件再同步生成物,不要手改生成文件,也不要另写第二份白名单。准备步骤是 `node apps/ai-game-creator-shell/scripts/prepare-bundled-resources.mjs`:写临时目录后原子替换、命中缓存不写任何文件、只替换本工具产物、失败即退出并给出可执行提示;其用例为 `npm run agc:bundled-resources:test`。内置 Codex CLI 的上游平台包来自仓库根 `npm ci`,缺失时工具会直接提示重新安装。构建脚本对既有随包产物做只读校验(不再有写入分支,因此也没有跳过写入的开关)。
+
+随包资源由准备步骤在 Tauri 之前生成:`npm run agc` 在 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 里、spawn Tauri CLI 之前调用(日志以 `[ai-game-creator-shell]` 前缀给出命中缓存或重新生成);发布链在 `build-release.mjs` 的 `runTauriBuild` 内与 Node 运行时 staging 并列调用,并传 `profile: 'release'`,`tauri build --no-bundle` 也会执行随包资源 staging(app 构建本身就需要这些资源),只跳过总号发布。**构建脚本完全不生成随包资源**(源码派生内容逐文件比对、已准备产物查存在性、Godot 随包库跑既有深度校验),源码不变时 `cargo build` 稳定 fresh。需要外部工具链或同一次 cargo 构建才能产出的内容(Unity publish 目录、Godot gdextension、Cocos bridge dll)也由准备步骤按 `build_support/package-layout.json` 的 `prepareSteps` 先运行 `powershell.exe -File build.ps1` 或 `cargo build -p … --target …` 再复制;这些步骤按内容指纹跳过未变化的情况。`resources/plugins` 由准备步骤拥有:不要手工往里放东西,准备步骤会按仓库 `plugins/` 与声明重建;dev 构建下客户端读的是 `target/debug/plugins/**`(Tauri 在 debug 配置下把随包资源拷到那里),所以改完资源要重跑准备步骤而不是手动改 `resources/`;`.taurignore` 的 staging 条目已删除(构建期不再写该目录)。
+
### 本地 Rust 构建缓存与磁盘上限
`server-rs/Cargo.toml` 和 `apps/ai-game-creator-shell/src-tauri/Cargo.toml` 是两个独立 Cargo workspace;AGC 会以 path dependency 复用 `agent-runtime-core`、`platform-llm`、`platform-agent` 和 `shared-contracts`,但两边默认仍分别写入 `server-rs/target` 与 `apps/ai-game-creator-shell/src-tauri/target`。这个代码和锁文件边界继续保留,不为节省磁盘直接合并 workspace;生产构建脚本和 Tauri 发布还依赖当前 manifest / lock / target 身份。
@@ -620,6 +626,8 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null
后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照,默认只看本部署渠道,顶部“渠道”选择框可切换远端已存在的其它渠道;列表按游标分页(每页 20/50/100,上一页复用已取得的游标,远端不给总数所以只显示当前页)。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。“用户”列与“素材查询”同口径展示昵称与陶泥号,并可点开用户详情;不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。
+“按同步时间排序”按钮按需读取所选渠道全部项目,完成后按同步时间从新到旧、用户 ID 和项目 ID 字符串升序显示,并在浏览器本地按 20/50/100 条分页;列表会显示本轮项目总数。读取中可取消,失败保留原结果。排序模式下“刷新”重新读取全部项目,“恢复默认顺序”返回远端目录分页;切换渠道或重新登录清除临时排序结果。每次全量读取上限为 200 页、10,000 个唯一项目和 60 秒,超限报错,不将部分集合视作排序完成。该操作增加当次 OSS 清单读取量,不写 OSS 索引、不改数据库或 AGC 上传契约;并发同步结果以本轮读取集合为准,最新变化需刷新。
+
自动上传以原生登记的活动工程为准:打开即首传、每 300 秒周期同步、切换/关闭补传。排障同时核对 AppData `project-snapshots` 索引、`project_snapshot.sync.*` 日志和远端清单;只有测试项目的历史清单不能证明现役项目同步生效。前端在同一窗口内切项目时必须登记生命周期,不能只检查 URL 是否包含 `projectPath`。
后台枚举另外需要 AGC 私有前缀的 `ListObjects`(RAM `oss:ListObjects`,限制 prefix)与 `GetObject` 权限;下载不需要写入权限。客户端修复、后台页面与 api-server 必须分别发布才能在安装版和线上后台使用。本地定向测试及页面模拟不能代替发布后的真实上传与 ZIP 下载验收。
diff --git a/package.json b/package.json
index cf5a355c7..c26f72011 100644
--- a/package.json
+++ b/package.json
@@ -184,6 +184,10 @@
"agc:build": "npm --prefix apps/ai-game-creator-shell run build --",
"agc:skill-pack:check": "npm --prefix apps/ai-game-creator-shell run skill-pack:check",
"agc:skill-pack:sync": "npm --prefix apps/ai-game-creator-shell run skill-pack:sync",
+ "agc:bundled-resources:check": "npm --prefix apps/ai-game-creator-shell run bundled-resources:check",
+ "agc:bundled-resources:sync": "npm --prefix apps/ai-game-creator-shell run bundled-resources:sync",
+ "agc:bundled-resources:prepare": "npm --prefix apps/ai-game-creator-shell run bundled-resources:prepare --",
+ "agc:bundled-resources:test": "npm --prefix apps/ai-game-creator-shell run bundled-resources:test --",
"agc:plugins:test": "node --test plugins/agc-cocos-editor/src/entry.test.mjs plugins/agc-unity-editor/src/entry.test.mjs plugins/agc-godot-editor/src/entry.test.mjs",
"agc:plugins:native-test": "cargo test --manifest-path plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml && cargo test --locked --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml",
"agc:plugins:check": "npm run agc:plugins:test && npm run agc:plugins:native-test",
@@ -207,7 +211,7 @@
"agent-runtime-core:check": "cargo test --manifest-path server-rs/crates/agent-runtime-core/Cargo.toml",
"agent-runtime-orchestration:check": "cargo test --manifest-path server-rs/crates/agent-runtime-orchestration/Cargo.toml",
"ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck",
- "ai-game-creator-shell:check:web": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests",
+ "ai-game-creator-shell:check:web": "npm run ai-game-creator-shell:typecheck && npm --prefix apps/ai-game-creator-shell run bundled-resources:test && npm run test -- apps/ai-game-creator-shell/tests",
"ai-game-creator-shell:check:rust:crates": "npm run agent-runtime-core:check && npm run agent-runtime-orchestration:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && npm run check:generated-bindings",
"ai-game-creator-shell:check:rust:shell": "node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4",
"ai-game-creator-shell:check:rust": "npm run ai-game-creator-shell:check:rust:crates && npm run ai-game-creator-shell:check:rust:shell",
diff --git a/scripts/project-ci-workflow.test.ts b/scripts/project-ci-workflow.test.ts
index 283f6a53a..16bc6b87b 100644
--- a/scripts/project-ci-workflow.test.ts
+++ b/scripts/project-ci-workflow.test.ts
@@ -707,7 +707,7 @@ describe('project CI workflow', () => {
'npm run ai-game-creator-shell:check:web && npm run ai-game-creator-shell:check:rust && npm run ai-game-creator-shell:agent-run:smoke',
);
expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:web']).toBe(
- 'npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests',
+ 'npm run ai-game-creator-shell:typecheck && npm --prefix apps/ai-game-creator-shell run bundled-resources:test && npm run test -- apps/ai-game-creator-shell/tests',
);
expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:rust']).toBe(
'npm run ai-game-creator-shell:check:rust:crates && npm run ai-game-creator-shell:check:rust:shell',