feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s

This commit is contained in:
2026-09-08 14:30:27 +08:00
parent 69776e447f
commit 4cd6e997b5
86 changed files with 4303 additions and 956 deletions
@@ -15,6 +15,7 @@ vi.mock('electron-log', () => ({
}));
import { AnthropicAdapter } from '../anthropic.adapter';
import { __imageFetcher } from '../shared/ssrf-image-fetch';
import { ContentFilterError } from '../base-adapter';
import type { MetonaRequest, MetonaStreamEvent } from '../../types';
import { MetonaFinishReason, MetonaStreamEventType } from '../../types';
@@ -235,17 +236,60 @@ describe('AnthropicAdapter — 图片块转换', () => {
it('http URL 图片 → 下载后转 base64content-type 作为 media_type', async () => {
const adapter = makeAdapter();
const imageBytes = new TextEncoder().encode('PNG-DATA');
// 第一个 fetch(图片下载)返回二进制;第二个 fetch(chat)返回 ok
mockFetch
.mockResolvedValueOnce({
// v0.8.2 P0-1: 图片下载改走 SSRF 安全通道(独立下载器注入点,不走 global fetch
const fetchRestore = __imageFetcher.current;
__imageFetcher.current = (async () =>
new Response(imageBytes, {
status: 200,
headers: { 'content-type': 'image/jpeg' },
})) as typeof __imageFetcher.current;
try {
// chat 请求返回 ok
mockFetch.mockResolvedValue(okResponse());
await adapter.send(
makeRequest({
messages: [
{
role: 'user',
content: '看图',
images: [{ url: 'https://example.com/pic.jpg' }],
timestamp: Date.now(),
},
],
}),
);
const body = lastBody();
const userMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[0];
const imageBlock = userMsg.content.find((c) => c.type === 'image');
expect(imageBlock).toMatchObject({
type: 'image',
source: { type: 'base64', media_type: 'image/jpeg' },
});
const source = (imageBlock as { source: { data: string } }).source;
expect(Buffer.from(source.data, 'base64').toString('utf8')).toBe('PNG-DATA');
} finally {
__imageFetcher.current = fetchRestore;
}
});
it('http URL 图片下载失败 → 降级忽略该图片(不阻断请求)', async () => {
const adapter = makeAdapter();
// v0.8.2 P0-1: 下载失败同样走注入点(SSRF 拒绝/网络失败均降级为跳过图片)
const fetchRestore = __imageFetcher.current;
__imageFetcher.current = (async () => {
throw new Error('ECONNREFUSED');
}) as typeof __imageFetcher.current;
try {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: { get: () => 'image/jpeg' },
arrayBuffer: async () => imageBytes.buffer,
} as unknown as Response)
.mockResolvedValue(okResponse());
await adapter.send(
makeRequest({
json: async () => ({
content: [{ type: 'text', text: 'ok' }],
usage: {},
stop_reason: 'end_turn',
}),
} as unknown as Response);
const request = makeRequest({
messages: [
{
role: 'user',
@@ -254,42 +298,12 @@ describe('AnthropicAdapter — 图片块转换', () => {
timestamp: Date.now(),
},
],
}),
);
const body = lastBody();
const userMsg = (body.messages as Array<{ content: Array<Record<string, unknown>> }>)[0];
const imageBlock = userMsg.content.find((c) => c.type === 'image');
expect(imageBlock).toMatchObject({
type: 'image',
source: { type: 'base64', media_type: 'image/jpeg' },
});
const source = (imageBlock as { source: { data: string } }).source;
expect(Buffer.from(source.data, 'base64').toString('utf8')).toBe('PNG-DATA');
});
it('http URL 图片下载失败 → 降级忽略该图片(不阻断请求)', async () => {
const adapter = makeAdapter();
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: 'ok' }],
usage: {},
stop_reason: 'end_turn',
}),
} as unknown as Response);
const request = makeRequest({
messages: [
{
role: 'user',
content: '看图',
images: [{ url: 'https://example.com/pic.jpg' }],
timestamp: Date.now(),
},
],
});
const res = await adapter.send(request);
expect(res.content).toBe('ok'); // 请求未被阻断
});
const res = await adapter.send(request);
expect(res.content).toBe('ok'); // 请求未被阻断
} finally {
__imageFetcher.current = fetchRestore;
}
});
it('非法 data URI(非 base64)→ 返回 null,不生成 image 块', async () => {
@@ -970,3 +984,281 @@ describe('AnthropicAdapter — getContextWindow / listModels', () => {
expect(mockFetch).not.toHaveBeenCalled();
});
});
// ===== v0.8.2 P1-1: thinking 块回传 + pause_turn 续传 =====
describe('AnthropicAdapter — v0.8.2 P1-1', () => {
function okJson(data: Record<string, unknown>): Response {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
function sse(lines: string[]): Response {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(lines.join('\n') + '\n'));
controller.close();
},
});
return new Response(body, { status: 200 });
}
it('流式 DONE 携带带签名的 thinking 块(redacted 原样)', async () => {
const adapter = makeAdapter();
mockFetch.mockResolvedValue(
sse([
'event: message_start',
jsonLine({ type: 'message_start', message: { usage: { input_tokens: 10 } } }),
'event: content_block_start',
jsonLine({
type: 'content_block_start',
index: 0,
content_block: { type: 'thinking', thinking: '' },
}),
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 0,
delta: { type: 'thinking_delta', thinking: 'deep thought' },
}),
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 0,
delta: { type: 'signature_delta', signature: 'sig-abc' },
}),
'event: content_block_stop',
jsonLine({ type: 'content_block_stop', index: 0 }),
'event: content_block_start',
jsonLine({
type: 'content_block_start',
index: 1,
content_block: { type: 'redacted_thinking', data: 'opaque' },
}),
'event: content_block_stop',
jsonLine({ type: 'content_block_stop', index: 1 }),
'event: content_block_start',
jsonLine({
type: 'content_block_start',
index: 2,
content_block: { type: 'text', text: '' },
}),
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 2,
delta: { type: 'text_delta', text: 'Answer' },
}),
'event: content_block_stop',
jsonLine({ type: 'content_block_stop', index: 2 }),
'event: message_delta',
jsonLine({
type: 'message_delta',
delta: { stop_reason: 'end_turn' },
usage: { output_tokens: 20 },
}),
'event: message_stop',
jsonLine({ type: 'message_stop' }),
]),
);
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
const done = events.find((e) => e.type === MetonaStreamEventType.DONE);
expect(done).toBeDefined();
expect(done!.finishReason).toBe('stop');
expect(done!.thinkingBlocks).toEqual([
{ type: 'thinking', thinking: 'deep thought', signature: 'sig-abc' },
{ type: 'redacted_thinking', data: 'opaque' },
]);
});
it('下一轮请求按协议回传 thinking 块(thinking 开启时块前置;关闭时不回传)', async () => {
const adapter = makeAdapter();
const thinkingBlocks = [
{ type: 'thinking' as const, thinking: 'deep', signature: 'sig' },
{ type: 'redacted_thinking' as const, data: 'opaque' },
];
const withHistory = makeRequest({
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true },
messages: [
{ role: 'user', content: 'q', timestamp: 1 },
{
role: 'assistant',
content: null,
thinkingBlocks,
toolCalls: [{ id: 'tc1', name: 'read_file', args: {}, iteration: 1, timestamp: 1 }],
timestamp: 1,
},
{
role: 'tool',
content: 'data',
toolResult: {
toolCallId: 'tc1',
toolName: 'read_file',
result: 'data',
success: true,
durationMs: 1,
timestamp: 1,
},
timestamp: 1,
},
{ role: 'user', content: 'go on', timestamp: 2 },
],
});
mockFetch.mockResolvedValue(
okJson({ content: [{ type: 'text', text: 'ok' }], usage: {}, stop_reason: 'end_turn' }),
);
await adapter.send(withHistory);
const body = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body) as Record<
string,
unknown
>;
const assistant = (
body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
).find(
(m) =>
m.role === 'assistant' &&
Array.isArray(m.content) &&
m.content.some((c) => c.type === 'tool_use'),
);
expect(assistant).toBeDefined();
// thinking 块位于 assistant content 首位(协议要求)
expect(assistant!.content[0]).toEqual({ type: 'thinking', thinking: 'deep', signature: 'sig' });
expect(assistant!.content[1]).toEqual({ type: 'redacted_thinking', data: 'opaque' });
// 思考关闭(降级重试路径)→ 不回传 thinking 块
const disabled = makeRequest({
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
messages: withHistory.messages,
});
mockFetch.mockReset();
mockFetch.mockResolvedValue(
okJson({ content: [{ type: 'text', text: 'ok' }], usage: {}, stop_reason: 'end_turn' }),
);
await adapter.send(disabled);
const body2 = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body) as Record<
string,
unknown
>;
const assistant2 = (
body2.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
).find(
(m) =>
m.role === 'assistant' &&
Array.isArray(m.content) &&
m.content.some((c) => c.type === 'tool_use'),
);
expect(
assistant2!.content.some((c) => c.type === 'thinking' || c.type === 'redacted_thinking'),
).toBe(false);
});
it('pause_turn 流式自动续传:两段文本拼接、单次 DONE、续传请求原样携带本段 content', async () => {
const adapter = makeAdapter();
mockFetch
.mockResolvedValueOnce(
sse([
'event: message_start',
jsonLine({ type: 'message_start', message: { usage: { input_tokens: 5 } } }),
'event: content_block_start',
jsonLine({
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' },
}),
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: 'part1' },
}),
'event: content_block_stop',
jsonLine({ type: 'content_block_stop', index: 0 }),
'event: content_block_start',
jsonLine({
type: 'content_block_start',
index: 1,
content_block: { type: 'pause_turn' },
}),
'event: content_block_stop',
jsonLine({ type: 'content_block_stop', index: 1 }),
'event: message_delta',
jsonLine({
type: 'message_delta',
delta: { stop_reason: 'pause_turn' },
usage: { output_tokens: 10 },
}),
'event: message_stop',
jsonLine({ type: 'message_stop' }),
]),
)
.mockResolvedValueOnce(
sse([
'event: message_start',
jsonLine({ type: 'message_start', message: { usage: { input_tokens: 5 } } }),
'event: content_block_start',
jsonLine({
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' },
}),
'event: content_block_delta',
jsonLine({
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: 'part2' },
}),
'event: content_block_stop',
jsonLine({ type: 'content_block_stop', index: 0 }),
'event: message_delta',
jsonLine({
type: 'message_delta',
delta: { stop_reason: 'end_turn' },
usage: { output_tokens: 10 },
}),
'event: message_stop',
jsonLine({ type: 'message_stop' }),
]),
);
const events = await collectStream(adapter, makeRequest({ params: { stream: true } }));
const texts = events
.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)
.map((e) => e.delta);
expect(texts.join('')).toBe('part1part2');
const dones = events.filter((e) => e.type === MetonaStreamEventType.DONE);
expect(dones).toHaveLength(1);
expect(dones[0].finishReason).toBe('stop');
// 第二次请求体应把第一段 content(含 pause_turn 块)原样追加为 assistant 消息
const secondBody = JSON.parse((mockFetch.mock.calls[1][1] as { body: string }).body) as {
messages: Array<{ role: string; content: Array<Record<string, unknown>> }>;
};
const carried = secondBody.messages[secondBody.messages.length - 1];
expect(carried.role).toBe('assistant');
expect(carried.content.some((c) => c.type === 'text' && c.text === 'part1')).toBe(true);
expect(carried.content.some((c) => c.type === 'pause_turn')).toBe(true);
});
it('非流式 pause_turn 同样续传至自然结束', async () => {
const adapter = makeAdapter();
mockFetch
.mockResolvedValueOnce(
okJson({
content: [{ type: 'text', text: 'half' }, { type: 'pause_turn' }],
usage: {},
stop_reason: 'pause_turn',
}),
)
.mockResolvedValueOnce(
okJson({ content: [{ type: 'text', text: 'done' }], usage: {}, stop_reason: 'end_turn' }),
);
const res = await adapter.send(makeRequest());
expect(res.finishReason).toBe(MetonaFinishReason.STOP);
expect(mockFetch).toHaveBeenCalledTimes(2);
const secondBody = JSON.parse((mockFetch.mock.calls[1][1] as { body: string }).body) as {
messages: Array<{ role: string }>;
};
expect(secondBody.messages[secondBody.messages.length - 1].role).toBe('assistant');
});
});
@@ -16,6 +16,9 @@ vi.mock('electron-log', () => ({
}));
import { OllamaAdapter } from '../ollama.adapter';
// v0.8.2 P0-1: 图片下载走 SSRF 安全通道(注入点替代 global fetch 打桩)
import { __imageFetcher } from '../shared/ssrf-image-fetch';
import type { ImageFetcher } from '../shared/ssrf-image-fetch';
import type { MetonaRequest } from '../../types';
import { MetonaStreamEventType } from '../../types';
@@ -604,48 +607,61 @@ describe('OllamaAdapter — 图片归一化', () => {
it('http URL 图片下载为纯 base64(无 data: 前缀)', async () => {
const adapter = makeAdapter();
const imageBytes = new TextEncoder().encode('IMG-BYTES');
mockFetch
.mockResolvedValueOnce({
ok: true,
// v0.8.2 P0-1: 下载经 SSRF 安全通道(注入受控下载器)
const restore = __imageFetcher.current;
__imageFetcher.current = (async () =>
new Response(imageBytes, {
status: 200,
arrayBuffer: async () => imageBytes.buffer,
} as unknown as Response)
.mockResolvedValue(okChatResponse());
await adapter.send(
makeRequest({
messages: [
{
role: 'user',
content: '看图',
images: [{ url: 'https://example.com/pic.png' }],
timestamp: Date.now(),
},
],
}),
);
const body = lastBody();
const userMsg = (body.messages as Array<Record<string, unknown>>).find(
(m) => m.role === 'user',
);
expect(userMsg!.images).toEqual([Buffer.from('IMG-BYTES').toString('base64')]);
headers: { 'content-type': 'image/png' },
})) as unknown as ImageFetcher;
try {
mockFetch.mockResolvedValue(okChatResponse());
await adapter.send(
makeRequest({
messages: [
{
role: 'user',
content: '看图',
images: [{ url: 'https://example.com/pic.png' }],
timestamp: Date.now(),
},
],
}),
);
const body = lastBody();
const userMsg = (body.messages as Array<Record<string, unknown>>).find(
(m) => m.role === 'user',
);
expect(userMsg!.images).toEqual([Buffer.from('IMG-BYTES').toString('base64')]);
} finally {
__imageFetcher.current = restore;
}
});
it('http URL 下载失败 → 图片被忽略(空数组/不发送),请求不阻断', async () => {
const adapter = makeAdapter();
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue(okChatResponse());
const res = await adapter.send(
makeRequest({
messages: [
{
role: 'user',
content: '看图',
images: [{ url: 'https://example.com/pic.png' }],
timestamp: Date.now(),
},
],
}),
);
expect(res.content).toBe('ok');
const restore = __imageFetcher.current;
__imageFetcher.current = (async () => {
throw new Error('ECONNREFUSED');
}) as unknown as ImageFetcher;
try {
mockFetch.mockResolvedValue(okChatResponse());
const res = await adapter.send(
makeRequest({
messages: [
{
role: 'user',
content: '看图',
images: [{ url: 'https://example.com/pic.png' }],
timestamp: Date.now(),
},
],
}),
);
expect(res.content).toBe('ok');
} finally {
__imageFetcher.current = restore;
}
});
it('data URI 图片剥前缀;纯 base64 原样保留', async () => {
@@ -675,26 +691,31 @@ describe('OllamaAdapter — 图片归一化', () => {
it('HTTP 下载响应非 2xx → 图片降级忽略', async () => {
const adapter = makeAdapter();
mockFetch
.mockResolvedValueOnce({ ok: false, status: 404 } as unknown as Response)
.mockResolvedValue(okChatResponse());
await adapter.send(
makeRequest({
messages: [
{
role: 'user',
content: '看图',
images: [{ url: 'https://example.com/missing.png' }],
timestamp: Date.now(),
},
],
}),
);
const body = lastBody();
const userMsg = (body.messages as Array<Record<string, unknown>>).find(
(m) => m.role === 'user',
);
expect(userMsg!.images).toEqual([]);
const restore = __imageFetcher.current;
__imageFetcher.current = (async () =>
new Response(null, { status: 404 })) as unknown as ImageFetcher;
try {
mockFetch.mockResolvedValue(okChatResponse());
await adapter.send(
makeRequest({
messages: [
{
role: 'user',
content: '看图',
images: [{ url: 'https://example.com/missing.png' }],
timestamp: Date.now(),
},
],
}),
);
const body = lastBody();
const userMsg = (body.messages as Array<Record<string, unknown>>).find(
(m) => m.role === 'user',
);
expect(userMsg!.images).toEqual([]);
} finally {
__imageFetcher.current = restore;
}
});
});
@@ -901,15 +901,17 @@ describe('MimoAdapter — thinking 显式开关', () => {
expect(bodies[0].top_p).toBe(0.8);
});
it('thinkingEnabled 未配置 → 默认 {type:enabled} 且不传 temperature/top_pAPI 强制覆盖)', async () => {
// v0.8.2 P2-6 修订:未配置时显式 disabled(与 DeepSeek/Agnes 的"用户意图优先"对齐;
// 旧行为隐式 enabled 会静默吞掉用户 temperature/top_p —— 服务端思考模式强制覆盖)
it('thinkingEnabled 未配置 → 显式 {type:disabled} 且 temperature/top_p 透传', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({ params: { maxTokens: 4096, temperature: 0.7, topP: 0.8, stream: false } }),
);
expect(bodies[0].thinking).toEqual({ type: 'enabled' });
expect(bodies[0].temperature).toBeUndefined();
expect(bodies[0].top_p).toBeUndefined();
expect(bodies[0].thinking).toEqual({ type: 'disabled' });
expect(bodies[0].temperature).toBe(0.7);
expect(bodies[0].top_p).toBe(0.8);
});
it('max_completion_tokens 原样透传(v0.8.1pro/standard 均不钳制)', async () => {
@@ -710,3 +710,51 @@ describe('parseOpenAICompatibleResponse — 补充形态', () => {
expect(result.toolCalls![0].id).toBeUndefined();
});
});
describe('parseSSEStream — v0.8.2 P2-6 MiMo 联网搜索引用回填', () => {
it('message.annotations 引用以 TEXT_DELTA 回填正文(DONE 前)', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"content":"今天的新闻"}}],"annotations":[]}\n\n',
'data: {"choices":[{"delta":{},"finish_reason":null}],"annotations":[{"title":"科技日报","url":"https://news.example.com/a","site_name":"example"}]}\n\n',
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
'data: [DONE]\n\n',
]),
);
const citation = events.filter(
(e) => e.type === MetonaStreamEventType.TEXT_DELTA && String(e.delta).includes('References'),
);
expect(citation).toHaveLength(1);
expect(String(citation[0].delta)).toContain('https://news.example.com/a');
expect(String(citation[0].delta)).toContain('科技日报');
// 引用块必须在 DONE 之前
expect(events.indexOf(citation[0])).toBeLessThan(
events.findIndex((e) => e.type === MetonaStreamEventType.DONE),
);
});
it('按 url 去重;非流式路径同样回填', async () => {
const events = await collect(
makeStream([
'data: {"annotations":[{"title":"a","url":"https://x.example/1"},{"title":"a-dup","url":"https://x.example/1"}]}\n\n',
'data: [DONE]\n\n',
]),
);
const citation = events.find(
(e) => e.type === MetonaStreamEventType.TEXT_DELTA && String(e.delta).includes('References'),
);
expect(citation).toBeDefined();
expect(String(citation!.delta).match(/https:\/\/x.example\/1/g)).toHaveLength(1);
const res = parseOpenAICompatibleResponse({
choices: [
{
message: { content: 'answer', annotations: [{ title: 'b', url: 'https://x.example/2' }] },
},
],
usage: {},
});
expect(res.content).toContain('answer');
expect(res.content).toContain('https://x.example/2');
});
});
@@ -155,6 +155,18 @@ describe('P0-3 修订: 用户思考意图优先于模型元信息', () => {
expect(body.temperature).toBe(0);
});
it('MiMo: 未配置(undefined)→ 显式 disabledv0.8.2 P2-6 与 DeepSeek/Agnes 对齐,不隐式吞用户 temperature', async () => {
const adapter = new MimoAdapter({
provider: 'mimo',
baseURL: 'https://api.xiaomimimo.com/v1',
apiKey: 'k',
defaultModel: 'mimo-v2.5-pro',
});
const body = asNative(adapter)(makeRequest({ thinkingEnabled: undefined }), false);
expect(body.thinking).toEqual({ type: 'disabled' });
expect(body.temperature).toBe(0);
});
it('Ollama: 探测不支持思考(服务端硬约束)→ 不发 think 参数(唯一保留的门控)', async () => {
const adapter = new OllamaAdapter({
provider: 'ollama',