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); } // 随包 Node 的 npm 是包里唯一允许出现的 node_modules:除了 npm 目录自身与它的子项, // 还要放行它的上级目录 `game-runtime/node/node_modules`(recursive readdir 会列出目录项, // 少了这一条会让整个门禁对合法包失败——#439 引入后一直没被跑到,直到 2026-09-21 才暴露)。 const allowedNodeModules = (file) => file === 'game-runtime/node/node_modules' || file === 'game-runtime/node/node_modules/npm' || file.startsWith('game-runtime/node/node_modules/npm/'); 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) && !allowedNodeModules(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 }); }