Files
Genarrative/apps/ai-game-creator-shell/scripts/check-config.mjs
T
kdletters 1a7abfd059
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
AGC 项目定时快照上传(目标 OSS agc-dev) (#400)
## 交付内容

- 客户端新增 `project_snapshot` 模块:项目扫描与排除口径、增量索引、差异对比、上传编排、状态查询。
- 触发:工作区窗口存活期间的周期定时器 + 工作区窗口关闭(`CloseRequested`);应用退出只在有界预算内等待在途同步收尾,不重复发起。
- 服务端新增两条登录态路由 `POST /api/agc/project-snapshots/files` 与 `/manifest`,由 api-server 用服务端凭据写入私有前缀 `agc/project-snapshots/v1/{user}/{project}/`;客户端不持有 OSS 凭据、不直连 OSS。
- `platform-oss` 新增内部对象精确写入与探测、项目快照对象键构造;修复 `head_internal_object` 读 HEAD 响应长度恒为 0(会导致服务端"已存在即跳过"永不生效)。
- `shared-contracts` 新增 `agc_project_snapshots` DTO 与项目 ID、相对路径、摘要校验。
- 真实 OSS 存储层冒烟示例 + 客户端真实链路冒烟用例(`#[ignore]`,env 驱动)。
- 登记 `check-config.mjs` 的 native-only 命令白名单,恢复 `npm run agc` 可启动。

## 验证证据

- 存储层:`cargo run -p platform-oss --example agc_project_snapshot_live_smoke` 对真实 `agc-dev` 完成写入 → 读回(contentLength 32)→ 清单写入 → 探针清理。
- HTTP 层:登录态下 7 项校验(401 无 token、400 路径穿越/长度不一致/非法摘要/空正文/重复路径清单)全部符合预期且不写对象。
- 客户端链路(真实项目 gameagent-033b6cf3…):第一次 `synced uploaded=6 uploadedBytes=36694 remoteSkipped=6`,紧接着第二次 `no-op uploaded=0`。
- GUI 触发:`trigger=periodic ... uploaded=6`;把周期设为 600 秒排除干扰、改一个文件后关窗得到 `trigger=project-close ... revision=2 uploaded=1`,索引摘要由 `28d837cd84f8ab62` 推进到 `bff34e2e336d901f`(同步在进程退出前完成)。
- 门禁:AGC `project_snapshot` 14 passed(+1 ignored live)、api-server `project_snapshots` 3 passed、platform-oss 39 passed、shared-contracts 87 passed、`cargo fmt --check`(两处)、`check:encoding`、`check:doc-index`、`git diff --check`、`check-config.mjs` 全部通过。

## 已知未决(不阻塞本里程碑)

- 远端对象只增不减:没有删除路径,也没有 bucket 生命周期规则;要收口需要先定保留语义(清单引用 GC 还是 OSS 生命周期)。
- 部署环境需确认 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_*` 或回退的 `ALIYUN_OSS_*` 具备目标 bucket 私有前缀的 `PutObject` 权限;未配置时接口返回 503、客户端失败关闭(不推进索引)。
- 缺少按用户/项目的配额与限流(error_reports 有每小时提交上限的先例,本功能没有)。
- 没有界面入口与状态可见性:`read_local_project_snapshot_state` 已注册但未接 UI,失败只写本机日志。

## 不做

- 不做云端下载/恢复、跨设备合并、版本回滚。
- 不改 `/api/external/v1` 与 External OpenAPI,不新增 SpacetimeDB 表。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/400
2026-09-17 18:34:51 +08:00

1917 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import ts from 'typescript';
import {
appIdentifier,
defaultRealSwarmTestTask,
} from './agent-swarm-test-chat.mjs';
import {
askHidden,
assertSafeGameCreatorConfigDestination,
buildGameCreatorWizardConfig,
readGameCreatorWizardConfigState,
writeGameCreatorConfigAtomically,
writeGameCreatorWizardConfig,
} from './game-creator-config-wizard.mjs';
const packageConfig = JSON.parse(
fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
);
const tauriConfig = JSON.parse(
fs.readFileSync(
new URL('../src-tauri/tauri.conf.json', import.meta.url),
'utf8',
),
);
const windowsTauriConfig = JSON.parse(
fs.readFileSync(
new URL('../src-tauri/tauri.windows.conf.json', import.meta.url),
'utf8',
),
);
const cargoManifestSource = fs.readFileSync(
new URL('../src-tauri/Cargo.toml', import.meta.url),
'utf8',
);
const cargoPackageVersion = cargoManifestSource
.split(/\r?\n(?=\[)/u)
.find((section) => section.startsWith('[package]'))
?.match(/^version\s*=\s*"([^"]+)"\s*$/mu)?.[1];
const eventCapabilityPath = new URL(
'../src-tauri/capabilities/events.json',
import.meta.url,
);
const defaultAppConfig = JSON.parse(
fs.readFileSync(
new URL('../game-creator.config.json', import.meta.url),
'utf8',
),
);
const rootPackageConfig = JSON.parse(
fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'),
);
const swarmTestChatSource = fs.readFileSync(
new URL('../scripts/agent-swarm-test-chat.mjs', import.meta.url),
'utf8',
);
const viteConfigSource = fs.readFileSync(
new URL('../vite.config.ts', import.meta.url),
'utf8',
);
const devPortSource = fs.readFileSync(
new URL('../scripts/dev-port.mjs', import.meta.url),
'utf8',
);
const startTauriDevSource = fs.readFileSync(
new URL('../scripts/start-tauri-dev.mjs', import.meta.url),
'utf8',
);
const appSource = [
readSourceTree(new URL('../src/', import.meta.url), '.ts'),
readSourceTree(new URL('../src/', import.meta.url), '.tsx'),
].join('\n');
const appInvokeSources = readSourceFiles(
new URL('../src/', import.meta.url),
new Set(['.ts', '.tsx']),
);
const appEntrypointSource = fs.readFileSync(
new URL('../src/main.tsx', import.meta.url),
'utf8',
);
const tauriHandlerSource = fs.readFileSync(
new URL('../src-tauri/src/main.rs', import.meta.url),
'utf8',
);
const tauriRustSource = readSourceTree(
new URL('../src-tauri/src/', import.meta.url),
'.rs',
);
const sharedContractSource = fs.readFileSync(
new URL(
'../../../packages/shared/src/contracts/gameCreationApp.ts',
import.meta.url,
),
'utf8',
);
const rustSharedContractSource = fs.readFileSync(
new URL(
'../../../server-rs/crates/shared-contracts/src/game_creation_app.rs',
import.meta.url,
),
'utf8',
);
const allowedUncalledTauriCommands = [
'append_direct_project_conversation_message',
// TODO: Remove the retired binding command after the legacy runtime path is removed.
'bind_components',
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource',
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
// (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试
// `src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
// `start_local_project_asset_generation`。
'generate_local_project_asset',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_direct_project_conversation',
// 项目定时快照上传只在 Rust 侧触发(周期定时器 / 工作区窗口关闭)与排障调用;
// 按产品口径不做客户端可见界面,因此同 `open_game_creator_*_window` 一样按 native-only 登记。
'read_local_project_snapshot_state',
'sync_local_project_snapshot',
'reset_design_agent_session',
'stop_local_game_preview_if_matches',
'start_game_creator_external_mcp',
'stop_game_creator_external_mcp',
'list_agc_plugins',
'list_agc_extensions',
'refresh_agc_plugins',
'start_agc_plugin',
'stop_agc_plugin',
'reload_agc_plugin',
'call_agc_plugin',
'read_agc_plugin_panel',
'set_agc_plugin_project_path',
'set_agc_plugin_enabled',
];
const sourceExtensions = new Set([
'.json',
'.md',
'.mjs',
'.rs',
'.toml',
'.ts',
'.tsx',
]);
function collectFiles(path) {
const stat = fs.statSync(path);
if (stat.isDirectory()) {
return fs.readdirSync(path, { withFileTypes: true }).flatMap((entry) => {
if (entry.name === 'node_modules' || entry.name === 'target') {
return [];
}
return collectFiles(
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
);
});
}
if (sourceExtensions.has(pathnameExtension(path.pathname))) {
return [path];
}
return [];
}
function readSourceTree(path, extension) {
const stat = fs.statSync(path);
if (stat.isDirectory()) {
return fs
.readdirSync(path, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) =>
readSourceTree(
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
extension,
),
)
.join('\n');
}
if (pathnameExtension(path.pathname) !== extension) {
return '';
}
return fs.readFileSync(path, 'utf8');
}
function readSourceFiles(path, extensions) {
const stat = fs.statSync(path);
if (stat.isDirectory()) {
return fs
.readdirSync(path, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name))
.flatMap((entry) =>
readSourceFiles(
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
extensions,
),
);
}
if (!extensions.has(pathnameExtension(path.pathname))) return [];
return [{ fileName: path.pathname, source: fs.readFileSync(path, 'utf8') }];
}
function pathnameExtension(pathname) {
const index = pathname.lastIndexOf('.');
return index === -1 ? '' : pathname.slice(index);
}
function assertNoOpenAiApiKeys(paths) {
const secretPattern = /sk-[A-Za-z0-9_-]{20,}/;
for (const path of paths.flatMap((entry) => collectFiles(entry))) {
const source = fs.readFileSync(path, 'utf8');
if (secretPattern.test(source)) {
throw new Error(
`AI game creator shell source must not contain API keys: ${path.pathname}`,
);
}
}
}
function assertNoEnvironmentConfigFallbacks(paths) {
const allowedDevCheck = 'import.meta.env.DEV';
for (const path of paths.flatMap((entry) => collectFiles(entry))) {
const source = fs
.readFileSync(path, 'utf8')
.replaceAll(allowedDevCheck, '')
.replaceAll('game-creator.config.local.json', '');
if (/\bprocess\.env\b|\bdotenv\b/.test(source)) {
throw new Error(
`AI game creator shell must use runtime config, not environment config: ${path.pathname}`,
);
}
}
}
function assertNoNativeBrowserConfirm(paths) {
for (const path of paths.flatMap((entry) => collectFiles(entry))) {
const source = fs.readFileSync(path, 'utf8');
if (/\bwindow\.confirm\b/.test(source)) {
throw new Error(
`AI game creator shell confirmations must use in-app UI: ${path.pathname}`,
);
}
}
}
function assertNoBlockingNativeFilePicker(source) {
if (/\.blocking_pick_(?:file|files|folder|folders)\s*\(/.test(source)) {
throw new Error(
'AI game creator shell native file pickers must not block the Tauri event loop',
);
}
}
function extractConstArrayBlock(source, name) {
const start = source.indexOf(`const ${name}`);
if (start === -1) {
throw new Error(`Missing contract array: ${name}`);
}
const end = source.indexOf('];', start);
if (end === -1) {
throw new Error(`Missing contract array end: ${name}`);
}
return source.slice(start, end + 2);
}
function parseTsCommands(source) {
const block = extractConstArrayBlock(source, 'GAME_CREATION_APP_COMMANDS');
return Array.from(
block.matchAll(/\{\s*id:\s*'([^']+)',\s*permission:\s*'([^']+)'\s*\}/g),
([, id, permission]) => ({ id, permission }),
);
}
function parseRustCommands(source) {
const block = extractConstArrayBlock(source, 'GAME_CREATION_APP_COMMANDS');
const permissionNames = {
Auto: 'auto',
Confirm: 'confirm',
Deny: 'deny',
};
return Array.from(
block.matchAll(
/command\(\s*"([^"]+)",\s*GameCreationAppPermission::(Auto|Confirm|Deny)\s*\)/g,
),
([, id, permission]) => ({ id, permission: permissionNames[permission] }),
);
}
function parseTsCapabilities(source) {
const block = extractConstArrayBlock(
source,
'GAME_CREATION_AGENT_CAPABILITIES',
);
return Array.from(
block.matchAll(
/\{\s*id:\s*'([^']+)',\s*area:\s*'([^']+)',\s*title:\s*'([^']+)',?\s*\}/g,
),
([, id, area, title]) => ({ id, area, title }),
);
}
function parseRustCapabilities(source) {
const block = extractConstArrayBlock(
source,
'GAME_CREATION_AGENT_CAPABILITIES',
);
return Array.from(
block.matchAll(
/capability\(\s*"([^"]+)",\s*"([^"]+)",\s*"([^"]+)",?\s*\)/g,
),
([, id, area, title]) => ({ id, area, title }),
);
}
function assertContractRecordsMatch(label, leftRecords, rightRecords) {
const normalize = (records) =>
records
.map((record) => JSON.stringify(record))
.sort((left, right) => left.localeCompare(right));
const left = normalize(leftRecords);
const right = normalize(rightRecords);
if (left.length === 0 || right.length === 0) {
throw new Error(`${label} parser returned no records`);
}
if (JSON.stringify(left) !== JSON.stringify(right)) {
throw new Error(
`${label} drifted between TypeScript and Rust contracts\nTS=${left.join(
'\n',
)}\nRust=${right.join('\n')}`,
);
}
}
const APP_INVOKE_FILE_MAX_COUNT = 4 * 1024;
const APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH = 16 * 1024 * 1024;
const APP_INVOKE_SOURCE_MAX_LENGTH = 2 * 1024 * 1024;
const APP_INVOKE_COMMAND_MAX_LENGTH = 128;
const APP_INVOKE_CALL_MAX_COUNT = 4 * 1024;
const APP_INVOKE_BARE_CALL_NAMES = new Set([
'invoke',
'directInvoke',
'invokeInput',
'invokeAuthenticatedInput',
'invokeDiagnostic',
]);
function parseAppInvokeCommandNames(source, fileName = 'fixture.tsx') {
const sourceByteLength = Buffer.byteLength(source, 'utf8');
if (sourceByteLength > APP_INVOKE_SOURCE_MAX_LENGTH) {
throw new Error(
`AI game creator shell App invoke source exceeds ${APP_INVOKE_SOURCE_MAX_LENGTH} bytes: ${fileName}`,
);
}
const sourceFile = ts.createSourceFile(
fileName,
source,
ts.ScriptTarget.Latest,
true,
fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const parseDiagnostic = sourceFile.parseDiagnostics[0];
if (parseDiagnostic !== undefined) {
throw new Error(
`AI game creator shell App invoke source cannot be parsed: ${fileName} (TS${parseDiagnostic.code})`,
);
}
const commands = [];
const visit = (node) => {
if (ts.isCallExpression(node)) {
const expression = node.expression;
const isBareCall =
ts.isIdentifier(expression) &&
APP_INVOKE_BARE_CALL_NAMES.has(expression.text);
const isObjectInvoke =
ts.isPropertyAccessExpression(expression) &&
expression.name.text === 'invoke';
const commandArgument =
ts.isIdentifier(expression) && expression.text === 'invokeDiagnostic'
? node.arguments[1]
: node.arguments[0];
if (
(isBareCall || isObjectInvoke) &&
commandArgument !== undefined &&
ts.isStringLiteral(commandArgument) &&
commandArgument.text.length <= APP_INVOKE_COMMAND_MAX_LENGTH &&
/^[a-z0-9_]+$/u.test(commandArgument.text)
) {
commands.push(commandArgument.text);
if (commands.length > APP_INVOKE_CALL_MAX_COUNT) {
throw new Error(
`AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}: ${fileName}`,
);
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return commands;
}
function parseAppInvokeSourceFiles(files) {
if (files.length > APP_INVOKE_FILE_MAX_COUNT) {
throw new Error(
`AI game creator shell App invoke files exceed ${APP_INVOKE_FILE_MAX_COUNT}`,
);
}
const totalLength = files.reduce(
(length, file) => length + Buffer.byteLength(file.source, 'utf8'),
0,
);
if (totalLength > APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH) {
throw new Error(
`AI game creator shell App invoke sources exceed ${APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH} bytes`,
);
}
const commands = files.flatMap(({ fileName, source }) =>
parseAppInvokeCommandNames(source, fileName),
);
if (commands.length > APP_INVOKE_CALL_MAX_COUNT) {
throw new Error(
`AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}`,
);
}
return commands;
}
function parseTauriHandlerCommandNames(source) {
const match = source.match(/tauri::generate_handler!\[([\s\S]*?)\]/);
if (!match) {
throw new Error('AI game creator shell Tauri handler list is missing');
}
return Array.from(
match[1].matchAll(/\b([a-z][a-z0-9_]+)\b/g),
([, command]) => command,
);
}
function parseRustFunctionNames(source) {
return Array.from(
source.matchAll(/\b(?:async\s+)?fn\s+([a-z][a-z0-9_]*)\s*\(/g),
([, name]) => name,
);
}
function assertCommandNamesSubset(label, leftNames, rightNames) {
const right = new Set(rightNames);
const missing = Array.from(new Set(leftNames))
.filter((name) => !right.has(name))
.sort((left, rightName) => left.localeCompare(rightName));
if (missing.length > 0) {
throw new Error(`${label} missing commands: ${missing.join(', ')}`);
}
}
function assertCommandNamesDisjoint(label, leftNames, rightNames) {
const right = new Set(rightNames);
const overlapping = Array.from(new Set(leftNames))
.filter((name) => right.has(name))
.sort((left, rightName) => left.localeCompare(rightName));
if (overlapping.length > 0) {
throw new Error(`${label} overlapping commands: ${overlapping.join(', ')}`);
}
}
function runAppInvokeParserRegressionChecks() {
assert.deepEqual(
parseAppInvokeCommandNames(`
invoke('direct_command', {});
directInvoke<Result>('generic_direct_command', {});
invokeInput < Result > ('input_wrapper_command', {});
invokeAuthenticatedInput<Result>(
'authenticated_input_wrapper_command',
{},
);
input.invoke<Result>('object_field_command', {});
`),
[
'direct_command',
'generic_direct_command',
'input_wrapper_command',
'authenticated_input_wrapper_command',
'object_field_command',
],
);
assert.deepEqual(
parseAppInvokeCommandNames(`
// invoke('line_comment_decoy')
/* invokeInput('block_comment_decoy') */
const quoted = "directInvoke('string_decoy')";
const template = \`input.invoke('template_decoy')\`;
const expression = /invokeAuthenticatedInput\\('regex_decoy'\\)/u;
invokeCommand('unrelated_name');
myinvoke('unrelated_suffix');
invoke(dynamicCommand, {});
invoke('UPPERCASE_COMMAND', {});
`),
[],
);
assert.throws(
() => parseAppInvokeCommandNames("invoke<Result>('malformed_generic', {"),
/source cannot be parsed/u,
);
assert.deepEqual(
parseAppInvokeCommandNames(
`invoke('${'a'.repeat(APP_INVOKE_COMMAND_MAX_LENGTH + 1)}', {})`,
),
[],
);
assert.throws(
() =>
parseAppInvokeCommandNames(' '.repeat(APP_INVOKE_SOURCE_MAX_LENGTH + 1)),
/source exceeds/u,
);
const wrapperInvocations = parseAppInvokeCommandNames(
"invokeInput<Result>('wrapper_reachability_command', {})",
);
assert.doesNotThrow(() =>
assertCommandNamesSubset(
'App invoke parser reachability fixture',
['wrapper_reachability_command'],
wrapperInvocations,
),
);
assert.throws(
() =>
assertCommandNamesDisjoint(
'App invoke parser false allowlist fixture',
wrapperInvocations,
['wrapper_reachability_command'],
),
/overlapping commands: wrapper_reachability_command/u,
);
assert.throws(
() =>
assertCommandNamesSubset(
'App invoke parser removed wrapper fixture',
['wrapper_reachability_command'],
parseAppInvokeCommandNames('const wrapperWasRemoved = true;'),
),
/missing commands: wrapper_reachability_command/u,
);
}
function gitCheckResult({ code = 0, signal = null, stdout = '', stderr = '' }) {
return { code, signal, stdout, stderr };
}
class HiddenInputFixture extends EventEmitter {
constructor({ failRawRestore = false } = {}) {
super();
this.isTTY = true;
this.isRaw = false;
this.paused = true;
this.failRawRestore = failRawRestore;
this.rawModeChanges = [];
}
isPaused() {
return this.paused;
}
setRawMode(enabled) {
this.rawModeChanges.push(enabled);
this.isRaw = enabled;
if (!enabled && this.failRawRestore) {
throw new Error('fixture raw restore failure');
}
return this;
}
resume() {
this.paused = false;
return this;
}
pause() {
this.paused = true;
return this;
}
}
function hiddenOutputFixture() {
const writes = [];
return {
writes,
write(value) {
writes.push(value);
return true;
},
};
}
function assertHiddenInputRestored(input) {
assert.equal(input.listenerCount('data'), 0);
assert.equal(input.listenerCount('end'), 0);
assert.equal(input.listenerCount('error'), 0);
assert.equal(input.paused, true);
}
async function runHiddenInputRegressionChecks() {
const endedInput = new HiddenInputFixture();
const endedPromise = askHidden('fixture', {
input: endedInput,
output: hiddenOutputFixture(),
});
endedInput.emit('end');
await assert.rejects(endedPromise, /隐藏输入在完成前已结束/u);
assert.deepEqual(endedInput.rawModeChanges, [true, false]);
assertHiddenInputRestored(endedInput);
const erroredInput = new HiddenInputFixture();
const erroredPromise = askHidden('fixture', {
input: erroredInput,
output: hiddenOutputFixture(),
});
erroredInput.emit('error', new Error('fixture stdin error'));
await assert.rejects(erroredPromise, /fixture stdin error/u);
assert.deepEqual(erroredInput.rawModeChanges, [true, false]);
assertHiddenInputRestored(erroredInput);
const restoreFailedInput = new HiddenInputFixture({ failRawRestore: true });
const restoreFailedPromise = askHidden('fixture', {
input: restoreFailedInput,
output: hiddenOutputFixture(),
});
restoreFailedInput.emit('data', Buffer.from('secret\n'));
await assert.rejects(restoreFailedPromise, /恢复终端 raw mode 失败/u);
assert.deepEqual(restoreFailedInput.rawModeChanges, [true, false]);
assertHiddenInputRestored(restoreFailedInput);
const signaledInput = new HiddenInputFixture();
const signalSource = new EventEmitter();
const forwardedSignals = [];
const signaledPromise = askHidden('fixture', {
input: signaledInput,
output: hiddenOutputFixture(),
signalSource,
terminateForSignal: (signal) => forwardedSignals.push(signal),
platform: 'win32',
});
signalSource.emit('SIGBREAK');
await assert.rejects(signaledPromise, /SIGBREAK/u);
assert.deepEqual(signaledInput.rawModeChanges, [true, false]);
assertHiddenInputRestored(signaledInput);
assert.deepEqual(forwardedSignals, ['SIGBREAK']);
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) {
assert.equal(signalSource.listenerCount(signal), 0);
}
}
async function runConfigWizardRegressionChecks() {
const testRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-agc-config-check-'),
);
try {
const canonicalTestRoot = fs.realpathSync.native(testRoot);
const realConfigAncestor = path.join(testRoot, 'real-config-ancestor');
const linkedConfigAncestor = path.join(testRoot, 'linked-config-ancestor');
fs.mkdirSync(realConfigAncestor);
fs.symlinkSync(
realConfigAncestor,
linkedConfigAncestor,
process.platform === 'win32' ? 'junction' : 'dir',
);
const missingLinkedConfigDir = path.join(
linkedConfigAncestor,
'missing-appdata',
);
assert.equal(fs.existsSync(missingLinkedConfigDir), false);
assert.equal(
await assertSafeGameCreatorConfigDestination(missingLinkedConfigDir),
path.join(fs.realpathSync.native(realConfigAncestor), 'missing-appdata'),
);
const gitRoot = path.join(testRoot, 'tracked-repository');
const trackedConfigDir = path.join(gitRoot, 'runtime-config');
fs.mkdirSync(trackedConfigDir, { recursive: true });
fs.writeFileSync(
path.join(trackedConfigDir, 'game-creator.config.json'),
'{}\n',
{ mode: 0o600 },
);
execFileSync('git', ['init', '--quiet', gitRoot], { stdio: 'ignore' });
execFileSync(
'git',
['-C', gitRoot, 'add', 'runtime-config/game-creator.config.json'],
{ stdio: 'ignore' },
);
await assert.rejects(
assertSafeGameCreatorConfigDestination(trackedConfigDir),
/Git 已跟踪/u,
);
const untrackedConfigDir = path.join(gitRoot, 'untracked-runtime-config');
await assert.rejects(
assertSafeGameCreatorConfigDestination(untrackedConfigDir),
/Git 仓库之外/u,
);
const outsideConfigDir = path.join(testRoot, 'outside-appdata');
assert.equal(
await assertSafeGameCreatorConfigDestination(outsideConfigDir),
path.join(canonicalTestRoot, 'outside-appdata'),
);
await assert.rejects(
assertSafeGameCreatorConfigDestination(outsideConfigDir, {
requireDedicatedLeaf: true,
}),
new RegExp(appIdentifier.replaceAll('.', '\\.')),
);
const dedicatedConfigDir = path.join(testRoot, appIdentifier);
assert.equal(
await assertSafeGameCreatorConfigDestination(dedicatedConfigDir, {
requireDedicatedLeaf: true,
}),
path.join(canonicalTestRoot, appIdentifier),
);
const injectedNonGitConfigDir = path.join(testRoot, 'injected-non-git');
assert.equal(
await assertSafeGameCreatorConfigDestination(injectedNonGitConfigDir, {
runGit: async () =>
gitCheckResult({
code: 128,
stderr:
'fatal: not a git repository (or any of the parent directories): .git\n',
}),
}),
path.join(canonicalTestRoot, 'injected-non-git'),
);
await assert.rejects(
assertSafeGameCreatorConfigDestination(
path.join(testRoot, 'missing-git-command'),
{
runGit: async () => {
const error = new Error('spawn git ENOENT');
error.code = 'ENOENT';
throw error;
},
},
),
/Git 命令不可用或启动失败/u,
);
await assert.rejects(
assertSafeGameCreatorConfigDestination(
path.join(testRoot, 'abnormal-rev-parse'),
{
runGit: async () =>
gitCheckResult({
code: 129,
stderr: 'fixture rev-parse failure',
}),
},
),
/Git 检查异常.*fixture rev-parse failure/u,
);
const abnormalTrackedGitRoot = path.join(
testRoot,
'abnormal-tracked-repository',
);
const abnormalTrackedConfigDir = path.join(
abnormalTrackedGitRoot,
'runtime-config',
);
fs.mkdirSync(abnormalTrackedConfigDir, { recursive: true });
let gitCheckCount = 0;
await assert.rejects(
assertSafeGameCreatorConfigDestination(abnormalTrackedConfigDir, {
runGit: async () => {
gitCheckCount += 1;
if (gitCheckCount === 1) {
return gitCheckResult({ stdout: `${abnormalTrackedGitRoot}\n` });
}
return gitCheckResult({
code: 2,
stderr: 'fixture ls-files failure',
});
},
}),
/配置文件跟踪状态.*fixture ls-files failure/u,
);
assert.equal(gitCheckCount, 2);
const overlayConfigDir = path.join(testRoot, 'overlay-appdata');
fs.mkdirSync(overlayConfigDir, { recursive: true, mode: 0o700 });
const primaryConfigPath = path.join(
overlayConfigDir,
'game-creator.config.json',
);
const localConfigPath = path.join(
overlayConfigDir,
'game-creator.config.local.json',
);
const primaryConfig = {
llm: {
apiKey: 'fixture-primary-key',
baseUrl: 'https://primary.example.test/v1',
model: 'primary-model',
apiKind: 'openai_chat',
stream: false,
requestTimeoutMs: 12345,
},
editorApi: {
apiKey: 'fixture-editor-key',
baseUrl: 'http://127.0.0.1:8082',
},
};
const localConfig = {
llm: {
apiKey: 'fixture-old-overlay-key',
model: 'old-overlay-model',
stream: true,
},
agentLlm: {
planner: { model: 'planner-overlay-model' },
},
};
fs.writeFileSync(
primaryConfigPath,
`${JSON.stringify(primaryConfig, null, 2)}\n`,
{ mode: 0o644 },
);
fs.writeFileSync(
localConfigPath,
`${JSON.stringify(localConfig, null, 2)}\n`,
{ mode: 0o644 },
);
const overlayState =
await readGameCreatorWizardConfigState(overlayConfigDir);
if (process.platform !== 'win32') {
assert.equal(fs.statSync(primaryConfigPath).mode & 0o777, 0o600);
assert.equal(fs.statSync(localConfigPath).mode & 0o777, 0o600);
}
assert.equal(overlayState.configPath, primaryConfigPath);
assert.equal(overlayState.localConfigPath, localConfigPath);
assert.equal(overlayState.effectiveConfig.llm.model, 'old-overlay-model');
assert.equal(overlayState.effectiveConfig.llm.stream, true);
assert.equal(overlayState.effectiveConfig.llm.requestTimeoutMs, 12345);
assert.deepEqual(
overlayState.writeConfig.editorApi,
primaryConfig.editorApi,
);
const updatedPrimary = buildGameCreatorWizardConfig(
overlayState.writeConfig,
{
apiKey: 'fixture-new-key',
baseUrl: 'https://new.example.test/v1/',
model: 'new-model',
apiKind: 'openai_responses',
},
);
assert.equal(updatedPrimary.agentMode, 'provider');
await writeGameCreatorWizardConfig(overlayState, updatedPrimary);
const reloadedState =
await readGameCreatorWizardConfigState(overlayConfigDir);
assert.equal(reloadedState.effectiveConfig.llm.apiKey, 'fixture-new-key');
assert.equal(reloadedState.effectiveConfig.agentMode, 'provider');
assert.equal(reloadedState.effectiveConfig.llm.model, 'new-model');
assert.equal(
reloadedState.effectiveConfig.llm.baseUrl,
'https://new.example.test/v1',
);
assert.equal(reloadedState.effectiveConfig.llm.requestTimeoutMs, 12345);
assert.equal(reloadedState.effectiveConfig.llm.stream, true);
assert.equal(
JSON.parse(fs.readFileSync(primaryConfigPath, 'utf8')).llm.apiKey,
'fixture-new-key',
);
const sanitizedLocalConfig = JSON.parse(
fs.readFileSync(localConfigPath, 'utf8'),
);
assert.equal(sanitizedLocalConfig.llm, undefined);
assert.deepEqual(sanitizedLocalConfig.agentLlm, localConfig.agentLlm);
if (process.platform !== 'win32') {
assert.equal(fs.statSync(localConfigPath).mode & 0o777, 0o600);
}
const unchangedLocalConfigDir = path.join(
testRoot,
'unchanged-local-appdata',
);
fs.mkdirSync(unchangedLocalConfigDir, { recursive: true, mode: 0o700 });
const unchangedPrimaryConfigPath = path.join(
unchangedLocalConfigDir,
'game-creator.config.json',
);
const unchangedLocalConfigPath = path.join(
unchangedLocalConfigDir,
'game-creator.config.local.json',
);
const unchangedLocalSource =
'{\n "editorApi": { "baseUrl": "http://127.0.0.1:8082" }\n}\n';
fs.writeFileSync(unchangedPrimaryConfigPath, '{}\n', { mode: 0o600 });
fs.writeFileSync(unchangedLocalConfigPath, unchangedLocalSource, {
mode: 0o644,
});
const unchangedLocalState = await readGameCreatorWizardConfigState(
unchangedLocalConfigDir,
);
await writeGameCreatorWizardConfig(
unchangedLocalState,
buildGameCreatorWizardConfig(unchangedLocalState.writeConfig, {
apiKey: 'fixture-private-local-key',
baseUrl: 'https://private.example.test/v1',
model: 'private-model',
apiKind: 'openai_responses',
}),
);
assert.equal(
fs.readFileSync(unchangedLocalConfigPath, 'utf8'),
unchangedLocalSource,
);
if (process.platform !== 'win32') {
assert.equal(fs.statSync(unchangedLocalConfigPath).mode & 0o777, 0o600);
}
const invalidLocalConfigDir = path.join(testRoot, 'invalid-local-appdata');
fs.mkdirSync(invalidLocalConfigDir, { recursive: true, mode: 0o700 });
const invalidPrimaryConfigPath = path.join(
invalidLocalConfigDir,
'game-creator.config.json',
);
const invalidLocalConfigPath = path.join(
invalidLocalConfigDir,
'game-creator.config.local.json',
);
fs.writeFileSync(invalidPrimaryConfigPath, '{}\n', { mode: 0o600 });
fs.writeFileSync(invalidLocalConfigPath, '{ invalid json\n', {
mode: 0o644,
});
await assert.rejects(
writeGameCreatorWizardConfig(
{
configPath: invalidPrimaryConfigPath,
localConfigPath: invalidLocalConfigPath,
},
{ llm: { apiKey: 'fixture-invalid-local-key' } },
),
/读取客户端配置失败/u,
);
if (process.platform !== 'win32') {
assert.equal(fs.statSync(invalidLocalConfigPath).mode & 0o777, 0o600);
}
const windowsLocalConfigDir = path.join(testRoot, 'windows-local-appdata');
fs.mkdirSync(windowsLocalConfigDir, { recursive: true });
const windowsLocalPrimaryPath = path.join(
windowsLocalConfigDir,
'game-creator.config.json',
);
const windowsLocalConfigPath = path.join(
windowsLocalConfigDir,
'game-creator.config.local.json',
);
fs.writeFileSync(windowsLocalPrimaryPath, '{}\n');
fs.writeFileSync(windowsLocalConfigPath, unchangedLocalSource);
const windowsReadAclPaths = [];
const windowsLocalState = await readGameCreatorWizardConfigState(
windowsLocalConfigDir,
{
platform: 'win32',
secureWindowsPath: async (targetPath, { isDirectory }) => {
windowsReadAclPaths.push({ targetPath, isDirectory });
},
},
);
assert.deepEqual(windowsReadAclPaths, [
{ targetPath: windowsLocalConfigDir, isDirectory: true },
{ targetPath: windowsLocalPrimaryPath, isDirectory: false },
{ targetPath: windowsLocalConfigPath, isDirectory: false },
]);
const windowsLocalAclPaths = [];
await writeGameCreatorWizardConfig(
windowsLocalState,
buildGameCreatorWizardConfig(windowsLocalState.writeConfig, {
apiKey: 'fixture-windows-local-key',
baseUrl: 'https://windows.example.test/v1',
model: 'windows-model',
apiKind: 'openai_responses',
}),
{
platform: 'win32',
secureWindowsPath: async (targetPath, { isDirectory }) => {
if (!isDirectory) windowsLocalAclPaths.push(targetPath);
},
},
);
assert.equal(
fs.readFileSync(windowsLocalConfigPath, 'utf8'),
unchangedLocalSource,
);
assert.equal(windowsLocalAclPaths.includes(windowsLocalConfigPath), true);
const guiSavedConfig = JSON.parse(
fs.readFileSync(primaryConfigPath, 'utf8'),
);
guiSavedConfig.llm = {
...guiSavedConfig.llm,
apiKey: 'fixture-gui-key',
model: 'gui-model',
};
await writeGameCreatorConfigAtomically(primaryConfigPath, guiSavedConfig);
const afterGuiSave =
await readGameCreatorWizardConfigState(overlayConfigDir);
assert.equal(afterGuiSave.effectiveConfig.llm.apiKey, 'fixture-gui-key');
assert.equal(afterGuiSave.effectiveConfig.llm.model, 'gui-model');
const windowsConfigDir = path.join(testRoot, 'windows-appdata');
const windowsConfigPath = path.join(
windowsConfigDir,
'game-creator.config.json',
);
const aclEvents = [];
await writeGameCreatorConfigAtomically(
windowsConfigPath,
{ llm: { apiKey: 'fixture-windows-key' } },
{
platform: 'win32',
secureWindowsPath: async (targetPath, { isDirectory }) => {
const metadata = fs.lstatSync(targetPath);
const temporaryFile =
!isDirectory && path.basename(targetPath).startsWith('.');
if (temporaryFile) {
assert.equal(metadata.size, 0);
assert.equal(fs.readFileSync(targetPath, 'utf8'), '');
}
aclEvents.push({ isDirectory, temporaryFile });
},
},
);
assert.deepEqual(aclEvents, [
{ isDirectory: true, temporaryFile: false },
{ isDirectory: false, temporaryFile: true },
{ isDirectory: false, temporaryFile: false },
]);
assert.equal(
JSON.parse(fs.readFileSync(windowsConfigPath, 'utf8')).llm.apiKey,
'fixture-windows-key',
);
const failedWindowsConfigDir = path.join(
testRoot,
'failed-windows-appdata',
);
const failedWindowsConfigPath = path.join(
failedWindowsConfigDir,
'game-creator.config.json',
);
await assert.rejects(
writeGameCreatorConfigAtomically(
failedWindowsConfigPath,
{ llm: { apiKey: 'fixture-must-not-reach-disk' } },
{
platform: 'win32',
secureWindowsPath: async (_targetPath, { isDirectory }) => {
if (!isDirectory) throw new Error('fixture DACL failure');
},
},
),
/fixture DACL failure/u,
);
assert.equal(fs.existsSync(failedWindowsConfigPath), false);
assert.deepEqual(fs.readdirSync(failedWindowsConfigDir), []);
} finally {
fs.rmSync(testRoot, { recursive: true, force: true });
}
}
assertNoOpenAiApiKeys([
new URL('../src/', import.meta.url),
new URL('../scripts/', import.meta.url),
new URL('../game-creator.config.json', import.meta.url),
new URL('../src-tauri/src/', import.meta.url),
new URL('../package.json', import.meta.url),
new URL('../vite.config.ts', import.meta.url),
new URL('../src-tauri/Cargo.toml', import.meta.url),
new URL('../src-tauri/tauri.conf.json', import.meta.url),
new URL('../src-tauri/tauri.windows.conf.json', import.meta.url),
new URL(
'../../../packages/shared/src/contracts/gameCreationApp.ts',
import.meta.url,
),
new URL(
'../../../packages/shared/src/contracts/gameCreationApp.test.ts',
import.meta.url,
),
new URL(
'../../../server-rs/crates/platform-agent/src/game_creation.rs',
import.meta.url,
),
new URL(
'../../../server-rs/crates/shared-contracts/src/game_creation_app.rs',
import.meta.url,
),
new URL(
'../../../docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md',
import.meta.url,
),
]);
assertNoEnvironmentConfigFallbacks([
new URL('../src/', import.meta.url),
new URL('../scripts/run-cli-with-config.mjs', import.meta.url),
new URL('../scripts/smoke-agent-run-local-provider.mjs', import.meta.url),
new URL('../scripts/start-dev-server.mjs', import.meta.url),
new URL('../src-tauri/src/', import.meta.url),
new URL('../package.json', import.meta.url),
new URL('../vite.config.ts', import.meta.url),
new URL('../src-tauri/Cargo.toml', import.meta.url),
new URL('../src-tauri/tauri.conf.json', import.meta.url),
new URL('../src-tauri/tauri.windows.conf.json', import.meta.url),
]);
assertNoNativeBrowserConfirm([new URL('../src/', import.meta.url)]);
assertNoBlockingNativeFilePicker(tauriRustSource);
runAppInvokeParserRegressionChecks();
const appInvokeCommandNames = parseAppInvokeSourceFiles(appInvokeSources);
assertContractRecordsMatch(
'AI game creator shell command contract',
parseTsCommands(sharedContractSource),
parseRustCommands(rustSharedContractSource),
);
assertContractRecordsMatch(
'AI game creator shell capability contract',
parseTsCapabilities(sharedContractSource),
parseRustCapabilities(rustSharedContractSource),
);
assertCommandNamesSubset(
'AI game creator shell Tauri handler',
appInvokeCommandNames,
parseTauriHandlerCommandNames(tauriHandlerSource),
);
assertCommandNamesSubset(
'AI game creator shell Tauri command implementation',
parseTauriHandlerCommandNames(tauriHandlerSource),
parseRustFunctionNames(tauriRustSource),
);
assertCommandNamesSubset(
'AI game creator shell App invoke or explicit native-only allowlist',
parseTauriHandlerCommandNames(tauriHandlerSource),
[...appInvokeCommandNames, ...allowedUncalledTauriCommands],
);
assertCommandNamesSubset(
'AI game creator shell explicit native-only allowlist',
allowedUncalledTauriCommands,
parseTauriHandlerCommandNames(tauriHandlerSource),
);
assertCommandNamesDisjoint(
'AI game creator shell App invoke and explicit native-only allowlist',
appInvokeCommandNames,
allowedUncalledTauriCommands,
);
const tauriHandlerCommandNames =
parseTauriHandlerCommandNames(tauriHandlerSource);
if (!tauriHandlerCommandNames.includes('create_automatic_local_game_project')) {
throw new Error(
'AI game creator shell home creation must expose automatic project creation',
);
}
if (
tauriHandlerCommandNames.includes('chat_with_game_creator_home_direct_codex')
) {
throw new Error(
'AI game creator shell home surface must not expose a projectless Codex conversation',
);
}
if (packageConfig.name !== '@genarrative/ai-game-creator-shell') {
throw new Error('AI game creator shell package name drifted');
}
if (
packageConfig.scripts?.['llm-status'] !==
'node scripts/run-cli-with-config.mjs --llm-status'
) {
throw new Error(
'AI game creator shell llm-status must use client config before checking LLM config',
);
}
if (
packageConfig.scripts?.['agent-run'] !==
'node scripts/run-cli-with-config.mjs --agent-run'
) {
throw new Error(
'AI game creator shell agent-run must use client config before running the provider path',
);
}
if (
packageConfig.scripts?.['agent-task'] !==
'node scripts/run-cli-with-config.mjs --agent-task'
) {
throw new Error(
'AI game creator shell agent-task must use client config before starting the single Agent runtime',
);
}
if (
packageConfig.scripts?.swarm !==
'node scripts/run-cli-with-config.mjs --swarm-chat'
) {
throw new Error(
'AI game creator shell swarm must use client config before starting the interactive Agent runtime',
);
}
if (
packageConfig.scripts?.config !==
'node scripts/game-creator-config-wizard.mjs'
) {
throw new Error(
'AI game creator shell config must use the AppData configuration wizard',
);
}
if (
packageConfig.scripts?.['test:chat'] !==
`node scripts/agent-swarm-test-chat.mjs --task ${JSON.stringify(defaultRealSwarmTestTask)} --no-open`
) {
throw new Error(
'AI game creator shell test:chat must use the one-click Swarm test entry',
);
}
if (
packageConfig.scripts?.['test:chat:manual'] !==
'node scripts/agent-swarm-test-chat.mjs'
) {
throw new Error(
'AI game creator shell test:chat:manual must keep the interactive Swarm test entry',
);
}
if (
rootPackageConfig.scripts?.['agc:test'] !==
'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e --'
) {
throw new Error('agc:test must delegate to the deterministic playable E2E');
}
if (
rootPackageConfig.scripts?.['agc:config'] !==
'npm --prefix apps/ai-game-creator-shell run config --'
) {
throw new Error(
'agc:config must delegate to the AppData configuration wizard',
);
}
if (
rootPackageConfig.scripts?.['agc:test:chat'] !==
'npm --prefix apps/ai-game-creator-shell run test:chat --'
) {
throw new Error(
'agc:test:chat must delegate to the one-click Swarm test entry',
);
}
if (
rootPackageConfig.scripts?.['agc:test:chat:manual'] !==
'npm --prefix apps/ai-game-creator-shell run test:chat:manual --'
) {
throw new Error(
'agc:test:chat:manual must delegate to the interactive Swarm test entry',
);
}
for (const requiredSource of [
"export const appIdentifier = 'world.genarrative.ai-game-creator'",
"'--swarm-chat'",
"'--autonomous-game-build'",
"'--preview-serve'",
'cleanupSwarmTestProject(project)',
]) {
if (!swarmTestChatSource.includes(requiredSource)) {
throw new Error(`Swarm test entry contract drifted: ${requiredSource}`);
}
}
if (tauriConfig.productName !== '陶泥儿') {
throw new Error('AI game creator shell productName drifted');
}
if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
throw new Error('AI game creator shell identifier drifted');
}
const expectedBundledDesignAgentResources = {
'design-agent': 'design-agent',
};
const expectedBundledWindowsResources = {
'resources/codex/win-x64/bin/codex.exe': 'coding-agent/win-x64/bin/codex.exe',
'resources/codex/win-x64/bin/codex-code-mode-host.exe':
'coding-agent/win-x64/bin/codex-code-mode-host.exe',
'resources/codex/win-x64/codex-path/rg.exe':
'coding-agent/win-x64/codex-path/rg.exe',
'resources/codex/win-x64/codex-resources/codex-command-runner.exe':
'coding-agent/win-x64/codex-resources/codex-command-runner.exe',
'resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe':
'coding-agent/win-x64/codex-resources/codex-windows-sandbox-setup.exe',
'resources/codex/win-x64/codex-package.json':
'coding-agent/win-x64/codex-package.json',
'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',
};
assert.deepEqual(
tauriConfig.bundle?.resources,
expectedBundledDesignAgentResources,
'AI game creator shell base Tauri config must bundle the design-agent resource pack',
);
for (const key of Object.keys(tauriConfig.bundle?.resources ?? {})) {
if (String(key).includes('codex')) {
throw new Error(
'AI game creator shell base Tauri config must not require Windows-only Codex resources',
);
}
}
assert.deepEqual(
windowsTauriConfig.bundle?.resources,
expectedBundledWindowsResources,
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set and Cocos bridge payload directory',
);
if (
Object.prototype.hasOwnProperty.call(
windowsTauriConfig.bundle?.resources ?? {},
'design-agent',
)
) {
throw new Error(
'design-agent resource pack must not be mixed into the Windows Codex sidecar bundle',
);
}
if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
throw new Error(
'AI game creator shell Windows Tauri config must cache bundling tools in the project target directory',
);
}
if (tauriConfig.app?.withGlobalTauri !== true) {
throw new Error(
'AI game creator shell must expose window.__TAURI__ for local commands',
);
}
if (!fs.existsSync(eventCapabilityPath)) {
throw new Error(
'AI game creator shell must declare a Tauri event-listener capability',
);
}
const eventCapability = JSON.parse(
fs.readFileSync(eventCapabilityPath, 'utf8'),
);
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
for (const windowLabel of [
'client',
'developer',
'main',
'launcher',
'supervisor-chat',
]) {
if (!eventCapabilityWindows.has(windowLabel)) {
throw new Error(
`AI game creator shell event capability missing window: ${windowLabel}`,
);
}
}
for (const permission of [
'core:event:allow-listen',
'core:event:allow-unlisten',
]) {
if (!eventCapabilityPermissions.has(permission)) {
throw new Error(
`AI game creator shell event capability missing permission: ${permission}`,
);
}
}
if (
eventCapabilityPermissions.has('core:event:allow-emit') ||
eventCapabilityPermissions.has('core:event:allow-emit-to') ||
eventCapabilityPermissions.has('core:event:default')
) {
throw new Error(
'AI game creator shell frontend event capability must stay listen-only',
);
}
if (
!Array.isArray(tauriConfig.app?.windows) ||
tauriConfig.app.windows.length !== 1 ||
tauriConfig.app.windows[0]?.label !== 'client' ||
tauriConfig.app.windows[0]?.title !== '陶泥儿' ||
tauriConfig.app.windows[0]?.url !== 'index.html'
) {
throw new Error(
'AI game creator shell must start with only the client window',
);
}
if (defaultAppConfig.llm?.apiKey !== '') {
throw new Error('AI game creator shell default llm.apiKey must stay empty');
}
if (defaultAppConfig.agentMode !== 'codex_app_server') {
throw new Error(
'AI game creator shell default agentMode must be codex_app_server',
);
}
const allowedLlmReasoningEfforts = new Set([
'default',
'low',
'medium',
'high',
'max',
]);
if (defaultAppConfig.llm?.reasoningEffort !== 'max') {
throw new Error(
'AI game creator shell default llm.reasoningEffort must stay max',
);
}
for (const [agentId, agentConfig] of Object.entries(
defaultAppConfig.agentLlm ?? {},
)) {
if (
agentConfig?.reasoningEffort !== undefined &&
!allowedLlmReasoningEfforts.has(agentConfig.reasoningEffort)
) {
throw new Error(
`AI game creator shell agentLlm.${agentId}.reasoningEffort is invalid`,
);
}
}
if (defaultAppConfig.editorApi !== undefined) {
throw new Error(
'AI game creator shell ordinary default config must not contain editorApi',
);
}
if (
defaultAppConfig.llm?.requestTimeoutMs < 1000 ||
defaultAppConfig.llm?.maxRetries < 0 ||
defaultAppConfig.llm?.retryBackoffMs < 1
) {
throw new Error('AI game creator shell default LLM timing config is invalid');
}
const windows = tauriConfig.app?.windows ?? [];
if (
windows.length !== 1 ||
windows[0]?.label !== 'client' ||
windows[0]?.url !== 'index.html'
) {
throw new Error(
'AI game creator shell release config must expose only the client window',
);
}
const clientWindow = windows[0];
if (
clientWindow.width !== 1280 ||
clientWindow.height !== 800 ||
clientWindow.minWidth !== 1280 ||
clientWindow.minHeight !== 720
) {
throw new Error(
'AI game creator shell client window must default to 1280x800 and stay at least 1280x720',
);
}
if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') {
throw new Error(
'AI game creator shell Tauri config must retain the non-launcher fallback devUrl',
);
}
if (!viteConfigSource.includes("host: '127.0.0.1'")) {
throw new Error(
'AI game creator shell Vite dev server must bind to localhost',
);
}
if (
!viteConfigSource.includes('port: 3080') ||
!viteConfigSource.includes('port: server.config.server.port')
) {
throw new Error(
'AI game creator shell Vite config must retain its fallback and report the actual CLI-selected port',
);
}
for (const snippet of [
'mapDevPortsToPortRange',
'agcVitePort',
'resolveAgcDevEndpoint',
'GENARRATIVE_AGC_VITE_PORT',
]) {
if (!devPortSource.includes(snippet)) {
throw new Error(
`AI game creator shell dev port resolver drifted: ${snippet}`,
);
}
}
for (const snippet of [
'resolveAgcDevEndpoint',
'withAgcDevEndpointEnv',
"'--config'",
'configOverride',
]) {
if (!startTauriDevSource.includes(snippet)) {
throw new Error(
`AI game creator shell Tauri dev port injection drifted: ${snippet}`,
);
}
}
if (!viteConfigSource.includes('strictPort: true')) {
throw new Error(
'AI game creator shell Vite dev server must not drift away from Tauri devUrl',
);
}
if (!viteConfigSource.includes('allow: [repoRoot]')) {
throw new Error(
'AI game creator shell Vite dev server must allow shared repository sources',
);
}
if (
!(
tauriConfig.build?.beforeDevCommand?.includes(
'run ai-game-creator-shell:dev-server',
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
)
) {
throw new Error(
'AI game creator shell beforeDevCommand must start the selected Vite dev server',
);
}
if (packageConfig.scripts?.dev !== 'node scripts/start-tauri-dev.mjs') {
throw new Error(
'AI game creator shell dev must run through the managed Tauri dev launcher',
);
}
if (
!tauriHandlerSource.includes('.build(tauri_context)') ||
!tauriHandlerSource.includes('handle_game_creator_gui_run_event(&event)')
) {
throw new Error(
'AI game creator Tauri runtime must build from the prepared Context and preserve the generic GUI exit hook',
);
}
if (
!tauriConfig.build?.beforeBuildCommand?.includes('--config vite.config.ts')
) {
throw new Error(
'AI game creator shell beforeBuildCommand must resolve vite config from app root',
);
}
const releaseVersions = [
packageConfig.version,
tauriConfig.version,
cargoPackageVersion,
];
if (
releaseVersions.some((version) => !/^\d+\.\d+\.\d+$/u.test(version ?? '')) ||
new Set(releaseVersions).size !== 1
) {
throw new Error(
'AI game creator release versions must be valid three-part semver and synchronized',
);
}
const devServerSource = fs.readFileSync(
new URL('../scripts/start-dev-server.mjs', import.meta.url),
'utf8',
);
const runCliWithConfigSource = fs.readFileSync(
new URL('../scripts/run-cli-with-config.mjs', import.meta.url),
'utf8',
);
if (!runCliWithConfigSource.includes('resolveGameCreatorAppConfigDir')) {
throw new Error(
'AI game creator shell CLI wrapper must resolve the GUI AppData directory',
);
}
if (
!runCliWithConfigSource.includes(
"'--config-dir', resolveGameCreatorAppConfigDir()",
)
) {
throw new Error(
'AI game creator shell CLI wrapper must pass the GUI AppData directory to native commands',
);
}
for (const snippet of [
'resolveAgcDevEndpoint',
'withAgcDevEndpointEnv',
"response.body.includes('<title>陶泥儿</title>')",
'function isPortListening()',
'cannot be safely reused',
'non-HTTP or unrecognized server',
"'--config'",
"'vite.config.ts'",
"'--port'",
]) {
if (!devServerSource.includes(snippet)) {
throw new Error(
`AI game creator shell dev server wrapper drifted: ${snippet}`,
);
}
}
const configWizardSource = fs.readFileSync(
new URL('./game-creator-config-wizard.mjs', import.meta.url),
'utf8',
);
for (const snippet of [
'assertSafeGameCreatorConfigDestination',
'readGameCreatorWizardConfigState',
'$security.SetAccessRuleProtection($true, $false)',
'$targetItem = Get-Item -LiteralPath $target -Force',
'$targetItem.SetAccessControl($security)',
'$verified = $targetItem.GetAccessControl()',
'$rules.Count -ne 1',
'[System.Security.AccessControl.FileSystemRights]::FullControl',
"'-NoProfile'",
"'-Command'",
'windowsPrivateAclScript',
'await secureWindowsPath(temporaryPath, { isDirectory: false })',
'await temporaryFile.writeFile',
]) {
if (!configWizardSource.includes(snippet)) {
throw new Error(
`AI game creator config wizard guardrail drifted: ${snippet}`,
);
}
}
if (!/runChildCapture\(\s*['"]powershell\.exe['"]/u.test(configWizardSource)) {
throw new Error(
'AI game creator config wizard guardrail drifted: runChildCapture(powershell.exe)',
);
}
if (/\bGet-Acl\b/u.test(configWizardSource)) {
throw new Error(
'AI game creator config wizard must not rely on Get-Acl module auto-loading',
);
}
await runConfigWizardRegressionChecks();
await runHiddenInputRegressionChecks();
for (const snippet of [
"new URL('..', import.meta.url)",
"'--manifest-path'",
"'src-tauri/Cargo.toml'",
]) {
if (!runCliWithConfigSource.includes(snippet)) {
throw new Error(
`AI game creator shell config CLI wrapper drifted: ${snippet}`,
);
}
}
for (const snippet of [
'const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"',
'const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"',
'const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json")',
'fn configure_game_creator_runtime_config_dir(',
'game_creator_runtime_config_dir()',
'.unwrap_or_else(|| app.path().app_config_dir())?',
'fn load_game_creator_app_config()',
'fn read_game_creator_app_config()',
'fn write_game_creator_app_config(',
'fn writable_game_creator_config_path()',
'fn normalize_game_creator_app_config(',
'fn merge_game_creator_config_file(',
'.join("apps")',
'.join("ai-game-creator-shell")',
'read_game_creator_app_config,',
'write_game_creator_app_config,',
'resolve_game_creator_llm_config_for_agent(app_config, "planner")',
'resolve_game_creator_llm_config_for_agent(app_config, "generator")',
'build_game_creator_llm_client_from_llm_config(&planner_llm, "agentLlm.planner")?',
'build_game_creator_llm_client_from_llm_config(&generator_llm, "agentLlm.generator")?',
'agentLlm.{agent_id}',
'let app_config = match load_game_creator_app_config()',
'fn append_local_permission_log_at(',
'"command.auto"',
'GameCreationAppPermission::Auto',
'GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH',
'validate_game_creator_bundled_codex_cli',
'内置 Codex CLI 完整性校验失败',
]) {
if (!tauriRustSource.includes(snippet)) {
throw new Error(
`AI game creator shell developer window guardrail drifted: ${snippet}`,
);
}
}
const runtimeConfigSetupStart = tauriHandlerSource.indexOf(
'configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| {',
);
const runtimeConfigSetupEnd = tauriHandlerSource.indexOf(
'})?;',
runtimeConfigSetupStart,
);
const runtimeConfigSetupSource = tauriHandlerSource.slice(
runtimeConfigSetupStart,
runtimeConfigSetupEnd,
);
if (
runtimeConfigSetupStart === -1 ||
runtimeConfigSetupEnd === -1 ||
!runtimeConfigSetupSource.includes('sanitize_diagnostic_message(') ||
!runtimeConfigSetupSource.includes('setup_log.fail(') ||
!runtimeConfigSetupSource.includes(
'startup.appdata.configure.failed details={details}',
) ||
!tauriHandlerSource.includes('impl StartupLogSlot {') ||
!tauriHandlerSource.includes('append_bounded_diagnostic_line(&path, line)') ||
!tauriHandlerSource.includes(
'self.append(line);\n show_startup_error_dialog(self.path().as_deref());',
) ||
!tauriHandlerSource.includes('early_startup_log_path(')
) {
throw new Error(
'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures',
);
}
for (const snippet of [
'import.meta.env.DEV',
'supervisorChatMode',
'supervisorChatOnly',
'open_project_supervisor_chat_window',
'index.html?supervisor-chat&projectPath=',
]) {
if (!`${appEntrypointSource}\n${tauriRustSource}`.includes(snippet)) {
throw new Error(
`AI game creator shell developer window guardrail drifted: ${snippet}`,
);
}
}
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
throw new Error(
'AI game creator normal startup must not automatically open the developer window',
);
}
const smokeAgentRunSource = fs.readFileSync(
new URL('./smoke-agent-run-local-provider.mjs', import.meta.url),
'utf8',
);
for (const snippet of [
'agentLlm',
'planner-smoke-model',
'generator-smoke-model',
'global-smoke-model-unused',
]) {
if (!smokeAgentRunSource.includes(snippet)) {
throw new Error(
`AI game creator local-provider smoke lost per-agent LLM coverage: ${snippet}`,
);
}
}
for (const snippet of [
"'game.run_local'",
"'read_game_creator_app_config'",
"'write_game_creator_app_config'",
'aria-label="运行时配置"',
'陶泥儿智能创作(固定)',
'官方账号服务(固定)',
'runtime_config.save',
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
"'activate_local_game_preview'",
'已切换到客户端运行视图',
'async function executeRunLocal',
'function needsInitializedChatProject',
'function resolvePendingCommandProjectPath',
'resolveChatProjectPath(localProject) ?? draftProjectPath',
'`permission.cancel ${command.id} missing-project`',
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
'function parseRememberInput',
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
'async function executeAgentTraceChat',
"relativePath: '.agent/logs/command.log'",
"'permission.pending'",
"'permission.confirm'",
"'permission.cancel'",
"'command.auto'",
"'agent.run_status'",
'function summarizeAgentRunTrace',
'工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}',
'agentRunTrace.error ?',
'className="trace-error"',
'agentRunTrace.taskGraph.repairRoutes.map',
"in: ${step.inputPaths.join(', ') || 'none'}",
"out: ${step.outputPaths.join(', ') || 'none'}",
]) {
if (!appSource.includes(snippet)) {
throw new Error(
`AI game creator shell trace panel guardrail drifted: ${snippet}`,
);
}
}
for (const script of [
'ai-game-creator-shell:dev',
'ai-game-creator-shell:dev-server',
'ai-game-creator-shell:build',
'ai-game-creator-shell:agent-task',
'agc:swarm',
'ai-game-creator-shell:typecheck',
'ai-game-creator-shell:agent-run:smoke',
'ai-game-creator-shell:check',
]) {
if (!rootPackageConfig.scripts?.[script]) {
throw new Error(`root package missing ${script}`);
}
}
if (
rootPackageConfig.scripts?.['agc:swarm'] !==
'npm --prefix apps/ai-game-creator-shell run swarm --'
) {
throw new Error('root agc:swarm script must forward CLI args');
}
if (
rootPackageConfig.scripts?.['ai-game-creator-shell:build'] !==
'npm --prefix apps/ai-game-creator-shell run build --'
) {
throw new Error(
'root ai-game-creator-shell:build script must forward build args',
);
}
const agentRunSmokeSource = fs.readFileSync(
new URL('../scripts/smoke-agent-run-local-provider.mjs', import.meta.url),
'utf8',
);
for (const snippet of [
"const smokeAssetMarker = 'SMOKE_LOCAL_ASSET:chef'",
"const smokeAudioAssetPath = 'assets/uploads/smoke-bounce.mp3'",
"mediaType: 'audio/mpeg'",
'document.body.dataset.smokeFrame = String(frameCount)',
"ctx.fillStyle = '#ff00ff'",
'ctx.fillRect(4, 4, 8, 24)',
'const sample = ctx.getImageData(4, 4, 24, 24).data',
'chef.complete && chef.naturalWidth > 0',
'document.body.dataset.smokeCanvasPixels = String(litPixels)',
'document.body.dataset.smokeCanvasColors = String(colors.size)',
'readBrowserDom(previewUrl)',
"extractDomNumber(previewDom, 'smoke-canvas-pixels')",
'--dump-dom',
'readHttpHead(new URL(smokeAssetPath, previewUrl).toString())',
'readHttpHead(new URL(smokeAudioAssetPath, previewUrl).toString())',
'function writeStreamingChatCompletion',
'requestJson?.stream === true',
`requestBodies.every((body) => body.includes('"stream":true'))`,
"const localConfigPath = path.join(appRoot, 'game-creator.config.local.json')",
"apiKind: 'openai_chat'",
'stream: true',
'await restoreOptionalFile(localConfigPath, previousLocalConfig)',
"method: 'HEAD'",
'previewAssetHead.contentLength === String(smokeAssetBytes.length)',
"previewAudioHead.contentType === 'audio/mpeg'",
"previewAssetHead.body === ''",
'trace missing professional group ${group}',
]) {
if (!agentRunSmokeSource.includes(snippet)) {
throw new Error(
`AI game creator shell agent-run smoke drifted: ${snippet}`,
);
}
}