// main/ai.js —— AI 代理的协议分支测试 // // 覆盖: // - aiProvider 默认走 OpenAI 分支(旧版配置 / 没填 provider) // - aiProvider='openai' → POST /chat/completions · Bearer 鉴权 // - aiProvider='anthropic' → POST /v1/messages · x-api-key + anthropic-version // - 未配置 / 超时 / 取消 / HTTP 错误 / 响应格式错误的稳定错误码 // - OpenAI / Anthropic 各自的截断信号(finish_reason / stop_reason) // // 不依赖 Electron(ai.js 只用 Node 内置 fetch);通过 deps.fetchImpl 注入 // 假 fetch 验证 URL / headers / body,避免真实网络。 import { describe, it, expect } from 'vitest'; const { createAiProxy } = require('../../main/ai.js'); /** * 构造一个 mock fetch:断言请求、返回预设响应。 * @param {object} opts * @param {(url:string, init:object) => void} [opts.onCall] 每次调用都跑一次 * @param {Response|Error} [opts.response] 默认 200 + 合法 OpenAI 回复 */ function mockFetch({ onCall, response } = {}) { /** @type {Array<{url:string, init:object}>} */ const calls = []; const fn = async (url, init = {}) => { calls.push({ url, init }); if (onCall) onCall(url, init); if (response instanceof Error) throw response; return response || new Response(JSON.stringify({ choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; return { fn, calls }; } describe('createAiProxy.runEdit —— Provider 分发', () => { it('aiProvider 缺省 / 未知 → 走 OpenAI 分支(向后兼容)', async () => { let captured = null; const fetchImpl = async (url, init) => { captured = { url, init }; return new Response(JSON.stringify({ choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; const proxy = createAiProxy({ getConfig: () => ({ aiBaseUrl: 'https://x/v1', aiApiKey: 'sk-x', aiModel: 'gpt-x' }), fetchImpl, }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'r1' }); expect(r.ok).toBe(true); expect(captured.url).toBe('https://x/v1/chat/completions'); expect(captured.init.headers.Authorization).toBe('Bearer sk-x'); }); it('aiProvider=openai → POST /chat/completions · Bearer', async () => { let captured = null; const fetchImpl = async (url, init) => { captured = { url, init }; return new Response(JSON.stringify({ choices: [{ message: { content: 'plain reply' }, finish_reason: 'stop' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://api.openai.com/v1', aiApiKey: 'sk-test', aiModel: 'gpt-4o-mini', }), fetchImpl, }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'r2' }); expect(r.ok).toBe(true); expect(r.responseFormat).toBe('raw'); expect(captured.url).toBe('https://api.openai.com/v1/chat/completions'); expect(captured.init.method).toBe('POST'); expect(captured.init.headers.Authorization).toBe('Bearer sk-test'); expect(captured.init.headers['x-api-key']).toBeUndefined(); // body 应含 messages 数组(system + user) const body = JSON.parse(captured.init.body); expect(Array.isArray(body.messages)).toBe(true); expect(body.messages[0].role).toBe('system'); expect(body.messages[1].role).toBe('user'); }); it('aiProvider=anthropic → POST /v1/messages · x-api-key + anthropic-version', async () => { let captured = null; const fetchImpl = async (url, init) => { captured = { url, init }; return new Response(JSON.stringify({ content: [{ type: 'text', text: '{"content":"hello"}' }], stop_reason: 'end_turn', }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://api.anthropic.com', aiApiKey: 'sk-ant-test', aiModel: 'claude-3-5-sonnet-latest', }), fetchImpl, }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'r3' }); expect(r.ok).toBe(true); expect(r.content).toBe('hello'); expect(captured.url).toBe('https://api.anthropic.com/v1/messages'); expect(captured.init.headers['x-api-key']).toBe('sk-ant-test'); expect(captured.init.headers['anthropic-version']).toBe('2023-06-01'); expect(captured.init.headers.Authorization).toBeUndefined(); const body = JSON.parse(captured.init.body); // Anthropic: system 在顶层,messages 只有 user expect(body.system).toBeDefined(); expect(Array.isArray(body.messages)).toBe(true); expect(body.messages.length).toBe(1); expect(body.messages[0].role).toBe('user'); }); }); describe('createAiProxy.runEdit —— 错误码', () => { it('未配置 → AI_NOT_CONFIGURED', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiBaseUrl: '', aiApiKey: '', aiModel: '' }), fetchImpl: async () => { throw new Error('should not call'); }, }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' }); expect(r.ok).toBe(false); expect(r.error).toBe('AI_NOT_CONFIGURED'); }); // audit fix (Round 8 A-2):明文 HTTP + 非loopback + 已配置 API Key → 拒绝。 // 本地代理(Ollama / LM Studio / vllm)走 http://localhost / 127.0.0.1 不需要 Key, // 留空 apiKey 即过;这里测的是「Key 存在 + 非loopback 明文」必须被拒。 it('OpenAI 明文 http + 非loopback + 已配 Key → AI_NOT_CONFIGURED(防 Key 泄露)', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'http://evil.example.com/v1', aiApiKey: 'sk-real-key', aiModel: 'gpt-4' }), fetchImpl: async () => { throw new Error('should not call — 明文 HTTP + 非loopback 必须在 fetch 前拒绝'); }, }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' }); expect(r.ok).toBe(false); expect(r.error).toBe('AI_NOT_CONFIGURED'); expect(r.message).toMatch(/明文 HTTP/); expect(r.message).toMatch(/https/); }); it('Anthropic 明文 http + 非loopback + 已配 Key → AI_NOT_CONFIGURED', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'http://gateway.example.com', aiApiKey: 'sk-ant-key', aiModel: 'claude-3-5-sonnet' }), fetchImpl: async () => { throw new Error('should not call'); }, }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' }); expect(r.ok).toBe(false); expect(r.error).toBe('AI_NOT_CONFIGURED'); expect(r.message).toMatch(/明文 HTTP/); }); it('明文 http + localhost + 已配 Key → 仍放过(本地代理合法场景)', async () => { // localhost 上 Key 即使被同机嗅探到也比公网风险低,且本地代理(Ollama) // 走 http 是惯例。这里验证 validateBaseUrl + http 守卫不误伤。 const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'http://localhost:11434/v1', aiApiKey: 'k', aiModel: 'gpt-4' }), fetchImpl: async () => new Response(JSON.stringify({ choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' }); expect(r.ok).toBe(true); }); it('明文 http + 127.0.0.1 + 已配 Key → 仍放过', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'http://127.0.0.1:1234/v1', aiApiKey: 'k', aiModel: 'gpt-4' }), fetchImpl: async () => new Response(JSON.stringify({ choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' }); expect(r.ok).toBe(true); }); it('OpenAI 401 → AI_PROVIDER_ERROR("API Key 无效或没有权限")', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'bad', aiModel: 'gpt' }), fetchImpl: async () => new Response('Unauthorized', { status: 401 }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' }); expect(r.ok).toBe(false); expect(r.error).toBe('AI_PROVIDER_ERROR'); expect(r.message).toMatch(/API Key/); }); it('Anthropic 400 + invalid_request_error → 透出 error.message', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => new Response(JSON.stringify({ type: 'error', error: { type: 'invalid_request_error', message: 'max_tokens: too large' }, }), { status: 400, headers: { 'Content-Type': 'application/json' } }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' }); expect(r.ok).toBe(false); expect(r.error).toBe('AI_PROVIDER_ERROR'); expect(r.message).toMatch(/max_tokens: too large/); }); it('OpenAI finish_reason=length → AI_BAD_RESPONSE(截断提示)', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => new Response(JSON.stringify({ choices: [{ message: { content: '{"content":"abc' }, finish_reason: 'length' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' }); expect(r.ok).toBe(false); expect(r.error).toBe('AI_BAD_RESPONSE'); expect(r.message).toMatch(/不完整/); }); it('Anthropic stop_reason=max_tokens → AI_BAD_RESPONSE(截断提示)', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => new Response(JSON.stringify({ content: [{ type: 'text', text: '{"content":"trunc' }], stop_reason: 'max_tokens', }), { status: 200, headers: { 'Content-Type': 'application/json' } }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' }); expect(r.ok).toBe(false); expect(r.error).toBe('AI_BAD_RESPONSE'); expect(r.message).toMatch(/不完整/); }); it('网络错误 → AI_PROVIDER_ERROR(带原始 message)', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => { throw new Error('ECONNREFUSED'); }, }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r' }); expect(r.ok).toBe(false); expect(r.error).toBe('AI_PROVIDER_ERROR'); expect(r.message).toMatch(/ECONNREFUSED/); }); }); describe('createAiProxy —— timer / pending Map cleanup', () => { // audit fix:postJson 之前只 clearTimeout 没从 timers Map delete entry, // 长时间使用会内存泄漏。这里通过重复 runEdit 验证 timer/pending 都已清空。 it('连续 10 次成功请求后 timers / pending Map 都为空', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => new Response(JSON.stringify({ choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } }), }); for (let i = 0; i < 10; i++) { const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: `r${i}` }); expect(r.ok).toBe(true); } // 通过 cancelAll 的清理量反推:cancelAll 应该不需要清任何东西(如果还有泄漏,会 abort 已完成的 controller) proxy.cancelAll(); // 重复 cancelAll 不应该抛错(说明 Map 已空) expect(() => proxy.cancelAll()).not.toThrow(); }); it('失败响应(HTTP 400)后 timer Map 已清', async () => { let count = 0; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => { count++; return new Response('Bad Request', { status: 400 }); }, }); for (let i = 0; i < 5; i++) { const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: `err${i}` }); expect(r.ok).toBe(false); } expect(count).toBe(5); // 后续 cancelAll 无副作用说明没有累积 expect(() => proxy.cancelAll()).not.toThrow(); }); it('网络异常(fetch reject)后 timer Map 已清', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => { throw new Error('ECONNRESET'); }, }); for (let i = 0; i < 5; i++) { const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: `net${i}` }); expect(r.ok).toBe(false); } expect(() => proxy.cancelAll()).not.toThrow(); }); // looksLikeEditJson 是 main/ai.js 的内部函数,没导出。这里通过 runEdit 行为 // 反推 —— 当 AI 返回「带嵌套 JSON 但格式损坏」的文本时,应得到 AI_BAD_RESPONSE // (提示不完整)而不是 raw 回退(让用户看到残缺 JSON 当作文本)。 describe('createAiProxy.runEdit —— looksLikeEditJson(嵌套 JSON 截断识别)', () => { // 这些测试间接覆盖嵌套对象场景:原来的正则 `/\{[^{}]*"content"\s*:[^{}]*\}/` // 在 content 后跟嵌套 {} 时会失配,导致 looksLikeEditJson 误报 false, // normalizeAssistantText 走 raw 回退 —— AI 返回半截带嵌套对象的 JSON 时, // 用户看到的是残缺 JSON 当文本,不是「请缩小文档」的提示。 function makeProxyWithText(aiText) { const fetchImpl = async () => new Response(JSON.stringify({ choices: [{ message: { content: aiText }, finish_reason: 'stop' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } }); return createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl, }); } it('AI 返回半截嵌套 JSON(外层 {} 未闭合)→ raw 回退,responseFormat=raw', async () => { // 模拟 AI 想要返回 {content:"...", patches:[...]} 但 token 耗尽、整个对象都 // 没闭合:tryParseJson 失败、extractFirstJsonObject 也找不到配对对象、 // looksLikeEditJson 返回 false → 走 raw 路径把残文本返回给用户。 // (这是设计:raw 回退让用户至少能看到 AI 输出了什么;TRUNCATED 是 OpenAI / // Anthropic 自己的 finish_reason=length / stop_reason=max_tokens 触发的, // 不靠正文里的 {} 配对判断。) const truncated = '{"content":"hi","patches":[{"op":"replace","old":"a","new":"b"},'; const proxy = makeProxyWithText(truncated); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt1' }); expect(r.ok).toBe(true); expect(r.responseFormat).toBe('raw'); expect(r.content).toBe(truncated); }); it('AI 返回半截 JSON 但 brace-pair slice 仍能拿到合法对象 → 解析成功', async () => { // 复杂 case:文本里有 prose + JSON 截断(缺尾 `}`),但第一个 { 到最后一个 } // 之间存在合法 JSON。这种情况应当被 getJsonCandidates 的 brace-pair slice // 提取并解析 —— 验证新 extractFirstJsonObject 与既有 brace-pair slice // 路径都不破坏嵌套解析。 const mixed = 'Here is the edit:\n{"content":"abc","meta":{"note":"done"}}\n[truncated'; const proxy = makeProxyWithText(mixed); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt2' }); expect(r.ok).toBe(true); expect(r.content).toBe('abc'); expect(r.responseFormat).toBe('json'); }); it('AI 返回完整嵌套 JSON(合法)→ ok=true', async () => { // 完整嵌套 JSON 应当被 tryParseJson 解析通过、走 responseFormat='json'。 const valid = '{"content":"hi","patches":[{"op":"replace"}]}'; const proxy = makeProxyWithText(valid); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt3' }); expect(r.ok).toBe(true); expect(r.content).toBe('hi'); expect(r.responseFormat).toBe('json'); }); it('用户提示词纯文本里出现 "content" 子串 → 不误判 TRUNCATED', async () => { // 老正则 `"content"\s*:` 在纯文本里有 `please update the "content" of section` 也会命中。 // 新实现要求体内有真配对的 JSON 对象 + `"content"` 键,纯文本不会触发。 const prose = '请把 "content" 字段里那个段落的标题改了。'; const proxy = makeProxyWithText(prose); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt4' }); // 不是 JSON → responseFormat='raw',当成普通回复返回,不应报错。 expect(r.ok).toBe(true); expect(r.responseFormat).toBe('raw'); expect(r.content).toBe(prose); }); it('AI 返回带 fenced code block 的 JSON(含嵌套) → ok=true', async () => { // ```json\n{...}\n``` 也应被 tryParseJson 处理,新实现的 // extractFirstJsonObject 在 fenced block 内也能正常工作(先 trim 后扫描)。 const fenced = '```json\n{"content":"fenced ok","meta":{"k":"v"}}\n```'; const proxy = makeProxyWithText(fenced); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'rt5' }); expect(r.ok).toBe(true); expect(r.content).toBe('fenced ok'); }); }); it('重复 requestId 第二次进入 runEdit 会取消第一次', async () => { let callCount = 0; const fetchImpl = (url) => { callCount++; // 第一次永远不返回(模拟长任务) if (callCount === 1) return new Promise(() => {}); // 第二次正常返回 return Promise.resolve(new Response(JSON.stringify({ choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } })); }; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl, }); // 第一次请求(永远 hang) const p1 = proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'dup' }); // 第二次同 requestId → 应该 cancel 第一次并发新请求 const p2 = proxy.runEdit({ prompt: 'p2', content: 'c', filename: 'f', requestId: 'dup' }); const r2 = await p2; expect(r2.ok).toBe(true); expect(r2.content).toBe('ok'); // p1 应该被 cancel(不需要 resolve,cancelAll 会清掉) proxy.cancelAll(); // 等 p1 真的 reject(被 abort 后 fetch promise 不会自己结束) // 加个超时兜底防止测试挂住 await Promise.race([p1.catch(() => {}), new Promise((r) => setTimeout(r, 100))]); expect(() => proxy.cancelAll()).not.toThrow(); }); }); // audit F1:cleanup 必须 identity-check 后再 delete(详细注释见 main/ai.js 的 cleanup)。 describe('createAiProxy —— requestId 重入取消', () => { it('重复 requestId 第二次进入 runEdit 会取消第一次', async () => { let callCount = 0; const fetchImpl = () => { callCount++; if (callCount === 1) return new Promise(() => {}); return Promise.resolve(new Response(JSON.stringify({ choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } })); }; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl, }); const p1 = proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'dup' }); const p2 = proxy.runEdit({ prompt: 'p2', content: 'c', filename: 'f', requestId: 'dup' }); const r2 = await p2; expect(r2.ok).toBe(true); expect(r2.content).toBe('ok'); proxy.cancelAll(); await Promise.race([p1.catch(() => {}), new Promise((r) => setTimeout(r, 100))]); expect(() => proxy.cancelAll()).not.toThrow(); }); it('同 requestId 重新发起后,新请求仍可被 cancel(requestId) 单独取消', async () => { const inFlight = []; const fetchImpl = (url, opts) => { return new Promise((resolve, reject) => { inFlight.push({ opts }); // 必须监听 abort 信号;否则 catch 永远不触发,测试会 timeout if (opts && opts.signal) { if (opts.signal.aborted) { reject(new DOMException('aborted', 'AbortError')); return; } opts.signal.addEventListener('abort', () => { reject(new DOMException('aborted', 'AbortError')); }, { once: true }); } }); }; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl, }); const p1 = proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'dup' }); await new Promise((r) => setTimeout(r, 0)); const p2 = proxy.runEdit({ prompt: 'p2', content: 'c', filename: 'f', requestId: 'dup' }); await new Promise((r) => setTimeout(r, 0)); proxy.cancel('dup'); // 等两个都 settle(aborted) await Promise.all([ p1.catch(() => {}), p2.catch(() => {}), ]); expect(inFlight).toHaveLength(2); // 关键断言:第二个请求的 signal.aborted 必须是 true // —— F1 修复前:旧 cleanup 把 controller 从 Map 抹掉,proxy.cancel('dup') 是 no-op expect(inFlight[1].opts.signal.aborted).toBe(true); }); }); // audit M1+M2+M3 回归测试:API Key 脱敏 + URL userinfo 剥除 + JSON-wrapped header 修复 describe('createAiProxy.runEdit —— API Key 脱敏 + URL 清洗', () => { // M1 fix:fetch 抛错时 message 里携带 key 也必须脱敏。 // 真实场景:fetch reject 的 Error.message 偶尔会包含 URL(含 query key)或 // 自定义 fetch 包装层把请求 headers 拼进 message。 it('M1:网络错误 message 含 sk- 前缀的 key → result.message 已脱敏', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => { throw new Error('fetch failed for Authorization: Bearer sk-realsk1234567890abcdef'); }, }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm1' }); expect(r.ok).toBe(false); expect(r.error).toBe('AI_PROVIDER_ERROR'); expect(r.message).not.toMatch(/sk-realsk1234567890abcdef/); expect(r.message).toMatch(/\[API_KEY\]/); }); // M1 follow-up:非 sk- 前缀的 key(自定义 token / API secret)也应被某条规则盖住。 it('M1:网络错误 message 含通用 token=xxx → result.message 已脱敏', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => { throw new Error('api_key=hunter2_real_secret_was_here'); }, }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm1b' }); expect(r.ok).toBe(false); expect(r.message).not.toMatch(/hunter2_real_secret_was_here/); expect(r.message).toMatch(/\[REDACTED\]/); }); // M2 fix:baseURL 含 userinfo(https://user:pass@host)必须剥掉。 // 真实场景:中转服务在 baseURL 里塞 userinfo 简化配置;fetch 会把 userinfo // 当 Basic Auth 自动发出去,凭据泄露到第三方。 it('M2:baseURL 含 userinfo → 实际 fetch 的 URL 已剥 userinfo', async () => { let captured = null; const fetchImpl = async (url) => { captured = url; return new Response(JSON.stringify({ choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://hunter2:secret@relay.example.com/v1', aiApiKey: 'k', aiModel: 'm', }), fetchImpl, }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm2' }); expect(r.ok).toBe(true); expect(captured).toBe('https://relay.example.com/v1/chat/completions'); expect(captured).not.toMatch(/hunter2/); expect(captured).not.toMatch(/secret/); expect(captured).not.toMatch(/@/); }); // M2 follow-up:URL 里 path 段含 sk-xxx 的极端情况也要在 sanitizeUrl 输出里被遮罩。 // 不依赖 joinUrl(防御深度):sanitizeUrl 单独处理原始 URL 时也应当剥 userinfo。 it('M2:sanitizeUrl 输出(HTTP 错误日志)已剥 userinfo + path 段 key', async () => { /** @type {Array<{event:string, data:any}>} */ const logs = []; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://user:pass@api.example.com/v1', aiApiKey: 'k', aiModel: 'm', }), fetchImpl: async () => new Response('oops', { status: 500 }), log: (event, data) => logs.push({ event, data }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm2b' }); expect(r.ok).toBe(false); const httpErrLog = logs.find((l) => l.event === 'ai:http_error'); expect(httpErrLog).toBeDefined(); expect(httpErrLog.data.url).not.toMatch(/user/); expect(httpErrLog.data.url).not.toMatch(/pass/); expect(httpErrLog.data.url).not.toMatch(/@/); expect(httpErrLog.data.url).toMatch(/^https:\/\/api\.example\.com/); }); // M3 fix:HTTP 错误回显里出现 JSON 风格的 `"authorization": "Bearer xxx"` 文本 // (错误信息里把 header 序列化成字符串,AI proxy / 网关常见),旧 regex 遇到 `"` // 就停,只盖到开引号,剩下 `xxx` 全部漏出。新增的 JSON 包装 pattern 一次性吃完整段。 // 这里使用非 sk- 前缀的 key,确保测试通过 sk- 正则 fail(只覆盖 JSON-wrapped 修复路径)。 it('M3:JSON-wrapped authorization header(非 sk- 前缀)→ 已脱敏', async () => { const SENSITIVE = 'eyJhbGciOiJIUzI1NiJ9.payload.signature_xxx_no_sk_prefix'; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => new Response(JSON.stringify({ error: { message: `upstream returned: "authorization": "Bearer ${SENSITIVE}"` }, }), { status: 400, headers: { 'Content-Type': 'application/json' } }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm3' }); expect(r.ok).toBe(false); // 关键断言:JWT 内容不再出现在 toast message 里 expect(r.message).not.toMatch(/eyJhbGciOiJIUzI1NiJ9/); expect(r.message).not.toMatch(/payload\.signature_xxx_no_sk_prefix/); expect(r.message).toMatch(/REDACTED/); }); // M3 follow-up:短 key 阈值 8 → 5 后,截断的 sk- 也能被捕获。 // 状态码选 400 且不带 invalid_request_error.type —— 走 main/ai.js 末尾 // 「AI 请求失败(HTTP 400):{detail}」路径,detail 经 sanitizeDetail 脱敏后 // 进入 result.message。401/403/404/429/5xx 都有早退固定文案,不展示上游 detail。 it('M3:截断的 sk-(5 字符后缀)→ 已脱敏为 [API_KEY]', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => new Response(JSON.stringify({ error: { message: 'invalid token: sk-12345abc (truncated for security)' }, }), { status: 400, headers: { 'Content-Type': 'application/json' } }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm3b' }); expect(r.ok).toBe(false); // 不应出现真实截断的 key 段 expect(r.message).not.toMatch(/sk-12345abc/); expect(r.message).toMatch(/\[API_KEY\]/); }); }); // audit shared-M11 + shared-M13:归一化层对「非字符串 content」和「非文本块」做了区分 describe('createAiProxy.runEdit —— shared-M11 / shared-M13 归一化分支', () => { // M11:Anthropic 返回的 content 全部是 tool_use / image(无 text 块)—— // 旧版会误报「AI 返回了空内容」让用户重试;新版提示「无法识别」。 it('M11:Anthropic 仅返回 tool_use 块(无 text)→ 明确提示 tool_use / 图像块', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => new Response(JSON.stringify({ content: [ { type: 'tool_use', id: 'toolu_1', name: 'get_weather', input: { city: 'BJ' } }, ], stop_reason: 'end_turn', }), { status: 200, headers: { 'Content-Type': 'application/json' } }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11a' }); expect(r.ok).toBe(false); expect(r.error).toBe('AI_BAD_RESPONSE'); // 提示里要让用户知道是「格式不识别」而不是「内容为空」 expect(r.message).toMatch(/tool_use|图像块/); }); it('M11:Anthropic 仅返回 image 块(无 text)→ 同样提示无法识别', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => new Response(JSON.stringify({ content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: '...' } }], stop_reason: 'end_turn', }), { status: 200, headers: { 'Content-Type': 'application/json' } }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11b' }); expect(r.ok).toBe(false); expect(r.error).toBe('AI_BAD_RESPONSE'); expect(r.message).toMatch(/tool_use|图像块/); }); it('M11:Anthropic content 数组空 → 仍报「空内容」(不是 tool_use 路径)', async () => { // 兜底:旧分支 'AI 返回了空内容' 仍保留,区分「真的没回」和「回了非文本」。 const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => new Response(JSON.stringify({ content: [], stop_reason: 'end_turn', }), { status: 200, headers: { 'Content-Type': 'application/json' } }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11c' }); expect(r.ok).toBe(false); expect(r.error).toBe('AI_BAD_RESPONSE'); expect(r.message).toBe('AI 返回了空内容'); }); it('M11:Anthropic 混合 text + tool_use → 只提取 text,responseFormat=json', async () => { // 混合块:正常路径 —— text 被拼接,tool_use 跳过,但不影响 ok=true。 const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => new Response(JSON.stringify({ content: [ { type: 'tool_use', id: 't1', name: 'noop', input: {} }, { type: 'text', text: '{"content":"mixed ok"}' }, ], stop_reason: 'end_turn', }), { status: 200, headers: { 'Content-Type': 'application/json' } }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm11d' }); expect(r.ok).toBe(true); expect(r.content).toBe('mixed ok'); expect(r.responseFormat).toBe('json'); }); // M13:模型按规矩返回了 JSON 对象但 content 不是字符串(数组 / 对象 / null / 数字)。 // 旧逻辑走「output 截断」分支误导用户;新版把 message 整体当 raw 回退给用户。 it('M13:OpenAI 返回的 JSON 解析后 content 是数组 → responseFormat=raw,回退原 message', async () => { const rawMessage = '{"content":[]}'; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => new Response(JSON.stringify({ choices: [{ message: { content: rawMessage }, finish_reason: 'stop' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm13a' }); expect(r.ok).toBe(true); expect(r.responseFormat).toBe('raw'); // 回退:原 message 整体作为 content,让用户看到模型实际输出 expect(r.content).toBe(rawMessage); }); it('M13:OpenAI 返回的 JSON 解析后 content 是 null → responseFormat=raw', async () => { const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => new Response(JSON.stringify({ choices: [{ message: { content: '{"content":null,"meta":"x"}' }, finish_reason: 'stop' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm13b' }); expect(r.ok).toBe(true); expect(r.responseFormat).toBe('raw'); expect(r.content).toBe('{"content":null,"meta":"x"}'); }); it('M13:OpenAI 返回的 JSON 解析后 content 是嵌套对象 → responseFormat=raw', async () => { const rawMessage = '{"content":{"ops":[{"op":"replace","old":"a","new":"b"}]}}'; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'k', aiModel: 'm' }), fetchImpl: async () => new Response(JSON.stringify({ choices: [{ message: { content: rawMessage }, finish_reason: 'stop' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } }), }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'm13c' }); expect(r.ok).toBe(true); expect(r.responseFormat).toBe('raw'); expect(r.content).toBe(rawMessage); }); }); // --------------------------------------------------------------------------- // audit round-13:Anthropic / OpenAI max_tokens 上限路由 // // 回归背景:pickAnthropicTokenConfig 的正则要求 `claude-` 后紧跟已知片段, // 于是 claude-opus-5 / claude-sonnet-5 / claude-haiku-4-5 全部漏网 → 回退 8192, // 比 claude-opus-4-8(16384)还低。「越新的模型拿到越小的 max_tokens」, // 长笔记改写在 8k 被截断 → stop_reason: max_tokens → 报「AI 修改结果不完整」。 // --------------------------------------------------------------------------- describe('max_tokens 上限按模型路由', () => { /** * 跑一次 runEdit,返回实际发出的请求 body。 * @param {'openai'|'anthropic'} provider * @param {string} model */ async function capturedBody(provider, model) { let captured = null; const okBody = provider === 'anthropic' ? { content: [{ type: 'text', text: '{"content":"ok"}' }], stop_reason: 'end_turn' } : { choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }] }; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: provider, aiBaseUrl: provider === 'anthropic' ? 'https://x' : 'https://x/v1', aiApiKey: 'k', aiModel: model, }), fetchImpl: async (url, init) => { captured = JSON.parse(init.body); return new Response(JSON.stringify(okBody), { status: 200, headers: { 'Content-Type': 'application/json' }, }); }, }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f.md', requestId: 'cap' }); expect(r.ok).toBe(true); return captured; } // ---- Anthropic:5 系与 4.6+ 拿到 32k ---- it.each([ 'claude-opus-5', 'claude-sonnet-5', 'claude-fable-5', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-sonnet-4-6', ])('Anthropic %s → max_tokens 32000(官方 128k 上限,保守取值)', async (model) => { const body = await capturedBody('anthropic', model); expect(body.max_tokens).toBe(32_000); }); // ---- Anthropic:其余 4 系 / 3-5 / 3-7 拿到 16k ---- it.each([ 'claude-haiku-4-5', 'claude-sonnet-4-5', 'claude-opus-4-5', 'claude-3-7-sonnet-20250219', 'claude-3-5-sonnet-20241022', ])('Anthropic %s → max_tokens 16384', async (model) => { const body = await capturedBody('anthropic', model); expect(body.max_tokens).toBe(16_384); }); // ---- Anthropic:claude-3 老家族与未知模型保守 8k ---- it.each([ 'claude-3-opus-20240229', 'claude-3-haiku-20240307', ])('Anthropic %s(claude-3 老家族)→ max_tokens 8192', async (model) => { const body = await capturedBody('anthropic', model); expect(body.max_tokens).toBe(8_192); }); it('Anthropic 未知模型(自部署 / 中转)→ max_tokens 8192,最大限度兼容', async () => { const body = await capturedBody('anthropic', 'my-local-llm-v2'); expect(body.max_tokens).toBe(8_192); }); // 关键不变式:新模型的上限不得低于老模型(这正是本轮修的 bug) it('回归:claude-opus-5 的 max_tokens 不低于 claude-opus-4-8', async () => { const five = await capturedBody('anthropic', 'claude-opus-5'); const four = await capturedBody('anthropic', 'claude-opus-4-8'); expect(five.max_tokens).toBeGreaterThanOrEqual(four.max_tokens); }); it('回归:claude-haiku-4-5 的 max_tokens 高于 claude-3 老家族', async () => { const haiku45 = await capturedBody('anthropic', 'claude-haiku-4-5'); const old3 = await capturedBody('anthropic', 'claude-3-haiku-20240307'); expect(haiku45.max_tokens).toBeGreaterThan(old3.max_tokens); }); // ---- OpenAI 侧:token 字段路由不受影响(防回归) ---- it('OpenAI gpt-5 → max_completion_tokens 且不带 temperature', async () => { const body = await capturedBody('openai', 'gpt-5'); expect(body.max_completion_tokens).toBe(32_000); expect(body.max_tokens).toBeUndefined(); expect(body.temperature).toBeUndefined(); }); it('OpenAI gpt-4o → max_tokens 16384 且带 temperature', async () => { const body = await capturedBody('openai', 'gpt-4o'); expect(body.max_tokens).toBe(16_384); expect(body.max_completion_tokens).toBeUndefined(); expect(typeof body.temperature).toBe('number'); }); }); // --------------------------------------------------------------------------- // audit round-13 Sec-H3:fetch 必须 redirect:'manual' 防 API key 跟着 302 走 // // 回归背景:Anthropic 用 x-api-key 自定义头(不在 fetch 规范 CORS 非通配脱敏集 // 合里),undici 默认 follow 重定向到不同 origin 时会原样复传 header。 // 用户配置的中转 / 网关一旦 302 到攻击者域,sk-ant-... 跟当前笔记全文会被转发。 // --------------------------------------------------------------------------- describe('createAiProxy —— redirect: manual 防 API Key 跟随重定向', () => { it('fetch 调用必带 redirect:"manual"(OpenAI 分支)', async () => { let captured = null; const fetchImpl = async (url, init) => { captured = { url, init }; return new Response(JSON.stringify({ choices: [{ message: { content: '{"content":"ok"}' }, finish_reason: 'stop' }], }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://x/v1', aiApiKey: 'sk-test', aiModel: 'gpt-4o-mini', }), fetchImpl, }); await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r-redir-1' }); expect(captured.init.redirect).toBe('manual'); }); it('fetch 调用必带 redirect:"manual"(Anthropic 分支)', async () => { let captured = null; const fetchImpl = async (url, init) => { captured = { url, init }; return new Response(JSON.stringify({ content: [{ type: 'text', text: '{"content":"ok"}' }], stop_reason: 'end_turn', }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'anthropic', aiBaseUrl: 'https://x', aiApiKey: 'sk-ant-test', aiModel: 'claude-opus-5', }), fetchImpl, }); await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r-redir-2' }); expect(captured.init.redirect).toBe('manual'); }); it('3xx 重定向 → opaqueredirect → AI_PROVIDER_ERROR + 提示用户改 Base URL', async () => { // 模拟 undici 在 manual 模式下返回的 opaqueredirect Response:status=0、type='opaqueredirect' const fetchImpl = async () => { const res = new Response(null, { status: 302 }); // 模拟 opaqueredirect 的关键属性:status 0 + type 'opaqueredirect' Object.defineProperty(res, 'status', { value: 0 }); Object.defineProperty(res, 'type', { value: 'opaqueredirect' }); return res; }; const proxy = createAiProxy({ getConfig: () => ({ aiProvider: 'openai', aiBaseUrl: 'https://evil-redirect.example/v1', aiApiKey: 'sk-test', aiModel: 'gpt-4o', }), fetchImpl, }); const r = await proxy.runEdit({ prompt: 'p', content: 'c', filename: 'f', requestId: 'r-redir-3' }); expect(r.ok).toBe(false); expect(r.error).toBe('AI_PROVIDER_ERROR'); expect(r.message).toMatch(/发生了重定向/); expect(r.message).toMatch(/不会自动跟随重定向/); }); });