6a0b75779b
- 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、缺非宿主运行时、版本不一致、按运行架构解析等用例
417 lines
14 KiB
JavaScript
417 lines
14 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;
|
||
}
|
||
|
||
/**
|
||
* 单独执行随包 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 });
|
||
}
|