| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488 |
- <!doctype html>
- <html lang="zh">
- <head>
- <meta charset="utf-8" />
- <title>Codex IPC 探针</title>
- <style>
- body { font: 12px/1.6 Consolas, monospace; margin: 16px; }
- .ok { color: #1a7f37; }
- .bad { color: #c00; }
- </style>
- </head>
- <body>
- <pre id="out">Codex IPC probe running…</pre>
- <script>
- /**
- * 真实 Electron IPC 链路探针:在应用窗口内逐条调用 controller/codexCtl/*,
- * 覆盖结构化克隆(含 Uint8Array 入参)、错误包装、以及真实 codex 子进程往返。
- *
- * 用法(env 开关,不污染常规 dev 启动):
- * ZSJZ_CODEX_PROBE=file:///<repo>/ai-electron/scripts/codex-ipc-probe.html \
- * ZSJZ_CODEX_PROBE_OUT=./scripts/codex-ipc-probe.json npx electron . --env=local
- */
- const { ipcRenderer } = require('electron');
- const fs = require('node:fs');
- const os = require('node:os');
- const path = require('node:path');
- const outFile = process.env.ZSJZ_CODEX_PROBE_OUT || path.join(os.tmpdir(), 'codex-ipc-probe.json');
- const results = [];
- const pre = document.getElementById('out');
- const call = (method, params) => ipcRenderer.invoke(`controller/codexCtl/${method}`, params);
- /** 既有的 osCtl controller(窗口控制 / 打开本机文件等) */
- const osCall = (method, params) => ipcRenderer.invoke(`controller/osCtl/${method}`, params);
- /** 下载管理器 controller(controller/downloadCtl/*) */
- const dlCall = (method, params) => ipcRenderer.invoke(`controller/downloadCtl/${method}`, params);
- function log(name, ok, detail) {
- results.push({ name, ok, detail });
- pre.innerHTML += `\n<span class="${ok ? 'ok' : 'bad'}">${ok ? 'PASS' : 'FAIL'} ${name}</span> ${JSON.stringify(detail).slice(0, 300)}`;
- }
- function isRpc(value) {
- return Boolean(value) && typeof value === 'object' && typeof value.success === 'boolean';
- }
- async function check(name, run, assert) {
- try {
- const rpc = await run();
- if (!isRpc(rpc)) {
- log(name, false, { reason: '返回不是 {success,data,message} 结构', rpc });
- return null;
- }
- const problem = assert ? assert(rpc) : rpc.success === false ? rpc.message : null;
- log(name, !problem, problem || (rpc.success ? rpc.data : { message: rpc.message }));
- return rpc;
- } catch (error) {
- log(name, false, { reason: 'invoke 直接 reject(说明主进程抛到了 Electron 层)', error: String(error) });
- return null;
- }
- }
- /** 断言「应当失败」:失败信息要回来,且不能被 Electron 包成 handler 错误 */
- function expectFailure(pattern) {
- return (rpc) => {
- if (rpc.success) return '本该失败却成功了';
- if (!pattern.test(rpc.message)) return `错误信息不匹配 ${pattern}: ${rpc.message}`;
- return null;
- };
- }
- function expectData(verify) {
- return (rpc) => {
- if (!rpc.success) return rpc.message;
- return verify(rpc.data) ? null : `数据不符合预期: ${JSON.stringify(rpc.data).slice(0, 200)}`;
- };
- }
- async function main() {
- pre.textContent = '';
- const tmpSkill = fs.mkdtempSync(path.join(os.tmpdir(), 'zsjz-probe-skill-'));
- fs.writeFileSync(
- path.join(tmpSkill, 'SKILL.md'),
- '---\nname: ipc-probe-skill\ndescription: 探针安装的技能\n---\n\n# probe\n',
- 'utf8',
- );
- await check('ping', () => call('ping'), expectData((d) => d.available === true && /codex-cli/.test(d.version || '')));
- await check('status', () => call('status'), expectData((d) => typeof d.running === 'boolean' && Boolean(d.codexHome)));
- await check('start', () => call('start'), expectData((d) => d.running === true));
- await check('listModels', () => call('listModels'), (rpc) => (rpc.success && Array.isArray(rpc.data) ? null : rpc.message));
- await check(
- 'mcpSave 含中文/空格参数',
- () => call('mcpSave', { id: 'probe-stdio', transport: 'stdio', command: 'node', args: ['-e', '过程 输出'], enabled: true }),
- expectData((list) => list.some((item) => item.id === 'probe-stdio' && item.args[1] === '过程 输出')),
- );
- await check('mcpList', () => call('mcpList'), expectData((list) => list.some((item) => item.id === 'probe-stdio')));
- await check('mcpSave 非法 id', () => call('mcpSave', { id: '有 空格', transport: 'stdio', command: 'x', enabled: true }), expectFailure(/MCP ID/));
- await check('mcpSave 明文 http 远程', () => call('mcpSave', { id: 'a', transport: 'http', url: 'http://10.0.0.8/mcp', enabled: true }), expectFailure(/HTTPS/));
- await check('mcpRemove 缺 id', () => call('mcpRemove', {}), expectFailure(/不能为空/));
- await check('mcpRemove', () => call('mcpRemove', { id: 'probe-stdio' }), expectData((list) => !list.some((item) => item.id === 'probe-stdio')));
- await check('skillInstallFolder', () => call('skillInstallFolder', { srcPath: tmpSkill, overwrite: true }), expectData((list) => list.some((s) => s.name === 'ipc-probe-skill' && s.managed)));
- await check('skillSetEnabled false', () => call('skillSetEnabled', { name: 'ipc-probe-skill', enabled: false }), expectData((d) => d.effectiveEnabled === false));
- await check('skillRead', () => call('skillRead', { name: 'ipc-probe-skill' }), expectData((d) => d.content.includes('ipc-probe-skill')));
- await check('skillInstallZip 非 zip 字节', () => call('skillInstallZip', { data: new Uint8Array([1, 2, 3, 4]) }), expectFailure(/无法解析 zip/));
- await check('skillRemove 内置', () => call('skillList', { forceReload: true }).then((rpc) => (rpc.success && rpc.data.find((s) => !s.managed) ? call('skillRemove', { path: rpc.data.find((s) => !s.managed).path }) : rpc)), expectFailure(/内置或暂存目录/));
- await check('skillRemove', () => call('skillRemove', { name: 'ipc-probe-skill' }), expectData((list) => !list.some((s) => s.name === 'ipc-probe-skill')));
- await check('删除后 config.toml 无残留条目', async () => {
- const st = await call('status');
- const file = st.success ? path.join(st.data.codexHome, 'config.toml') : null;
- if (!file || !fs.existsSync(file)) return { success: true, data: { note: '无 config.toml' } };
- const text = fs.readFileSync(file, 'utf8');
- return text.includes('ipc-probe-skill')
- ? { success: false, message: 'config.toml 仍留有该 skill 的条目(重装会继承禁用)' }
- : { success: true, data: { cleaned: true } };
- });
- await check('probeProvider 不可达端点', () => call('probeProvider', { baseUrl: 'http://127.0.0.1:9/v1' }), expectData((d) => d.protocol === 'unreachable' && d.responsesStatus === null && String(d.detail || '').includes('http://127.0.0.1:9/v1/responses')));
- // 用本地假端点跑一次真实 apply / clear(不触碰任何真实模型密钥)
- const http = require('node:http');
- const fake = http.createServer((req, res) => {
- req.resume();
- res.writeHead(req.url && req.url.endsWith('/responses') ? 401 : 200, { 'content-type': 'application/json' });
- res.end('{}');
- });
- await new Promise((resolve) => fake.listen(0, '127.0.0.1', resolve));
- const fakeUrl = `http://127.0.0.1:${fake.address().port}/v1`;
- await check(
- 'applyProvider 真实往返',
- () => call('applyProvider', { modelId: 'probe-live', baseUrl: fakeUrl, apiKey: 'sk-probe-local', providerType: 'OPENAI', modelRecordId: 'probe-1' }),
- expectData((d) => d.applied.model === 'probe-live' && d.applied.providerId === 'zsjz' && d.status.running === true && d.status.defaultModel === 'probe-live'),
- );
- await check('密钥不落盘(config.toml / provider.json)', async () => {
- const st = await call('status');
- if (!st.success) return st;
- const codexHome = st.data.codexHome;
- const dataDir = path.dirname(codexHome);
- const targets = [
- path.join(codexHome, 'config.toml'),
- path.join(dataDir, 'codex-data', 'provider.json'),
- ];
- for (const file of targets) {
- if (!fs.existsSync(file)) continue;
- if (fs.readFileSync(file, 'utf8').includes('sk-probe-local')) {
- return { success: false, message: `${file} 里出现了密钥` };
- }
- }
- return { success: true, data: { checked: targets.filter((f) => fs.existsSync(f)) } };
- });
- await check(
- 'applyProvider 端点不可达时报错(回归)',
- () => call('applyProvider', { modelId: 'qwen', baseUrl: 'http://127.0.0.1:9/v1' }),
- expectFailure(/不可达|没有 \/chat\/completions/),
- );
- await check('clearProvider', () => call('clearProvider'), expectData((d) => d.providerId === null && d.applied === null));
- fake.close();
- await check('applyProvider 缺 modelId', () => call('applyProvider', { modelId: ' ' }), expectFailure(/modelId/));
- await check('turnInterrupt 缺参', () => call('turnInterrupt', {}), expectFailure(/不能为空/));
- await check('approvalResolve 非法 decision', () => call('approvalResolve', { token: 'x', decision: 'whatever' }), expectFailure(/decision/));
- await check('eventsRead 非法 threadId', () => call('eventsRead', { threadId: '../etc' }), expectFailure(/不能为空|threadId|非法/));
- await check('logsTail', () => call('logsTail', { limit: 5 }), expectData((d) => Array.isArray(d)));
- await check('stop', () => call('stop'), expectData((d) => d.state === 'stopped'));
- // 本机地图目录:存在客户端 local-settings.json,不经服务端(设置抽屉的「地图」用它)
- const MAP_DIR_SAMPLE = 'E:/workspace/zsjz-ai/ai-electron/scripts';
- const MAP_STYLE_SAMPLE = 'E:/workspace/zsjz-ai/ai-electron/frontend/public/resource/map/behavior-spacetime-style.json';
- // 先记下运行前的配置,末尾恢复,保证探针不改动使用者机器上的设置
- const mapOriginal = ((await osCall('getMapLocalPath')) || {}).data?.path || '';
- await check('osCtl.getMapLocalPath', () => osCall('getMapLocalPath'), expectData((d) => typeof d.path === 'string'));
- await check(
- 'osCtl.setMapLocalPath 相对路径被拒',
- () => osCall('setMapLocalPath', { path: 'relative/maps' }),
- expectFailure(/绝对路径/),
- );
- await check(
- 'osCtl.setMapLocalPath 不存在的目录被拒',
- () => osCall('setMapLocalPath', { path: 'E:/no-such-dir/maps' }),
- expectFailure(/目录不存在/),
- );
- await check(
- 'osCtl.setMapLocalPath 文件被拒',
- () => osCall('setMapLocalPath', { path: MAP_STYLE_SAMPLE }),
- expectFailure(/请选择目录/),
- );
- await check(
- 'osCtl.setMapLocalPath 保存真实目录',
- () => osCall('setMapLocalPath', { path: MAP_DIR_SAMPLE }),
- expectData((d) => d.path === MAP_DIR_SAMPLE),
- );
- await check(
- 'osCtl.getMapLocalPath 回读一致',
- () => osCall('getMapLocalPath'),
- expectData((d) => d.path === MAP_DIR_SAMPLE),
- );
- await check(
- 'osCtl.setMapLocalPath 清空(空值)',
- async () => {
- await osCall('setMapLocalPath', { path: '' });
- const after = ((await osCall('getMapLocalPath')) || {}).data || {};
- return { success: true, data: { path: after.path } };
- },
- expectData((d) => d.path === ''),
- );
- await check(
- 'osCtl.setMapLocalPath 恢复运行前配置',
- async () => {
- if (mapOriginal) await osCall('setMapLocalPath', { path: mapOriginal });
- const after = ((await osCall('getMapLocalPath')) || {}).data || {};
- return { success: true, data: { path: after.path, expected: mapOriginal } };
- },
- expectData((d) => d.path === d.expected),
- );
- // 离线地图的本机读取(恢复改造前方式):瓦片按偏移分块读、样式读文本
- const MAP_FILE_SAMPLE = 'E:/workspace/zsjz-ai/ai-electron/frontend/public/resource/map/behavior-spacetime-style.json';
- await check(
- 'osCtl.readBinaryFileRange 读前 16 字节',
- () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 0, length: 16 }),
- expectData((d) => d.size === 16 && Buffer.from(d.data, 'base64').toString('utf8').trimStart().startsWith('{')),
- );
- await check(
- 'osCtl.readBinaryFileRange 中段偏移',
- () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 100, length: 32 }),
- expectData((d) => d.size === 32 && d.fileSize > 100),
- );
- await check(
- 'osCtl.readBinaryFileRange 超长自动收敛到文件末尾',
- () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 0, length: 99999999 }),
- expectData((d) => d.size === d.fileSize),
- );
- await check(
- 'osCtl.readBinaryFileRange 偏移越界返回空',
- () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 99999999, length: 16 }),
- expectData((d) => d.size === 0 && d.data === ''),
- );
- await check(
- 'osCtl.readBinaryFileRange 相对路径被拒',
- () => osCall('readBinaryFileRange', { filePath: 'relative/a.pmtiles', length: 16 }),
- expectFailure(/绝对路径/),
- );
- await check(
- 'osCtl.readBinaryFileRange 缺 length 被拒',
- () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE }),
- expectFailure(/长度不合法/),
- );
- await check(
- 'osCtl.readBinaryFileRange 不存在的文件被拒',
- () => osCall('readBinaryFileRange', { filePath: 'E:/no-such-dir/a.pmtiles', length: 16 }),
- expectFailure(/文件不存在/),
- );
- await check(
- 'osCtl.readTextFile 能读到样式文本',
- () => osCall('readTextFile', { filePath: MAP_FILE_SAMPLE, encoding: 'utf8' }),
- expectData((d) => String(d.text || '').includes('version')),
- );
- await check(
- 'osCtl.readTextFile 目录被拒',
- () => osCall('readTextFile', { filePath: 'E:/workspace/zsjz-ai' }),
- expectFailure(/不是文件/),
- );
- // 下载管理器:落盘到可配置目录 + 清单 + 打开/移除(都在临时目录里做,不动使用者的文件)
- const dlTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zsjz-dl-'));
- const dirBefore = ((await dlCall('getDownloadDir')) || {}).data || {};
- await check(
- 'downloadCtl.getDownloadDir',
- () => dlCall('getDownloadDir'),
- expectData((d) => typeof d.dir === 'string' && d.dir.length > 0),
- );
- await check(
- 'downloadCtl.setDownloadDir 文件被拒',
- () => dlCall('setDownloadDir', { dir: MAP_FILE_SAMPLE }),
- expectFailure(/请选择目录/),
- );
- await check(
- 'downloadCtl.setDownloadDir 不存在被拒',
- () => dlCall('setDownloadDir', { dir: 'E:/no-such-dir/downloads' }),
- expectFailure(/目录不存在/),
- );
- await check(
- 'downloadCtl.setDownloadDir 切到临时目录',
- () => dlCall('setDownloadDir', { dir: dlTempDir }),
- expectData((d) => d.dir === dlTempDir),
- );
- await check(
- 'downloadCtl.saveDownload 落盘',
- async () => {
- const saved = await dlCall('saveDownload', {
- fileName: 'probe-download.txt',
- mime: 'text/plain',
- data: Buffer.from('hello-download'),
- source: 'probe',
- });
- const path = saved?.data?.savedPath || '';
- const content = path && fs.existsSync(path) ? fs.readFileSync(path, 'utf8') : '';
- return { success: true, data: { fileName: saved?.data?.fileName, bytes: saved?.data?.bytes, content } };
- },
- expectData((d) => d.fileName === 'probe-download.txt' && d.bytes === 14 && d.content === 'hello-download'),
- );
- await check(
- 'downloadCtl.listDownloads 含新记录且目录正确',
- () => dlCall('listDownloads'),
- expectData((d) => d.dir === dlTempDir && Array.isArray(d.items) && d.items[0]?.fileName === 'probe-download.txt'),
- );
- await check(
- 'downloadCtl.saveDownload 重名自动加序号',
- () =>
- dlCall('saveDownload', {
- fileName: 'probe-download.txt',
- mime: 'text/plain',
- data: Buffer.from('second'),
- source: 'probe',
- }),
- expectData((d) => d.fileName === 'probe-download (1).txt'),
- );
- await check(
- 'downloadCtl.saveDownload 支持直接传 ArrayBuffer',
- async () => {
- const bytes = new TextEncoder().encode('array-buffer-payload');
- const saved = await dlCall('saveDownload', {
- fileName: 'probe-arraybuffer.bin',
- mime: 'application/octet-stream',
- data: bytes.buffer,
- source: 'probe',
- });
- const path = saved?.data?.savedPath || '';
- const content = path && fs.existsSync(path) ? fs.readFileSync(path, 'utf8') : '';
- return { success: true, data: { fileName: saved?.data?.fileName, bytes: saved?.data?.bytes, content } };
- },
- expectData((d) => d.bytes === 20 && d.content === 'array-buffer-payload'),
- );
- await check(
- 'downloadCtl.openDownloadFile 记录不存在被拒',
- () => dlCall('openDownloadFile', { id: 'no-such-id' }),
- expectFailure(/记录不存在/),
- );
- await check(
- 'downloadCtl.removeDownload 移除一条',
- async () => {
- const list = ((await dlCall('listDownloads')) || {}).data || {};
- const first = (list.items || [])[0];
- await dlCall('removeDownload', { id: first?.id });
- const after = ((await dlCall('listDownloads')) || {}).data || {};
- return { success: true, data: { before: (list.items || []).length, after: (after.items || []).length } };
- },
- expectData((d) => d.before > 0 && d.after === d.before - 1),
- );
- await check(
- 'downloadCtl.clearDownloads 清空清单',
- async () => {
- await dlCall('clearDownloads');
- const after = ((await dlCall('listDownloads')) || {}).data || {};
- return { success: true, data: { count: (after.items || []).length } };
- },
- expectData((d) => d.count === 0),
- );
- await check(
- 'downloadCtl.setDownloadDir 恢复运行前配置',
- async () => {
- const expected = dirBefore.dir || '';
- await dlCall('setDownloadDir', { dir: expected });
- const after = ((await dlCall('getDownloadDir')) || {}).data || {};
- return { success: true, data: { dir: after.dir, expected } };
- },
- expectData((d) => d.dir === d.expected),
- );
- // 后台下载 + 进度推送:探针页自己起一个"细水长流"的本地服务,验证进度事件与落盘
- await check(
- 'downloadCtl.startDownload 后台下载并推送进度',
- async () => {
- const http = require('node:http');
- const payload = Buffer.alloc(256 * 1024, 0x41);
- const server = http.createServer((req, res) => {
- res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': String(payload.length) });
- // 分 16 片下发,每片 30ms:足够观察到进度
- let offset = 0;
- const timer = setInterval(() => {
- if (offset >= payload.length) {
- clearInterval(timer);
- res.end();
- return;
- }
- res.write(payload.subarray(offset, offset + 16 * 1024));
- offset += 16 * 1024;
- }, 30);
- req.on('close', () => clearInterval(timer));
- });
- await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
- const port = server.address().port;
- const progresses = [];
- const onProgress = (_event, item) => progresses.push(item);
- ipcRenderer.on('downloadCtl:progress', onProgress);
- const started = await dlCall('startDownload', {
- url: `http://127.0.0.1:${port}/probe-background.bin`,
- fileName: 'probe-background.bin',
- source: 'probe',
- });
- const id = started?.data?.id;
- // 等它下完(最多 6 秒)
- let finalItem = null;
- for (let i = 0; i < 60; i += 1) {
- await new Promise((resolve) => setTimeout(resolve, 100));
- const list = ((await dlCall('listDownloads')) || {}).data || {};
- const found = (list.items || []).find((item) => item.id === id);
- if (found && found.status !== 'downloading') {
- finalItem = found;
- break;
- }
- }
- ipcRenderer.removeListener('downloadCtl:progress', onProgress);
- server.close();
- const sameId = progresses.filter((item) => item.id === id);
- const increased = sameId.some((item, index) => index > 0 && item.received > (sameId[index - 1].received || 0));
- const size = finalItem?.savedPath && fs.existsSync(finalItem.savedPath) ? fs.statSync(finalItem.savedPath).size : 0;
- return {
- status: 'ok',
- success: true,
- data: {
- eventCount: sameId.length,
- receivedIncreased: increased,
- finalStatus: finalItem?.status || null,
- size,
- total: finalItem?.total || 0,
- },
- };
- },
- expectData((d) => d.eventCount >= 2 && d.receivedIncreased === true && d.finalStatus === 'done' && d.size === 256 * 1024),
- );
- await check(
- 'downloadCtl.startDownload 后端报错记为 failed',
- async () => {
- const http = require('node:http');
- const server = http.createServer((req, res) => {
- res.writeHead(404, { 'content-type': 'application/json' });
- res.end(JSON.stringify({ code: 404, message: '文件不存在(探针)' }));
- });
- await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
- const port = server.address().port;
- const started = await dlCall('startDownload', {
- url: `http://127.0.0.1:${port}/missing.bin`,
- fileName: 'probe-missing.bin',
- source: 'probe',
- });
- const id = started?.data?.id;
- let finalItem = null;
- for (let i = 0; i < 30; i += 1) {
- await new Promise((resolve) => setTimeout(resolve, 100));
- const list = ((await dlCall('listDownloads')) || {}).data || {};
- const found = (list.items || []).find((item) => item.id === id);
- if (found && found.status !== 'downloading') {
- finalItem = found;
- break;
- }
- }
- server.close();
- return { success: true, data: { status: finalItem?.status || null, message: finalItem?.message || '' } };
- },
- expectData((d) => d.status === 'failed' && /文件不存在/.test(d.message)),
- );
- await check(
- 'downloadCtl.startDownload 空地址被拒',
- () => dlCall('startDownload', { fileName: 'x.bin' }),
- expectFailure(/地址为空/),
- );
- fs.writeFileSync(outFile, JSON.stringify({ at: new Date().toISOString(), results }, null, 2), 'utf8');
- const failed = results.filter((item) => !item.ok);
- pre.innerHTML += `\n\n${failed.length === 0 ? 'ALL PASS' : 'FAILED ' + failed.length} → ${outFile}`;
- // 让最后的写入落盘再退出
- setTimeout(() => process.exit(failed.length === 0 ? 0 : 1), 300);
- }
- main().catch((error) => {
- fs.writeFileSync(outFile, JSON.stringify({ error: String(error), results }, null, 2), 'utf8');
- setTimeout(() => process.exit(1), 100);
- });
- </script>
- </body>
- </html>
|