codex-ipc-probe.html 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  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. function log(name, ok, detail) {
  32. results.push({ name, ok, detail });
  33. pre.innerHTML += `\n<span class="${ok ? 'ok' : 'bad'}">${ok ? 'PASS' : 'FAIL'} ${name}</span> ${JSON.stringify(detail).slice(0, 300)}`;
  34. }
  35. function isRpc(value) {
  36. return Boolean(value) && typeof value === 'object' && typeof value.success === 'boolean';
  37. }
  38. async function check(name, run, assert) {
  39. try {
  40. const rpc = await run();
  41. if (!isRpc(rpc)) {
  42. log(name, false, { reason: '返回不是 {success,data,message} 结构', rpc });
  43. return null;
  44. }
  45. const problem = assert ? assert(rpc) : rpc.success === false ? rpc.message : null;
  46. log(name, !problem, problem || (rpc.success ? rpc.data : { message: rpc.message }));
  47. return rpc;
  48. } catch (error) {
  49. log(name, false, { reason: 'invoke 直接 reject(说明主进程抛到了 Electron 层)', error: String(error) });
  50. return null;
  51. }
  52. }
  53. /** 断言「应当失败」:失败信息要回来,且不能被 Electron 包成 handler 错误 */
  54. function expectFailure(pattern) {
  55. return (rpc) => {
  56. if (rpc.success) return '本该失败却成功了';
  57. if (!pattern.test(rpc.message)) return `错误信息不匹配 ${pattern}: ${rpc.message}`;
  58. return null;
  59. };
  60. }
  61. function expectData(verify) {
  62. return (rpc) => {
  63. if (!rpc.success) return rpc.message;
  64. return verify(rpc.data) ? null : `数据不符合预期: ${JSON.stringify(rpc.data).slice(0, 200)}`;
  65. };
  66. }
  67. async function main() {
  68. pre.textContent = '';
  69. const tmpSkill = fs.mkdtempSync(path.join(os.tmpdir(), 'zsjz-probe-skill-'));
  70. fs.writeFileSync(
  71. path.join(tmpSkill, 'SKILL.md'),
  72. '---\nname: ipc-probe-skill\ndescription: 探针安装的技能\n---\n\n# probe\n',
  73. 'utf8',
  74. );
  75. await check('ping', () => call('ping'), expectData((d) => d.available === true && /codex-cli/.test(d.version || '')));
  76. await check('status', () => call('status'), expectData((d) => typeof d.running === 'boolean' && Boolean(d.codexHome)));
  77. await check('start', () => call('start'), expectData((d) => d.running === true));
  78. await check('listModels', () => call('listModels'), (rpc) => (rpc.success && Array.isArray(rpc.data) ? null : rpc.message));
  79. await check(
  80. 'mcpSave 含中文/空格参数',
  81. () => call('mcpSave', { id: 'probe-stdio', transport: 'stdio', command: 'node', args: ['-e', '过程 输出'], enabled: true }),
  82. expectData((list) => list.some((item) => item.id === 'probe-stdio' && item.args[1] === '过程 输出')),
  83. );
  84. await check('mcpList', () => call('mcpList'), expectData((list) => list.some((item) => item.id === 'probe-stdio')));
  85. await check('mcpSave 非法 id', () => call('mcpSave', { id: '有 空格', transport: 'stdio', command: 'x', enabled: true }), expectFailure(/MCP ID/));
  86. await check('mcpSave 明文 http 远程', () => call('mcpSave', { id: 'a', transport: 'http', url: 'http://10.0.0.8/mcp', enabled: true }), expectFailure(/HTTPS/));
  87. await check('mcpRemove 缺 id', () => call('mcpRemove', {}), expectFailure(/不能为空/));
  88. await check('mcpRemove', () => call('mcpRemove', { id: 'probe-stdio' }), expectData((list) => !list.some((item) => item.id === 'probe-stdio')));
  89. await check('skillInstallFolder', () => call('skillInstallFolder', { srcPath: tmpSkill, overwrite: true }), expectData((list) => list.some((s) => s.name === 'ipc-probe-skill' && s.managed)));
  90. await check('skillSetEnabled false', () => call('skillSetEnabled', { name: 'ipc-probe-skill', enabled: false }), expectData((d) => d.effectiveEnabled === false));
  91. await check('skillRead', () => call('skillRead', { name: 'ipc-probe-skill' }), expectData((d) => d.content.includes('ipc-probe-skill')));
  92. await check('skillInstallZip 非 zip 字节', () => call('skillInstallZip', { data: new Uint8Array([1, 2, 3, 4]) }), expectFailure(/无法解析 zip/));
  93. 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(/内置或暂存目录/));
  94. await check('skillRemove', () => call('skillRemove', { name: 'ipc-probe-skill' }), expectData((list) => !list.some((s) => s.name === 'ipc-probe-skill')));
  95. await check('删除后 config.toml 无残留条目', async () => {
  96. const st = await call('status');
  97. const file = st.success ? path.join(st.data.codexHome, 'config.toml') : null;
  98. if (!file || !fs.existsSync(file)) return { success: true, data: { note: '无 config.toml' } };
  99. const text = fs.readFileSync(file, 'utf8');
  100. return text.includes('ipc-probe-skill')
  101. ? { success: false, message: 'config.toml 仍留有该 skill 的条目(重装会继承禁用)' }
  102. : { success: true, data: { cleaned: true } };
  103. });
  104. await check('probeProvider 不可达端点', () => call('probeProvider', { baseUrl: 'http://127.0.0.1:9/v1' }), expectData((d) => d.compatible === false && d.status === null));
  105. // 用本地假端点跑一次真实 apply / clear(不触碰任何真实模型密钥)
  106. const http = require('node:http');
  107. const fake = http.createServer((req, res) => {
  108. req.resume();
  109. res.writeHead(req.url && req.url.endsWith('/responses') ? 401 : 200, { 'content-type': 'application/json' });
  110. res.end('{}');
  111. });
  112. await new Promise((resolve) => fake.listen(0, '127.0.0.1', resolve));
  113. const fakeUrl = `http://127.0.0.1:${fake.address().port}/v1`;
  114. await check(
  115. 'applyProvider 真实往返',
  116. () => call('applyProvider', { modelId: 'probe-live', baseUrl: fakeUrl, apiKey: 'sk-probe-local', providerType: 'OPENAI', modelRecordId: 'probe-1' }),
  117. expectData((d) => d.applied.model === 'probe-live' && d.applied.providerId === 'zsjz' && d.status.running === true && d.status.defaultModel === 'probe-live'),
  118. );
  119. await check('密钥不落盘(config.toml / provider.json)', async () => {
  120. const st = await call('status');
  121. if (!st.success) return st;
  122. const codexHome = st.data.codexHome;
  123. const dataDir = path.dirname(codexHome);
  124. const targets = [
  125. path.join(codexHome, 'config.toml'),
  126. path.join(dataDir, 'codex-data', 'provider.json'),
  127. ];
  128. for (const file of targets) {
  129. if (!fs.existsSync(file)) continue;
  130. if (fs.readFileSync(file, 'utf8').includes('sk-probe-local')) {
  131. return { success: false, message: `${file} 里出现了密钥` };
  132. }
  133. }
  134. return { success: true, data: { checked: targets.filter((f) => fs.existsSync(f)) } };
  135. });
  136. await check(
  137. 'applyProvider 端点不可达时报错(回归)',
  138. () => call('applyProvider', { modelId: 'qwen', baseUrl: 'http://127.0.0.1:9/v1' }),
  139. expectFailure(/不可达|没有 \/chat\/completions/),
  140. );
  141. await check('clearProvider', () => call('clearProvider'), expectData((d) => d.providerId === null && d.applied === null));
  142. fake.close();
  143. await check('applyProvider 缺 modelId', () => call('applyProvider', { modelId: ' ' }), expectFailure(/modelId/));
  144. await check('turnInterrupt 缺参', () => call('turnInterrupt', {}), expectFailure(/不能为空/));
  145. await check('approvalResolve 非法 decision', () => call('approvalResolve', { token: 'x', decision: 'whatever' }), expectFailure(/decision/));
  146. await check('eventsRead 非法 threadId', () => call('eventsRead', { threadId: '../etc' }), expectFailure(/不能为空|threadId|非法/));
  147. await check('logsTail', () => call('logsTail', { limit: 5 }), expectData((d) => Array.isArray(d)));
  148. await check('stop', () => call('stop'), expectData((d) => d.state === 'stopped'));
  149. fs.writeFileSync(outFile, JSON.stringify({ at: new Date().toISOString(), results }, null, 2), 'utf8');
  150. const failed = results.filter((item) => !item.ok);
  151. pre.innerHTML += `\n\n${failed.length === 0 ? 'ALL PASS' : 'FAILED ' + failed.length} → ${outFile}`;
  152. // 让最后的写入落盘再退出
  153. setTimeout(() => process.exit(failed.length === 0 ? 0 : 1), 300);
  154. }
  155. main().catch((error) => {
  156. fs.writeFileSync(outFile, JSON.stringify({ error: String(error), results }, null, 2), 'utf8');
  157. setTimeout(() => process.exit(1), 100);
  158. });
  159. </script>
  160. </body>
  161. </html>