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');
});
});