codex-ipc-probe.html 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. <!doctype html>
  2. <html lang="zh">
  3. <head>
  4. <meta charset="utf-8" />
  5. <title>Codex IPC 探针</title>
  6. <style>
  7. body { font: 12px/1.6 Consolas, monospace; margin: 16px; }
  8. .ok { color: #1a7f37; }
  9. .bad { color: #c00; }
  10. </style>
  11. </head>
  12. <body>
  13. <pre id="out">Codex IPC probe running…</pre>
  14. <script>
  15. /**
  16. * 真实 Electron IPC 链路探针:在应用窗口内逐条调用 controller/codexCtl/*,
  17. * 覆盖结构化克隆(含 Uint8Array 入参)、错误包装、以及真实 codex 子进程往返。
  18. *
  19. * 用法(env 开关,不污染常规 dev 启动):
  20. * ZSJZ_CODEX_PROBE=file:///<repo>/ai-electron/scripts/codex-ipc-probe.html \
  21. * ZSJZ_CODEX_PROBE_OUT=./scripts/codex-ipc-probe.json npx electron . --env=local
  22. */
  23. const { ipcRenderer } = require('electron');
  24. const fs = require('node:fs');
  25. const os = require('node:os');
  26. const path = require('node:path');
  27. const outFile = process.env.ZSJZ_CODEX_PROBE_OUT || path.join(os.tmpdir(), 'codex-ipc-probe.json');
  28. const results = [];
  29. const pre = document.getElementById('out');
  30. const call = (method, params) => ipcRenderer.invoke(`controller/codexCtl/${method}`, params);
  31. /** 既有的 osCtl controller(窗口控制 / 打开本机文件等) */
  32. const osCall = (method, params) => ipcRenderer.invoke(`controller/osCtl/${method}`, params);
  33. function log(name, ok, detail) {
  34. results.push({ name, ok, detail });
  35. pre.innerHTML += `\n<span class="${ok ? 'ok' : 'bad'}">${ok ? 'PASS' : 'FAIL'} ${name}</span> ${JSON.stringify(detail).slice(0, 300)}`;
  36. }
  37. function isRpc(value) {
  38. return Boolean(value) && typeof value === 'object' && typeof value.success === 'boolean';
  39. }
  40. async function check(name, run, assert) {
  41. try {
  42. const rpc = await run();
  43. if (!isRpc(rpc)) {
  44. log(name, false, { reason: '返回不是 {success,data,message} 结构', rpc });
  45. return null;
  46. }
  47. const problem = assert ? assert(rpc) : rpc.success === false ? rpc.message : null;
  48. log(name, !problem, problem || (rpc.success ? rpc.data : { message: rpc.message }));
  49. return rpc;
  50. } catch (error) {
  51. log(name, false, { reason: 'invoke 直接 reject(说明主进程抛到了 Electron 层)', error: String(error) });
  52. return null;
  53. }
  54. }
  55. /** 断言「应当失败」:失败信息要回来,且不能被 Electron 包成 handler 错误 */
  56. function expectFailure(pattern) {
  57. return (rpc) => {
  58. if (rpc.success) return '本该失败却成功了';
  59. if (!pattern.test(rpc.message)) return `错误信息不匹配 ${pattern}: ${rpc.message}`;
  60. return null;
  61. };
  62. }
  63. function expectData(verify) {
  64. return (rpc) => {
  65. if (!rpc.success) return rpc.message;
  66. return verify(rpc.data) ? null : `数据不符合预期: ${JSON.stringify(rpc.data).slice(0, 200)}`;
  67. };
  68. }
  69. async function main() {
  70. pre.textContent = '';
  71. const tmpSkill = fs.mkdtempSync(path.join(os.tmpdir(), 'zsjz-probe-skill-'));
  72. fs.writeFileSync(
  73. path.join(tmpSkill, 'SKILL.md'),
  74. '---\nname: ipc-probe-skill\ndescription: 探针安装的技能\n---\n\n# probe\n',
  75. 'utf8',
  76. );
  77. await check('ping', () => call('ping'), expectData((d) => d.available === true && /codex-cli/.test(d.version || '')));
  78. await check('status', () => call('status'), expectData((d) => typeof d.running === 'boolean' && Boolean(d.codexHome)));
  79. await check('start', () => call('start'), expectData((d) => d.running === true));
  80. await check('listModels', () => call('listModels'), (rpc) => (rpc.success && Array.isArray(rpc.data) ? null : rpc.message));
  81. await check(
  82. 'mcpSave 含中文/空格参数',
  83. () => call('mcpSave', { id: 'probe-stdio', transport: 'stdio', command: 'node', args: ['-e', '过程 输出'], enabled: true }),
  84. expectData((list) => list.some((item) => item.id === 'probe-stdio' && item.args[1] === '过程 输出')),
  85. );
  86. await check('mcpList', () => call('mcpList'), expectData((list) => list.some((item) => item.id === 'probe-stdio')));
  87. await check('mcpSave 非法 id', () => call('mcpSave', { id: '有 空格', transport: 'stdio', command: 'x', enabled: true }), expectFailure(/MCP ID/));
  88. await check('mcpSave 明文 http 远程', () => call('mcpSave', { id: 'a', transport: 'http', url: 'http://10.0.0.8/mcp', enabled: true }), expectFailure(/HTTPS/));
  89. await check('mcpRemove 缺 id', () => call('mcpRemove', {}), expectFailure(/不能为空/));
  90. await check('mcpRemove', () => call('mcpRemove', { id: 'probe-stdio' }), expectData((list) => !list.some((item) => item.id === 'probe-stdio')));
  91. await check('skillInstallFolder', () => call('skillInstallFolder', { srcPath: tmpSkill, overwrite: true }), expectData((list) => list.some((s) => s.name === 'ipc-probe-skill' && s.managed)));
  92. await check('skillSetEnabled false', () => call('skillSetEnabled', { name: 'ipc-probe-skill', enabled: false }), expectData((d) => d.effectiveEnabled === false));
  93. await check('skillRead', () => call('skillRead', { name: 'ipc-probe-skill' }), expectData((d) => d.content.includes('ipc-probe-skill')));
  94. await check('skillInstallZip 非 zip 字节', () => call('skillInstallZip', { data: new Uint8Array([1, 2, 3, 4]) }), expectFailure(/无法解析 zip/));
  95. 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(/内置或暂存目录/));
  96. await check('skillRemove', () => call('skillRemove', { name: 'ipc-probe-skill' }), expectData((list) => !list.some((s) => s.name === 'ipc-probe-skill')));
  97. await check('删除后 config.toml 无残留条目', async () => {
  98. const st = await call('status');
  99. const file = st.success ? path.join(st.data.codexHome, 'config.toml') : null;
  100. if (!file || !fs.existsSync(file)) return { success: true, data: { note: '无 config.toml' } };
  101. const text = fs.readFileSync(file, 'utf8');
  102. return text.includes('ipc-probe-skill')
  103. ? { success: false, message: 'config.toml 仍留有该 skill 的条目(重装会继承禁用)' }
  104. : { success: true, data: { cleaned: true } };
  105. });
  106. 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')));
  107. // 用本地假端点跑一次真实 apply / clear(不触碰任何真实模型密钥)
  108. const http = require('node:http');
  109. const fake = http.createServer((req, res) => {
  110. req.resume();
  111. res.writeHead(req.url && req.url.endsWith('/responses') ? 401 : 200, { 'content-type': 'application/json' });
  112. res.end('{}');
  113. });
  114. await new Promise((resolve) => fake.listen(0, '127.0.0.1', resolve));
  115. const fakeUrl = `http://127.0.0.1:${fake.address().port}/v1`;
  116. await check(
  117. 'applyProvider 真实往返',
  118. () => call('applyProvider', { modelId: 'probe-live', baseUrl: fakeUrl, apiKey: 'sk-probe-local', providerType: 'OPENAI', modelRecordId: 'probe-1' }),
  119. expectData((d) => d.applied.model === 'probe-live' && d.applied.providerId === 'zsjz' && d.status.running === true && d.status.defaultModel === 'probe-live'),
  120. );
  121. await check('密钥不落盘(config.toml / provider.json)', async () => {
  122. const st = await call('status');
  123. if (!st.success) return st;
  124. const codexHome = st.data.codexHome;
  125. const dataDir = path.dirname(codexHome);
  126. const targets = [
  127. path.join(codexHome, 'config.toml'),
  128. path.join(dataDir, 'codex-data', 'provider.json'),
  129. ];
  130. for (const file of targets) {
  131. if (!fs.existsSync(file)) continue;
  132. if (fs.readFileSync(file, 'utf8').includes('sk-probe-local')) {
  133. return { success: false, message: `${file} 里出现了密钥` };
  134. }
  135. }
  136. return { success: true, data: { checked: targets.filter((f) => fs.existsSync(f)) } };
  137. });
  138. await check(
  139. 'applyProvider 端点不可达时报错(回归)',
  140. () => call('applyProvider', { modelId: 'qwen', baseUrl: 'http://127.0.0.1:9/v1' }),
  141. expectFailure(/不可达|没有 \/chat\/completions/),
  142. );
  143. await check('clearProvider', () => call('clearProvider'), expectData((d) => d.providerId === null && d.applied === null));
  144. fake.close();
  145. await check('applyProvider 缺 modelId', () => call('applyProvider', { modelId: ' ' }), expectFailure(/modelId/));
  146. await check('turnInterrupt 缺参', () => call('turnInterrupt', {}), expectFailure(/不能为空/));
  147. await check('approvalResolve 非法 decision', () => call('approvalResolve', { token: 'x', decision: 'whatever' }), expectFailure(/decision/));
  148. await check('eventsRead 非法 threadId', () => call('eventsRead', { threadId: '../etc' }), expectFailure(/不能为空|threadId|非法/));
  149. await check('logsTail', () => call('logsTail', { limit: 5 }), expectData((d) => Array.isArray(d)));
  150. // 「打开记录关联文件」用的 IPC:只验参数校验分支(成功分支会真的弹起本机应用)
  151. await check('osCtl.openPath 空路径被拒', () => osCall('openPath', {}), expectFailure(/不能为空/));
  152. await check('osCtl.openPath 相对路径被拒', () => osCall('openPath', { path: 'relative/a.xlsx' }), expectFailure(/绝对路径/));
  153. await check(
  154. 'osCtl.openPath 不存在的文件被拒',
  155. () => osCall('openPath', { path: 'E:/no-such-dir/no-such-file.xlsx' }),
  156. expectFailure(/文件不存在/),
  157. );
  158. await check('stop', () => call('stop'), expectData((d) => d.state === 'stopped'));
  159. // 本机地图目录:存在客户端 local-settings.json,不经服务端(设置抽屉的「地图」用它)
  160. const MAP_DIR_SAMPLE = 'E:/workspace/zsjz-ai/ai-electron/scripts';
  161. const MAP_STYLE_SAMPLE = 'E:/workspace/zsjz-ai/ai-electron/frontend/public/resource/map/behavior-spacetime-style.json';
  162. // 先记下运行前的配置,末尾恢复,保证探针不改动使用者机器上的设置
  163. const mapOriginal = ((await osCall('getMapLocalPath')) || {}).data?.path || '';
  164. await check('osCtl.getMapLocalPath', () => osCall('getMapLocalPath'), expectData((d) => typeof d.path === 'string'));
  165. await check(
  166. 'osCtl.setMapLocalPath 相对路径被拒',
  167. () => osCall('setMapLocalPath', { path: 'relative/maps' }),
  168. expectFailure(/绝对路径/),
  169. );
  170. await check(
  171. 'osCtl.setMapLocalPath 不存在的目录被拒',
  172. () => osCall('setMapLocalPath', { path: 'E:/no-such-dir/maps' }),
  173. expectFailure(/目录不存在/),
  174. );
  175. await check(
  176. 'osCtl.setMapLocalPath 文件被拒',
  177. () => osCall('setMapLocalPath', { path: MAP_STYLE_SAMPLE }),
  178. expectFailure(/请选择目录/),
  179. );
  180. await check(
  181. 'osCtl.setMapLocalPath 保存真实目录',
  182. () => osCall('setMapLocalPath', { path: MAP_DIR_SAMPLE }),
  183. expectData((d) => d.path === MAP_DIR_SAMPLE),
  184. );
  185. await check(
  186. 'osCtl.getMapLocalPath 回读一致',
  187. () => osCall('getMapLocalPath'),
  188. expectData((d) => d.path === MAP_DIR_SAMPLE),
  189. );
  190. await check(
  191. 'osCtl.setMapLocalPath 清空(空值)',
  192. async () => {
  193. await osCall('setMapLocalPath', { path: '' });
  194. const after = ((await osCall('getMapLocalPath')) || {}).data || {};
  195. return { success: true, data: { path: after.path } };
  196. },
  197. expectData((d) => d.path === ''),
  198. );
  199. await check(
  200. 'osCtl.setMapLocalPath 恢复运行前配置',
  201. async () => {
  202. if (mapOriginal) await osCall('setMapLocalPath', { path: mapOriginal });
  203. const after = ((await osCall('getMapLocalPath')) || {}).data || {};
  204. return { success: true, data: { path: after.path, expected: mapOriginal } };
  205. },
  206. expectData((d) => d.path === d.expected),
  207. );
  208. // 离线地图的本机读取(恢复改造前方式):瓦片按偏移分块读、样式读文本
  209. const MAP_FILE_SAMPLE = 'E:/workspace/zsjz-ai/ai-electron/frontend/public/resource/map/behavior-spacetime-style.json';
  210. await check(
  211. 'osCtl.readBinaryFileRange 读前 16 字节',
  212. () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 0, length: 16 }),
  213. expectData((d) => d.size === 16 && Buffer.from(d.data, 'base64').toString('utf8').trimStart().startsWith('{')),
  214. );
  215. await check(
  216. 'osCtl.readBinaryFileRange 中段偏移',
  217. () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 100, length: 32 }),
  218. expectData((d) => d.size === 32 && d.fileSize > 100),
  219. );
  220. await check(
  221. 'osCtl.readBinaryFileRange 超长自动收敛到文件末尾',
  222. () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 0, length: 99999999 }),
  223. expectData((d) => d.size === d.fileSize),
  224. );
  225. await check(
  226. 'osCtl.readBinaryFileRange 偏移越界返回空',
  227. () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 99999999, length: 16 }),
  228. expectData((d) => d.size === 0 && d.data === ''),
  229. );
  230. await check(
  231. 'osCtl.readBinaryFileRange 相对路径被拒',
  232. () => osCall('readBinaryFileRange', { filePath: 'relative/a.pmtiles', length: 16 }),
  233. expectFailure(/绝对路径/),
  234. );
  235. await check(
  236. 'osCtl.readBinaryFileRange 缺 length 被拒',
  237. () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE }),
  238. expectFailure(/长度不合法/),
  239. );
  240. await check(
  241. 'osCtl.readBinaryFileRange 不存在的文件被拒',
  242. () => osCall('readBinaryFileRange', { filePath: 'E:/no-such-dir/a.pmtiles', length: 16 }),
  243. expectFailure(/文件不存在/),
  244. );
  245. await check(
  246. 'osCtl.readTextFile 能读到样式文本',
  247. () => osCall('readTextFile', { filePath: MAP_FILE_SAMPLE, encoding: 'utf8' }),
  248. expectData((d) => String(d.text || '').includes('version')),
  249. );
  250. await check(
  251. 'osCtl.readTextFile 目录被拒',
  252. () => osCall('readTextFile', { filePath: 'E:/workspace/zsjz-ai' }),
  253. expectFailure(/不是文件/),
  254. );
  255. fs.writeFileSync(outFile, JSON.stringify({ at: new Date().toISOString(), results }, null, 2), 'utf8');
  256. const failed = results.filter((item) => !item.ok);
  257. pre.innerHTML += `\n\n${failed.length === 0 ? 'ALL PASS' : 'FAILED ' + failed.length} → ${outFile}`;
  258. // 让最后的写入落盘再退出
  259. setTimeout(() => process.exit(failed.length === 0 ? 0 : 1), 300);
  260. }
  261. main().catch((error) => {
  262. fs.writeFileSync(outFile, JSON.stringify({ error: String(error), results }, null, 2), 'utf8');
  263. setTimeout(() => process.exit(1), 100);
  264. });
  265. </script>
  266. </body>
  267. </html>