Files
Genarrative/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs
T
suzmii 6a0b75779b AGC 随包 Node 支持 universal 双架构运行时
- stage-node-runtime 新增 targetRuntimes:universal 展开为 aarch64/x86_64 两份单架构运行时
- 新增 runtimeSourceForTarget:宿主架构取当前构建 Node,其它架构必须由 AGC_NODE_RUNTIME_<PLATFORM>_<ARCH>_PATH 显式提供,缺失即失败关闭
- 把 stageNodeRuntime 拆成 inspectRuntimeSource(只读校验)+ writeRuntimeBundle(落盘),universal 在写出任何目录前完成全部来源校验与版本一致性核对
- stageNodeRuntimeForTarget 单架构仍写扁平 node-runtime/,universal 按架构写 node-runtime/<platform>-<arch>/
- build-release 默认按发布目标 stage,universal 构建不再只带宿主架构的运行时
- environment_check resolve_at 在扁平目录之后按当前运行架构查找 <platform>-<arch> 子目录,两份候选都要过清单校验,存在但损坏则失败关闭
- 覆盖双架构 staging、缺非宿主运行时、版本不一致、按运行架构解析等用例
2026-09-21 11:57:43 +08:00

417 lines
14 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 { 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;
}
/**
* 单独执行随包 Node 分片:分片架构与宿主一致时直接跑,不一致时用 `arch` 强制
* (arm64 机器上的 x86_64 分片依赖 Rosetta,与 `.app` 双架构 smoke 的前提相同)。
*/
function runNodeSlice(directory, args) {
const native = process.arch === 'arm64' ? 'arm64' : 'x86_64';
const binary = path.join(directory, 'node');
const [command, argv] =
architecture === native
? [binary, args]
: ['/usr/bin/arch', [`-${architecture}`, binary, ...args]];
const result = spawnSync(command, argv, {
cwd: root,
env,
encoding: 'utf8',
timeout: 120_000,
maxBuffer: 1024 * 1024,
});
assert.ifError(result.error);
assert.equal(
result.status,
0,
`${directory} 随包 Node 执行失败:${result.stderr || result.stdout}`,
);
return result.stdout.trim();
}
/**
* 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')));
// 随包 Node:单架构构建是扁平目录,universal 构建把两套架构运行时并列放进
// `game-runtime/node/<platform>-<arch>/`(与 Codex 侧车同形态,由 Rust 侧按运行架构选择)。
// 两套清单在本函数里都做完整性与架构校验;只执行与本次 smoke 架构一致的那一份,
// 另一份由另一次架构的 smoke 覆盖(build-macos-ci.mjs 会对 arm64 / x86_64 各跑一次)。
const nodeRoot = path.join(resources, 'game-runtime/node');
const nodeSlices = requireUniversal
? ['darwin-arm64', 'darwin-x64'].map((platform) => ({
platform,
directory: path.join(nodeRoot, platform),
}))
: [{ platform: null, directory: nodeRoot }];
for (const slice of nodeSlices) {
const { directory } = slice;
const nodeManifest = JSON.parse(
fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'),
);
assert.equal(nodeManifest.schemaVersion, 'agc-node-runtime.v1');
assert.equal(nodeManifest.platform, 'darwin');
if (slice.platform) {
// 目录名与清单架构必须一致:错位会让用户拿到跑不起来的运行时。
assert.equal(`darwin-${nodeManifest.arch}`, slice.platform);
assert.ok(
/^(arm64|x64)$/u.test(nodeManifest.arch),
`未知运行架构:${nodeManifest.arch}`,
);
} else {
assert.equal(nodeManifest.arch, process.arch);
}
const runtimeFiles = fs
.readdirSync(directory, { recursive: true })
.filter(
(file) =>
fs.statSync(path.join(directory, file)).isFile() &&
file !== 'manifest.json',
);
assert.deepEqual(
runtimeFiles.sort(),
Object.keys(nodeManifest.files).sort(),
slice.platform ?? 'flat',
);
for (const [file, digest] of Object.entries(nodeManifest.files)) {
assert.equal(await hashFile(path.join(directory, file)), digest, file);
}
assert.ok(nodeManifest.files['NODE-LICENSE']);
assert.ok(nodeManifest.files['node_modules/npm/LICENSE']);
fs.accessSync(path.join(directory, 'node'), fs.constants.X_OK);
// 二进制本身必须是本分片的单一架构:官方发行版不做 universal,lipo 能直接证明。
const sliceArchitecture = spawnSync(
'/usr/bin/lipo',
['-archs', path.join(directory, 'node')],
{ encoding: 'utf8' },
);
assert.equal(sliceArchitecture.status, 0, 'lipo -archs node');
assert.equal(
sliceArchitecture.stdout.trim(),
nodeManifest.arch === 'x64' ? 'x86_64' : 'arm64',
'随包 Node 的二进制架构必须等于清单架构',
);
// 只有与本次 smoke 架构一致的分片才执行;另一架构留给对应的那次 smoke。
if (
!requireUniversal ||
nodeManifest.arch === (architecture === 'arm64' ? 'arm64' : 'x64')
) {
assert.equal(
runNodeSlice(directory, ['--version']),
nodeManifest.nodeVersion,
`${slice.platform ?? 'flat'} node --version`,
);
assert.equal(
runNodeSlice(directory, [
path.join(directory, 'node_modules/npm/bin/npm-cli.js'),
'--version',
]),
nodeManifest.npmVersion,
`${slice.platform ?? 'flat'} npm --version`,
);
}
}
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 自带的 npm 允许出现 node_modules;两种布局都要放行:
// 单架构的 `game-runtime/node/node_modules/npm` 与 universal 的
// `game-runtime/node/<platform>-<arch>/node_modules/npm`。
(/(^|\/)node_modules(\/|$)/.test(file) &&
!/^game-runtime\/node\/((darwin-(arm64|x64))\/)?node_modules\/npm(\/|$)/u.test(
file,
)),
),
);
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 });
}