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] || ''); assert.ok( source.endsWith('.app') && fs.statSync(source).isDirectory(), '请传入 .app 绝对路径', ); const root = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'agc-macos-bundle-')), ); const app = path.join(root, '陶泥儿 隔离测试.app'); 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) { const result = spawnSync(command, args, { cwd: root, env, encoding: 'utf8', timeout: 30_000, maxBuffer: 1024 * 1024, }); assert.ifError(result.error); return result; } 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 初始化超时')), 15_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 { fs.cpSync(source, app, { recursive: true }); const resources = path.join(app, 'Contents/Resources'); const bundle = path.join(resources, 'coding-agent/mac-native'); const executable = path.join(bundle, 'bin/codex'); const main = path.join( app, 'Contents/MacOS/genarrative-ai-game-creator-shell', ); const manifest = JSON.parse( fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'), ); assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2'); assert.equal( manifest.platform, process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64', ); assert.equal(manifest.version, 'codex-cli 0.147.0'); 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(), process.arch === 'arm64' ? 'arm64' : 'x86_64', component, ); } } assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md'))); 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|node_modules|target|\.git)(\/|$)|\.(exe|dll)$/.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: 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝', ); console.log( '未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提', ); } finally { fs.rmSync(root, { recursive: true, force: true }); }