codex-ipc-probe.html 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  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. fs.writeFileSync(outFile, JSON.stringify({ at: new Date().toISOString(), results }, null, 2), 'utf8');
  160. const failed = results.filter((item) => !item.ok);
  161. pre.innerHTML += `\n\n${failed.length === 0 ? 'ALL PASS' : 'FAILED ' + failed.length} → ${outFile}`;
  162. // 让最后的写入落盘再退出
  163. setTimeout(() => process.exit(failed.length === 0 ? 0 : 1), 300);
  164. }
  165. main().catch((error) => {
  166. fs.writeFileSync(outFile, JSON.stringify({ error: String(error), results }, null, 2), 'utf8');
  167. setTimeout(() => process.exit(1), 100);
  168. });
  169. </script>
  170. </body>
  171. </html>