485ed50b26
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 2/4 (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 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
## 背景 净月潭案例(2026-09-19 21:53 → 09-20 01:26,3 小时 33 分)的问题不是模型慢,而是环境未就绪、验收靠模型自述、预算只约束单个工具入口、工具调用被 SDK 全局串行闸门卡住。本 PR 落地确认后的七项交付效率合同,并把捆绑 Codex 升到当前 npm latest。 ## 结果 - 新建 Web 游戏在正式生成前由宿主自动预检:捆绑 Node/npm、真实 Vite 构建、受限浏览器桌面/移动截图;失败不启动生成或付费素材。 - 首个副作用前冻结交付合同,宿主保存权威证据与预算;证据齐全后先封口、排空并取得执行器完整退出证明,再产出交付报告,模型回复不再当作验收。 - 原生 shell、内置浏览器与托管命令、第三方 MCP 共用同一执行许可和累计执行时间;`validation.maxRuns` 改为执行/返修批次语义,另设 `maxTurnSeconds` 墙钟上限。 - 所有工具可并发:移除 SDK 全局串行的 `apply_patch`/`update_plan` 注册,改由宿主 `agc_apply_patch`(官方 parser + 当前回合写许可 + 受控进程树 + 短项目事务)和 `agc_update_plan` 提供等价能力;真实请求目录里已无串行注册,长 MCP 与补丁/计划实测在同一响应内重叠。 - 付费提交与本地写入绑定原回合原租约:容量与同动作锁等待可取消,封口、取消或预算耗尽后零新增提交;已越过提交边界的请求保留 operation ID 走 GET 对账,不自动重放。 - 新增请求与工具分段计时、有界并行批读和首轮上下文预取,未知耗时不补零。 - 捆绑 Codex 0.147.0 → 0.155.1:固定版本收敛到 `build_support/codex_bundle.rs` 单一声明,vendor 解析源码按 `rust-v0.155.1` 逐字节重取并更新 UPSTREAM 证据,适配 0.155 统一 exec(`exec_command`/`write_stdin`);macOS 侧车最小系统版本仍为 15.0。 ## 验证 - 生产 CLI → 捆绑 Codex 0.155.1 → 本地 Responses/MCP 夹具 9/9:补丁、完成收尾、批次、只读/可写 MCP 并发、原生命令并发、原生资源并发、统一 exec 会话、期限终止(真实重叠 775 / 764 / 999 ms)。 - 真实模型目录 2/2;vendor 上游库 111 项;宿主定向与回归 106 项;真实 Windows 进程与取消 21 项;Node 侧门禁 52 项。 - 发行载荷:artifact-only 重建 NSIS,解包验证侧车清单 `codex-cli 0.155.1`、6 个组件哈希、打包后 `bin/codex.exe --version`、Node 运行时 2130 文件与双端 PNG、`--environment-check` ready。 - `check-config`、TypeScript、编码(4991 文件)、文档索引、`git diff --check`、`cargo fmt --check` 全部通过。 ## 未覆盖 - 真实陶泥儿登录态 Provider 生成尚未执行(本机 `--llm-status` 返回 `authentication-required`)。 - macOS 侧车只在本机做静态 Mach-O 与清单解析,未在 macOS 上跑 `check-macos-bundle.mjs`。 - 全仓库聚合套件在本机仍因负载敏感的后台 mock-provider 用例而红,与本次改动无关:改动前的旧二进制同样失败,换单线程后相关用例 3/3 通过。 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/439
338 lines
11 KiB
JavaScript
338 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import { spawn, spawnSync } from 'node:child_process';
|
||
import { createHash } from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
|
||
// 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。
|
||
assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行');
|
||
const source = path.resolve(process.argv[2] || '');
|
||
const architecture =
|
||
process.argv[3] || (process.arch === 'arm64' ? 'arm64' : 'x86_64');
|
||
assert.ok(
|
||
['arm64', 'x86_64'].includes(architecture),
|
||
'架构只接受 arm64 / x86_64',
|
||
);
|
||
const requireUniversal = process.argv.includes('--universal');
|
||
assert.ok(
|
||
source.endsWith('.app') && fs.statSync(source).isDirectory(),
|
||
'请传入 .app 绝对路径',
|
||
);
|
||
const root = fs.realpathSync(
|
||
fs.mkdtempSync(path.join(os.tmpdir(), 'agc-macos-bundle-')),
|
||
);
|
||
// 产品名从传入的 .app 推导,不在校验脚本里写死;改名后校验对象仍指向同一个包。
|
||
const appBundleName = path.basename(source);
|
||
const app = path.join(root, `隔离-${appBundleName}`);
|
||
// 侧车清单版本必须等于锁定的 @openai/codex 版本,避免两处固定版本漂移。
|
||
const appPackage = JSON.parse(
|
||
fs.readFileSync(
|
||
path.join(
|
||
path.dirname(new URL(import.meta.url).pathname),
|
||
'../package.json',
|
||
),
|
||
'utf8',
|
||
),
|
||
);
|
||
const pinnedCodexVersion =
|
||
appPackage.dependencies?.['@openai/codex'] ??
|
||
appPackage.devDependencies?.['@openai/codex'] ??
|
||
appPackage.optionalDependencies?.['@openai/codex'];
|
||
assert.match(
|
||
pinnedCodexVersion,
|
||
/^\d+\.\d+\.\d+$/u,
|
||
'package.json 必须锁定精确的 @openai/codex 版本',
|
||
);
|
||
const home = path.join(root, 'home');
|
||
const config = path.join(root, 'config');
|
||
const tmp = path.join(root, 'tmp');
|
||
const codexHome = path.join(root, 'codex-home');
|
||
for (const directory of [home, config, tmp, codexHome]) {
|
||
fs.mkdirSync(directory, { mode: 0o700 });
|
||
}
|
||
const env = {
|
||
HOME: home,
|
||
PATH: '/usr/bin:/bin',
|
||
TMPDIR: tmp,
|
||
CODEX_HOME: codexHome,
|
||
};
|
||
|
||
function run(command, args) {
|
||
// 只强制被测应用切片;本机 Xcode 检查工具可能仅提供宿主架构。
|
||
const useSlice = command.startsWith(`${app}${path.sep}`);
|
||
const result = spawnSync(
|
||
useSlice ? '/usr/bin/arch' : command,
|
||
useSlice ? [`-${architecture}`, command, ...args] : args,
|
||
{
|
||
cwd: root,
|
||
env,
|
||
encoding: 'utf8',
|
||
timeout: 120_000,
|
||
maxBuffer: 1024 * 1024,
|
||
},
|
||
);
|
||
assert.ifError(result.error);
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* APFS 上优先用 `ditto --clone`:整包按区块克隆,秒级完成且几乎不占额外空间。
|
||
* 跨卷或非 APFS 时回退到真实复制;两种路径都必须产出可独立改动的副本,
|
||
* 因为「缺组件拒绝」用例会在副本里改名文件。
|
||
*/
|
||
function copyBundle(from, to) {
|
||
const cloned = spawnSync('/usr/bin/ditto', ['--clone', from, to], {
|
||
encoding: 'utf8',
|
||
});
|
||
if (
|
||
cloned.status === 0 &&
|
||
fs.existsSync(path.join(to, 'Contents/Info.plist'))
|
||
) {
|
||
return 'clone';
|
||
}
|
||
fs.cpSync(from, to, { recursive: true });
|
||
return 'copy';
|
||
}
|
||
|
||
/** 可执行名以包内 Info.plist 为准:它是稳定契约,但没必要在校验脚本里重复硬编码。 */
|
||
function readBundleExecutable(appPath) {
|
||
const plist = path.join(appPath, 'Contents/Info.plist');
|
||
const result = spawnSync(
|
||
'/usr/libexec/PlistBuddy',
|
||
['-c', 'Print :CFBundleExecutable', plist],
|
||
{ encoding: 'utf8' },
|
||
);
|
||
const name = (result.stdout ?? '').trim();
|
||
assert.ok(
|
||
name.length > 0,
|
||
`无法从 Info.plist 读取 CFBundleExecutable:${plist}`,
|
||
);
|
||
return name;
|
||
}
|
||
|
||
async function hashFile(file) {
|
||
const hash = createHash('sha256');
|
||
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
|
||
return hash.digest('hex');
|
||
}
|
||
|
||
async function handshake(executable) {
|
||
const child = spawn(executable, ['app-server'], {
|
||
cwd: root,
|
||
env,
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
});
|
||
let buffered = '';
|
||
let stderrBytes = 0;
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
const timer = setTimeout(
|
||
() => reject(new Error('app-server 初始化超时')),
|
||
120_000,
|
||
);
|
||
const finish = (error) => {
|
||
clearTimeout(timer);
|
||
if (error) reject(error);
|
||
else resolve();
|
||
};
|
||
child.on('error', finish);
|
||
child.on('exit', (code) =>
|
||
finish(new Error(`app-server 提前退出 ${code}`)),
|
||
);
|
||
child.stderr.on('data', (chunk) => {
|
||
stderrBytes += chunk.length;
|
||
if (stderrBytes > 1024 * 1024)
|
||
finish(new Error('app-server stderr 超限'));
|
||
});
|
||
child.stdout.on('data', (chunk) => {
|
||
buffered += chunk.toString('utf8');
|
||
if (buffered.length > 1024 * 1024)
|
||
return finish(new Error('app-server stdout 超限'));
|
||
let end;
|
||
while ((end = buffered.indexOf('\n')) >= 0) {
|
||
const line = buffered.slice(0, end);
|
||
buffered = buffered.slice(end + 1);
|
||
try {
|
||
const message = JSON.parse(line);
|
||
if (message.id !== 1) continue;
|
||
assert.ok(message.result?.userAgent, '初始化必须返回真实服务身份');
|
||
assert.equal(message.error, undefined);
|
||
child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
|
||
finish();
|
||
} catch (error) {
|
||
finish(error);
|
||
}
|
||
}
|
||
});
|
||
child.stdin.on('error', finish);
|
||
child.stdin.write(
|
||
`${JSON.stringify({
|
||
id: 1,
|
||
method: 'initialize',
|
||
params: {
|
||
clientInfo: {
|
||
name: 'agc_bundle_smoke',
|
||
title: 'AGC bundle smoke',
|
||
version: '1',
|
||
},
|
||
capabilities: { experimentalApi: true },
|
||
},
|
||
})}\n`,
|
||
);
|
||
});
|
||
} finally {
|
||
if (child.exitCode === null && child.signalCode === null) {
|
||
await new Promise((resolve) => {
|
||
const timer = setTimeout(() => child.kill('SIGKILL'), 3000);
|
||
child.once('exit', () => {
|
||
clearTimeout(timer);
|
||
resolve();
|
||
});
|
||
child.kill('SIGTERM');
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
try {
|
||
const copiedWith = copyBundle(source, app);
|
||
const resources = path.join(app, 'Contents/Resources');
|
||
const platform = architecture === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
|
||
const bundle = path.join(resources, 'coding-agent/mac-native', platform);
|
||
const executable = path.join(bundle, 'bin/codex');
|
||
const main = path.join(app, 'Contents/MacOS', readBundleExecutable(app));
|
||
const mainArchitectures = run('/usr/bin/lipo', ['-archs', main]);
|
||
assert.equal(mainArchitectures.status, 0);
|
||
assert.ok(mainArchitectures.stdout.split(/\s+/).includes(architecture));
|
||
if (requireUniversal) {
|
||
assert.deepEqual(mainArchitectures.stdout.trim().split(/\s+/).sort(), [
|
||
'arm64',
|
||
'x86_64',
|
||
]);
|
||
for (const platform of ['darwin-arm64', 'darwin-x64']) {
|
||
assert.ok(
|
||
fs.existsSync(
|
||
path.join(
|
||
resources,
|
||
'coding-agent/mac-native',
|
||
platform,
|
||
'manifest.json',
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
const manifest = JSON.parse(
|
||
fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'),
|
||
);
|
||
assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2');
|
||
assert.equal(manifest.platform, platform);
|
||
assert.equal(manifest.version, `codex-cli ${pinnedCodexVersion}`);
|
||
const components = [
|
||
'bin/codex',
|
||
'bin/codex-code-mode-host',
|
||
'codex-path/rg',
|
||
'codex-resources/zsh/bin/zsh',
|
||
'codex-package.json',
|
||
];
|
||
assert.deepEqual(Object.keys(manifest.files).sort(), [...components].sort());
|
||
for (const component of components) {
|
||
const file = path.join(bundle, component);
|
||
assert.equal(await hashFile(file), manifest.files[component], component);
|
||
if (component !== 'codex-package.json') {
|
||
fs.accessSync(file, fs.constants.X_OK);
|
||
const arch = run('/usr/bin/lipo', ['-archs', file]);
|
||
assert.equal(arch.status, 0, component);
|
||
assert.equal(arch.stdout.trim(), architecture, component);
|
||
}
|
||
}
|
||
assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md')));
|
||
const nodeRoot = path.join(resources, 'game-runtime/node');
|
||
const nodeManifest = JSON.parse(
|
||
fs.readFileSync(path.join(nodeRoot, 'manifest.json'), 'utf8'),
|
||
);
|
||
assert.equal(nodeManifest.schemaVersion, 'agc-node-runtime.v1');
|
||
assert.equal(nodeManifest.platform, 'darwin');
|
||
assert.equal(nodeManifest.arch, process.arch);
|
||
const runtimeFiles = fs
|
||
.readdirSync(nodeRoot, { recursive: true })
|
||
.filter(
|
||
(file) =>
|
||
fs.statSync(path.join(nodeRoot, file)).isFile() &&
|
||
file !== 'manifest.json',
|
||
);
|
||
assert.deepEqual(runtimeFiles.sort(), Object.keys(nodeManifest.files).sort());
|
||
for (const [file, digest] of Object.entries(nodeManifest.files)) {
|
||
assert.equal(await hashFile(path.join(nodeRoot, file)), digest, file);
|
||
}
|
||
assert.ok(nodeManifest.files['NODE-LICENSE']);
|
||
assert.ok(nodeManifest.files['node_modules/npm/LICENSE']);
|
||
assert.equal(
|
||
run(path.join(nodeRoot, 'node'), ['--version']).stdout.trim(),
|
||
nodeManifest.nodeVersion,
|
||
);
|
||
assert.equal(
|
||
run(path.join(nodeRoot, 'node'), [
|
||
path.join(nodeRoot, 'node_modules/npm/bin/npm-cli.js'),
|
||
'--version',
|
||
]).stdout.trim(),
|
||
nodeManifest.npmVersion,
|
||
);
|
||
const plugin = path.join(resources, 'plugins/agc-cocos-editor');
|
||
for (const file of [
|
||
'plugin.json',
|
||
'src/entry.mjs',
|
||
'panels/cocos-editor.html',
|
||
]) {
|
||
assert.ok(fs.existsSync(path.join(plugin, file)), file);
|
||
}
|
||
const packageFiles = fs.readdirSync(resources, { recursive: true });
|
||
assert.ok(
|
||
!packageFiles.some(
|
||
(file) =>
|
||
/(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/.test(
|
||
file,
|
||
) ||
|
||
(/(^|\/)node_modules(\/|$)/.test(file) &&
|
||
!file.startsWith('game-runtime/node/node_modules/npm')),
|
||
),
|
||
);
|
||
assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
|
||
assert.equal(
|
||
run(path.join(bundle, 'codex-path/rg'), ['--version']).status,
|
||
0,
|
||
);
|
||
assert.equal(
|
||
run(path.join(bundle, 'codex-resources/zsh/bin/zsh'), ['--version']).status,
|
||
0,
|
||
);
|
||
|
||
// 使用正式 AGC 查找/校验入口,而非只证明 sidecar 可以独立执行。
|
||
const status = run(main, ['--config-dir', config, '--llm-status']);
|
||
const statusText = `${status.stdout}\n${status.stderr}`;
|
||
assert.ok(!statusText.includes('Codex CLI 未安装'), statusText);
|
||
assert.ok(
|
||
statusText.includes('authentication-required'),
|
||
'隔离账号应仅被登录门禁拒绝',
|
||
);
|
||
await handshake(executable);
|
||
|
||
// 临时复制品缺少辅助程序时,正式入口必须拒绝内置程序;PATH 无全局 Codex 可兜底。
|
||
fs.renameSync(
|
||
path.join(bundle, 'bin/codex-code-mode-host'),
|
||
path.join(root, 'saved-code-mode-host'),
|
||
);
|
||
const broken = run(main, ['--config-dir', config, '--llm-status']);
|
||
assert.notEqual(broken.status, 0);
|
||
assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/);
|
||
console.log(
|
||
`PASS (${architecture}, 副本=${copiedWith}): 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝`,
|
||
);
|
||
console.log(
|
||
'未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提',
|
||
);
|
||
} finally {
|
||
fs.rmSync(root, { recursive: true, force: true });
|
||
}
|