feat: v0.7.3 成本收口 · 状态一致 · 死账清理 — Prompt Cache 根治 + SSRF DNS Pinning + 87 用例扩充全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m47s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 9m55s

P1 修复面收口: Prompt Cache 根治(日期/记忆/附件三类易变内容出 system 入用户消息
  前置块 user-context.ts, system 跨 run 字节级稳定; Anthropic system 块数组化 +
  cache_control ephemeral 断言, DeepSeek 自动缓存前缀命中 — 多轮对话输入 token
  成本降数量级); 编辑重发/重新生成幽灵 Trace 双侧根治(DB truncateMessagesAfter
  同步过滤 metadata.traceSteps + 前端 trimTraceStepsByAnchor 镜像, 严格小于锚点
  时间戳, 同毫秒等值判废); sessions:deleteMessage 死通道全链路删除(渲染层零调用
  + message_count 漂移面); Ollama vision 能力门控全链路(MetonaModelInfo
  .supportsVision 贯穿 adapter/IPC/store/UI, model-capabilities.ts 三道判定纯函数,
  未知保守放行); 记忆固化节流(consolidation-policy 纯函数: 总开关 + 内容门控
  [回答>=200字符或存在成功工具调用] + 会话级 10 分钟频率窗口, 三 memory.* 配置键)

P2 安全纵深: SSRF DNS Pinning 关闭 rebinding 窗口(ssrf-guard 重构
  resolvePublicAddresses 单源; ssrf-dispatcher 以 undici Agent.connect.lookup
  钉死校验 IP, TLS SNI 保持原域名, 一次性 dispatcher 用后即毁; 代理激活显式
  退化为仅入口校验); web_fetch 重写手动逐跳重定向循环(每跳先校验后连接,
  替代 redirect:follow 内核跟跳的中间跳裸奔, 上限 5 跳); http_request 换用
  pinned fetch; web_search 可达性预检加固(私有 URL 零请求 + 不跟跳, 3xx 视为
  可达); Agent 浏览器 CORS 通配收紧为 Origin 回显 + Vary: Origin;
  ConfirmationHook.forgetSession 会话终态清理(会话删除/abort 联动/SubAgent
  终结三处接线, 根治 rememberedDecisions 泄漏)

P3 架构还债: agent.enableReflection 死配置全链路接线(main→shared→引擎→
  Orchestrator→设置开关, REFLECTING 状态真实可达); AgentLoopConfig.timeoutMs
  死字段删除; MemoryManager.cleanupExpired 挂入健康检查周期(expires_at 回收
  管道真实化); buildSafeEnv 收敛 utils/safe-env.ts 单源(run_command 与 MCP
  stdio 共用, 终结双实现漂移); Trace 生命周期治理(metadata 只保留最近 20 个
  run — keepRecentRuns 纯函数; JSONL 录制启动自动清理保留 200 个 + 设置页
  手动清理); SLO/健康快照可视化(app:healthSnapshot IPC + 设置页只读卡片 +
  审计链一键校验)

P4 能力演进: 会话标题 LLM 自动生成(TitleGenerator — 每会话幂等/并发重入复用
  同一 Promise/自定义标题不覆盖/失败静默回退, Sidebar 经 config:changed 实时
  刷新); MCP 自动重连(5s/15s/60s 退避最多 3 次, reconnecting 状态机,
  teardownConnection 内部拆除保留簿记 — 用户断开/开关关闭即时取消, 设置页
  显示第 N/3 次); 死循环检测 ABAB 乒乓模式(最近4轮 A→B→A→B 交替判定, 补齐
  docs 第五章"两状态反复切换"检测契约); i18n 第三阶段(ChatInput/LLMSettings/
  OnboardingWizard/MemoryViewer 主链路文案出层, zh-CN + en-US 双字典补齐)

测试: 737 → 824 用例(+87, 新增 8 个测试文件 + 扩展 3 个)。新覆盖: user-context
  分组/空值收缩/拼接契约、context-builder 字节级稳定性、Anthropic cache_control
  四态、consolidation-policy 九路判定矩阵、ssrf-dispatcher(pinned lookup/重定向
  解析/IP 校验)、forget-session 会话隔离、trace-lifecycle run 淘汰、
  trace-trim 严格小于边界、safe-env 净化矩阵、mcp-reconnect 退避状态机
  (fake timers)、title-generator 并发重入、SQLite 侧 truncate×TRACE 联动
  (Electron ABI)。测试驱动修复: GIT_*/ 注释终止块注释、重连计数被自身重试
  前置断开重置(拆 teardownConnection 保留簿记)、TitleGenerator 幂等占位与
  并发去重的检查顺序竞态(去重先于幂等)

版本: 0.7.3; README 同步(配置表新增 agent.enableReflection/memory.*/mcp.autoReconnect)

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 771 通过 53 跳过
  (better-sqlite3 ABI); Electron ABI 全量 824/824 零跳过
This commit is contained in:
2026-08-30 09:44:43 +08:00
parent 26169b7be4
commit ebe45482b0
68 changed files with 4568 additions and 664 deletions
@@ -0,0 +1,133 @@
/**
* Anthropic system cache_control 断言测试(v0.7.3 P1-1
*
* Anthropic 缓存按"内容块前缀"命中:system 必须以块数组传递并在块上打
* cache_control 才可缓存。本文件锁定:
* C1 非空 system → 块数组 + {type:'ephemeral'}
* C2 空 system → 保持空字符串(不发空块);
* C3 thinking 模式下断言仍然存在(cache 与 thinking 不互斥);
* C4 system 块文本为四分区完整拼接(roleDefinition/outputConstraints/
* safetyGuidelines/dynamicReminders)。
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { AnthropicAdapter } from '../anthropic.adapter';
import type { MetonaRequest } from '../../types';
function captureFetch(): { bodies: Array<Record<string, unknown>> } {
const bodies: Array<Record<string, unknown>> = [];
const genericBody = {
content: [{ type: 'text', text: 'ok' }],
usage: { input_tokens: 3, output_tokens: 2 },
stop_reason: 'end_turn',
};
const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => {
bodies.push(JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>);
return new Response(JSON.stringify(genericBody), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
return { bodies };
}
afterEach(() => {
vi.unstubAllGlobals();
});
function makeAdapter(): AnthropicAdapter {
return new AnthropicAdapter({
provider: 'anthropic',
baseURL: 'http://a.test',
apiKey: 'k',
defaultModel: 'claude-sonnet-4-5',
});
}
function makeRequest(overrides?: Partial<MetonaRequest>): MetonaRequest {
return {
meta: {
sessionId: 's1',
iteration: 1,
requestId: 'r1',
timestamp: Date.now(),
agentVersion: 'test',
},
systemPrompt: {
roleDefinition: 'You are Metona.',
outputConstraints: 'Be concise.',
safetyGuidelines: 'Stay safe.',
dynamicReminders: '## Current Workspace\n`/ws`',
},
messages: [{ role: 'user', content: 'hi', timestamp: Date.now() }],
params: { maxTokens: 63_488, temperature: 0, stream: false },
...overrides,
};
}
describe('AnthropicAdapter — system cache_controlP1-1', () => {
it('C1: 非空 system → 单 text 块 + cache_control ephemeral', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(makeRequest());
const system = bodies[0].system as Array<{
type: string;
text: string;
cache_control: { type: string };
}>;
expect(Array.isArray(system)).toBe(true);
expect(system).toHaveLength(1);
expect(system[0].type).toBe('text');
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
});
it('C2: 空 system → 保持空字符串(不发空块)', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
systemPrompt: { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' },
}),
);
expect(bodies[0].system).toBe('');
});
it('C3: thinking 模式下 cache_control 断言仍然存在', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(
makeRequest({
params: {
maxTokens: 8192,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'high',
},
}),
);
const system = bodies[0].system as Array<{ cache_control: { type: string } }>;
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
// thinking 与 cache 共存:thinking 块也在请求体中
expect(bodies[0].thinking).toMatchObject({ type: 'enabled' });
});
it('C4: system 块文本为四分区完整拼接', async () => {
const adapter = makeAdapter();
const { bodies } = captureFetch();
await adapter.send(makeRequest());
const system = bodies[0].system as Array<{ text: string }>;
expect(system[0].text).toContain('You are Metona.');
expect(system[0].text).toContain('Be concise.');
expect(system[0].text).toContain('Stay safe.');
expect(system[0].text).toContain('## Current Workspace');
});
});
@@ -109,29 +109,69 @@ describe('AnthropicAdapter — 请求体契约', () => {
role: 'assistant',
content: null,
toolCalls: [
{ id: 'tc_1', name: 'read_file', args: { path: 'a.txt' }, iteration: 1, timestamp: Date.now() },
{
id: 'tc_1',
name: 'read_file',
args: { path: 'a.txt' },
iteration: 1,
timestamp: Date.now(),
},
],
timestamp: Date.now(),
},
{ role: 'tool', content: null, toolResult: { toolCallId: 'tc_1', toolName: 'read_file', result: 'data', success: true, durationMs: 1, timestamp: Date.now() }, timestamp: Date.now() },
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_1',
toolName: 'read_file',
result: 'data',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
// 孤立 tool_result(前面没有对应 tool_use)应被过滤
{ role: 'tool', content: null, toolResult: { toolCallId: 'tc_orphan', toolName: 'x', result: '', success: true, durationMs: 1, timestamp: Date.now() }, timestamp: Date.now() },
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_orphan',
toolName: 'x',
result: '',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
{ role: 'user', content: 'next?', timestamp: Date.now() },
],
}),
);
const body = bodies[0];
expect(body.system).toContain('You are Metona.');
// v0.7.3 P1-1: system 转为块数组并打 cache_control 断言(稳定前缀 prompt cache
const system = body.system as Array<{
type: string;
text: string;
cache_control: { type: string };
}>;
expect(Array.isArray(system)).toBe(true);
expect(system[0].text).toContain('You are Metona.');
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
expect(Array.isArray(body.messages)).toBe(true);
const msgs = body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>;
// tool_use 的 assistant 消息存在且携带 id/name
const assistantToolMsg = msgs.find((m) => m.role === 'assistant');
expect(assistantToolMsg?.content[0]).toMatchObject({ type: 'tool_use', id: 'tc_1', name: 'read_file' });
expect(assistantToolMsg?.content[0]).toMatchObject({
type: 'tool_use',
id: 'tc_1',
name: 'read_file',
});
// tool 结果以 user 角色 tool_result 形态出现且配对 id 正确;孤立者被丢弃
const toolResultBlocks = msgs.flatMap((m) =>
m.content.filter((c) => c.type === 'tool_result'),
);
const toolResultBlocks = msgs.flatMap((m) => m.content.filter((c) => c.type === 'tool_result'));
expect(toolResultBlocks).toHaveLength(1);
expect(toolResultBlocks[0].tool_use_id).toBe('tc_1');
});
@@ -166,7 +206,15 @@ describe('AnthropicAdapter — 请求体契约', () => {
});
const { bodies } = captureFetch();
await adapter.send(
makeRequest({ params: { maxTokens: 1500, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'low' } }),
makeRequest({
params: {
maxTokens: 1500,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'low',
},
}),
);
const body = bodies[0];
const thinking = body.thinking as { type: string; budget_tokens: number };
@@ -185,13 +233,17 @@ describe('AnthropicAdapter — 请求体契约', () => {
});
const { bodies } = captureFetch();
await adapter.send(
makeRequest({ params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: true } }),
makeRequest({
params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: true },
}),
);
expect(bodies[0].temperature).toBeUndefined();
expect(bodies[0].thinking).toBeDefined();
await adapter.send(
makeRequest({ params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: false } }),
makeRequest({
params: { maxTokens: 4096, temperature: 0.7, stream: false, thinkingEnabled: false },
}),
);
expect(bodies[1].temperature).toBe(0.7);
expect(bodies[1].thinking).toBeUndefined();
@@ -236,13 +288,37 @@ describe('OllamaAdapter — 请求体契约', () => {
const adapter = makeOllama();
const { bodies } = captureFetch();
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'low' } }));
await adapter.send(
makeRequest({
params: {
maxTokens: 4096,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'low',
},
}),
);
expect(bodies[0].think).toBe('low');
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'max' } }));
await adapter.send(
makeRequest({
params: {
maxTokens: 4096,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'max',
},
}),
);
expect(bodies[1].think).toBe(true);
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false } }));
await adapter.send(
makeRequest({
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
}),
);
expect(bodies[2].think).toBeUndefined();
});
@@ -319,12 +395,26 @@ describe('AgnesAdapter — 思考模式对称性(v0.6.4', () => {
});
const { bodies } = captureFetch();
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: true, thinkingEffort: 'high' } }));
await adapter.send(
makeRequest({
params: {
maxTokens: 4096,
temperature: 0,
stream: false,
thinkingEnabled: true,
thinkingEffort: 'high',
},
}),
);
expect(
((bodies[0].chat_template_kwargs as Record<string, unknown>) ?? {}).enable_thinking,
).toBe(true);
await adapter.send(makeRequest({ params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false } }));
await adapter.send(
makeRequest({
params: { maxTokens: 4096, temperature: 0, stream: false, thinkingEnabled: false },
}),
);
expect(
((bodies[1].chat_template_kwargs as Record<string, unknown>) ?? {}).enable_thinking,
).toBe(false);
+14 -7
View File
@@ -248,8 +248,7 @@ export class AnthropicAdapter extends BaseAdapter {
usage: {
inputTokens: messageStartInputTokens,
outputTokens: (usage.output_tokens as number) ?? 0,
totalTokens:
messageStartInputTokens + ((usage.output_tokens as number) ?? 0),
totalTokens: messageStartInputTokens + ((usage.output_tokens as number) ?? 0),
// v0.6.4: 补采 Anthropic 自己的缓存字段(其他 provider 均已采集,
// cache_read/creation_input_tokens 与 output_tokens 同在 usage 内)
cacheHitTokens: (usage.cache_read_input_tokens as number) ?? undefined,
@@ -343,10 +342,7 @@ export class AnthropicAdapter extends BaseAdapter {
try {
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {};
} catch (err) {
args = truncatedArgumentsPayload(
(err as Error).message,
block.argsBuffer.slice(-120),
);
args = truncatedArgumentsPayload((err as Error).message, block.argsBuffer.slice(-120));
}
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
@@ -506,11 +502,22 @@ export class AnthropicAdapter extends BaseAdapter {
const body: Record<string, unknown> = {
model: this.config.defaultModel,
max_tokens: maxTokensForRequest,
system,
messages: merged,
stream,
};
// v0.7.3 P1-1: system 稳定前缀打 prompt cache 断言。
// Anthropic 缓存按"内容块前缀"命中 —— system 以字符串传递时无法附加
// cache_control,必须转为块数组并在最后一个块上打 {type:'ephemeral'}。
// 缓存前缀覆盖 tools + system(请求组装顺序 tools 在前):system 稳定后,
// 多轮对话/多轮迭代复用同一前缀,输入 token 计费按缓存读价(约 1/10)。
// 前缀稳定性由 P1-1 保证:易变内容(日期/记忆/附件提示)已迁入用户消息。
if (system) {
body.system = [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }];
} else {
body.system = system;
}
// 工具定义(input_schema 命名)
if (request.tools?.length) {
body.tools = request.tools.map((t) => ({
+146 -81
View File
@@ -59,17 +59,21 @@ export class OllamaAdapter extends BaseAdapter {
const nativeRequest = await this.toNativeRequest(request);
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: false }),
}, this.config.timeoutMs ?? 300_000);
const response = await this.fetchWithTimeout(
`${this.baseURL}/api/chat`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: false }),
},
this.config.timeoutMs ?? 300_000,
);
if (!response.ok) {
await this.throwHttpError(response, 'Ollama API error');
}
const data = await response.json() as Record<string, unknown>;
const data = (await response.json()) as Record<string, unknown>;
return this.toMetonaResponse(data, request.meta.requestId, request.meta.iteration);
}
@@ -79,11 +83,15 @@ export class OllamaAdapter extends BaseAdapter {
const nativeRequest = await this.toNativeRequest(request);
// #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理
const response = await this.fetchWithTimeout(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: true }),
}, this.config.timeoutMs ?? 300_000);
const response = await this.fetchWithTimeout(
`${this.baseURL}/api/chat`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: true }),
},
this.config.timeoutMs ?? 300_000,
);
if (!response.ok || !response.body) {
await this.throwHttpError(response, 'Ollama stream error');
@@ -209,7 +217,10 @@ export class OllamaAdapter extends BaseAdapter {
}
} catch (parseErr) {
// P2-8 修复: 与 sse-stream.ts 一致,记录解析失败行便于诊断
log.warn(`[Ollama] Failed to parse NDJSON line: ${(parseErr as Error).message}`, trimmed.slice(0, 200));
log.warn(
`[Ollama] Failed to parse NDJSON line: ${(parseErr as Error).message}`,
trimmed.slice(0, 200),
);
}
}
}
@@ -239,7 +250,13 @@ export class OllamaAdapter extends BaseAdapter {
format?: string | object;
images?: string[];
options?: Record<string, unknown>;
}): Promise<{ response: string; thinking?: string; done: boolean; totalDuration: number; evalCount: number }> {
}): Promise<{
response: string;
thinking?: string;
done: boolean;
totalDuration: number;
evalCount: number;
}> {
const response = await fetch(`${this.baseURL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -248,9 +265,12 @@ export class OllamaAdapter extends BaseAdapter {
});
if (!response.ok) throw new Error(`Ollama generate error: ${response.status}`);
const data = await response.json() as {
response?: string; thinking?: string; done?: boolean;
total_duration?: number; eval_count?: number;
const data = (await response.json()) as {
response?: string;
thinking?: string;
done?: boolean;
total_duration?: number;
eval_count?: number;
};
return {
@@ -277,7 +297,7 @@ export class OllamaAdapter extends BaseAdapter {
});
if (!response.ok) throw new Error(`Ollama embed error: ${response.status}`);
const data = await response.json() as { embeddings?: number[][]; total_duration?: number };
const data = (await response.json()) as { embeddings?: number[][]; total_duration?: number };
return {
embeddings: data.embeddings ?? [],
@@ -299,7 +319,7 @@ export class OllamaAdapter extends BaseAdapter {
signal: AbortSignal.timeout(10_000),
});
if (response.ok) {
const data = await response.json() as {
const data = (await response.json()) as {
models?: Array<{
name: string;
size?: number;
@@ -309,6 +329,8 @@ export class OllamaAdapter extends BaseAdapter {
if (data.models?.length) {
// v0.6.4 P4-1: 能力标志改为逐模型 /api/show 实测探测;单个探测失败
// 该模型回退保守 true(不可用时行为与旧实现一致,fail-open 保可用性)
// v0.7.3 P1-4: supportsVision 随探测结果透出(undefined = 未知 → 前端保守放行),
// 供上传入口拒绝不支持图片的本地语言模型
const enriched = await Promise.all(
data.models.map(async (m) => {
const caps = await this.probeCapabilities(m.name);
@@ -319,6 +341,7 @@ export class OllamaAdapter extends BaseAdapter {
contextWindow: OllamaAdapter.DEFAULT_CONTEXT_WINDOW,
supportsToolCalling: caps ? caps.supportsTools : true,
supportsThinking: caps ? caps.supportsThinking : true,
supportsVision: caps ? caps.supportsVision : undefined,
description: m.details
? `${m.details.family ?? 'unknown'} / ${m.details.parameter_size ?? '?'} / ${m.details.quantization_level ?? '?'}`
: undefined,
@@ -403,7 +426,9 @@ export class OllamaAdapter extends BaseAdapter {
// ===== POST /api/show =====
async showModel(model: string): Promise<{ parameters: string; template: string; capabilities: string[] } | null> {
async showModel(
model: string,
): Promise<{ parameters: string; template: string; capabilities: string[] } | null> {
try {
const response = await fetch(`${this.baseURL}/api/show`, {
method: 'POST',
@@ -412,7 +437,11 @@ export class OllamaAdapter extends BaseAdapter {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return null;
const data = await response.json() as { parameters?: string; template?: string; capabilities?: string[] };
const data = (await response.json()) as {
parameters?: string;
template?: string;
capabilities?: string[];
};
return {
parameters: data.parameters ?? '',
template: data.template ?? '',
@@ -470,13 +499,22 @@ export class OllamaAdapter extends BaseAdapter {
// ===== GET /api/ps =====
async listRunning(): Promise<Array<{ name: string; size: number; sizeVram: number; contextLength: number }>> {
async listRunning(): Promise<
Array<{ name: string; size: number; sizeVram: number; contextLength: number }>
> {
try {
const response = await fetch(`${this.baseURL}/api/ps`, {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return [];
const data = await response.json() as { models?: Array<{ name: string; size?: number; size_vram?: number; context_length?: number }> };
const data = (await response.json()) as {
models?: Array<{
name: string;
size?: number;
size_vram?: number;
context_length?: number;
}>;
};
return (data.models ?? []).map((m) => ({
name: m.name ?? '',
size: m.size ?? 0,
@@ -496,7 +534,7 @@ export class OllamaAdapter extends BaseAdapter {
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) return 'unknown';
const data = await response.json() as { version?: string };
const data = (await response.json()) as { version?: string };
return data.version ?? 'unknown';
} catch {
return 'unknown';
@@ -525,7 +563,9 @@ export class OllamaAdapter extends BaseAdapter {
const buf = Buffer.from(await res.arrayBuffer());
return buf.toString('base64');
} catch (error) {
log.warn(`[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`);
log.warn(
`[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`,
);
return '';
}
}
@@ -539,58 +579,60 @@ export class OllamaAdapter extends BaseAdapter {
request.systemPrompt.outputConstraints,
request.systemPrompt.safetyGuidelines,
request.systemPrompt.dynamicReminders,
].filter(Boolean).join('\n\n'),
]
.filter(Boolean)
.join('\n\n'),
},
];
// #2 修复: 改为 for 循环以支持 async 图片下载(map 回调无法 await)
for (const m of request.messages) {
if (m.role === 'system') continue;
// C-6 修复: Ollama API 不支持 null contentassistant 仅有 tool_calls 时转为空字符串
const msg: Record<string, unknown> = { role: m.role, content: m.content ?? '' };
// Ollama 图片使用 images 字段(纯 base64 数组,不含 data: 前缀)
if (m.images?.length) {
// #2 修复: 支持公网 URL 图片,下载后转为纯 base64
// 之前直接将 URL 字符串传给 Ollama,导致 base64 解码错误
const resolvedImages: string[] = [];
for (const img of m.images) {
const url = img.url;
if (url.startsWith('data:')) {
// data:image/png;base64,iVBOR... → iVBOR...
const base64Part = url.split(',')[1];
resolvedImages.push(base64Part ?? url);
} else if (url.startsWith('http://') || url.startsWith('https://')) {
// #2 修复: 公网 URL → 下载 → 纯 base64
const base64 = await this.resolveImageToBase64(url);
if (base64) resolvedImages.push(base64);
} else {
// 已是纯 base64 字符串(无 data: 前缀)
resolvedImages.push(url);
}
// C-6 修复: Ollama API 不支持 null contentassistant 仅有 tool_calls 时转为空字符串
const msg: Record<string, unknown> = { role: m.role, content: m.content ?? '' };
// Ollama 图片使用 images 字段(纯 base64 数组,不含 data: 前缀)
if (m.images?.length) {
// #2 修复: 支持公网 URL 图片,下载后转为纯 base64
// 之前直接将 URL 字符串传给 Ollama,导致 base64 解码错误
const resolvedImages: string[] = [];
for (const img of m.images) {
const url = img.url;
if (url.startsWith('data:')) {
// data:image/png;base64,iVBOR... → iVBOR...
const base64Part = url.split(',')[1];
resolvedImages.push(base64Part ?? url);
} else if (url.startsWith('http://') || url.startsWith('https://')) {
// #2 修复: 公网 URL → 下载 → 纯 base64
const base64 = await this.resolveImageToBase64(url);
if (base64) resolvedImages.push(base64);
} else {
// 已是纯 base64 字符串(无 data: 前缀)
resolvedImages.push(url);
}
msg.images = resolvedImages;
}
// 工具结果
if (m.role === 'tool' && m.toolResult) {
msg.tool_call_id = m.toolResult.toolCallId;
// CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content
msg.content = m.toolResult.error
? m.toolResult.error
: (typeof m.toolResult.result === 'string'
? m.toolResult.result
: JSON.stringify(m.toolResult.result));
}
// assistant 工具调用(Ollama REST API 要求 arguments 为 JSON 字符串)
if (m.role === 'assistant' && m.toolCalls?.length) {
msg.tool_calls = m.toolCalls.map((tc) => ({
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
}));
}
// 推理内容回传(保持多轮推理链完整)
if (m.role === 'assistant' && m.reasoningContent) {
(msg as Record<string, unknown>).reasoning_content = m.reasoningContent;
}
messages.push(msg);
msg.images = resolvedImages;
}
// 工具结果
if (m.role === 'tool' && m.toolResult) {
msg.tool_call_id = m.toolResult.toolCallId;
// CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content
msg.content = m.toolResult.error
? m.toolResult.error
: typeof m.toolResult.result === 'string'
? m.toolResult.result
: JSON.stringify(m.toolResult.result);
}
// assistant 工具调用(Ollama REST API 要求 arguments 为 JSON 字符串)
if (m.role === 'assistant' && m.toolCalls?.length) {
msg.tool_calls = m.toolCalls.map((tc) => ({
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
}));
}
// 推理内容回传(保持多轮推理链完整)
if (m.role === 'assistant' && m.reasoningContent) {
(msg as Record<string, unknown>).reasoning_content = m.reasoningContent;
}
messages.push(msg);
}
const body: Record<string, unknown> = {
@@ -619,14 +661,23 @@ export class OllamaAdapter extends BaseAdapter {
// Thinking 模式
if (request.params.thinkingEnabled) {
const effortMap: Record<string, string | boolean> = { low: 'low', medium: 'medium', high: 'high', max: true };
const effortMap: Record<string, string | boolean> = {
low: 'low',
medium: 'medium',
high: 'high',
max: true,
};
body.think = effortMap[request.params.thinkingEffort ?? 'high'] ?? true;
}
return body;
}
private toMetonaResponse(data: Record<string, unknown>, requestId: string, iteration: number = 0): MetonaResponse {
private toMetonaResponse(
data: Record<string, unknown>,
requestId: string,
iteration: number = 0,
): MetonaResponse {
const message = data.message as Record<string, unknown> | undefined;
const toolCalls = message?.tool_calls as Array<Record<string, unknown>> | undefined;
return {
@@ -638,11 +689,14 @@ export class OllamaAdapter extends BaseAdapter {
timestamp: Date.now(),
perfStats: {
loadDurationMs: data.load_duration ? (data.load_duration as number) / 1e6 : undefined,
promptEvalDurationMs: data.prompt_eval_duration ? (data.prompt_eval_duration as number) / 1e6 : undefined,
evalDurationMs: data.eval_duration ? (data.eval_duration as number) / 1e6 : undefined,
tokensPerSecond: data.eval_count && data.eval_duration
? ((data.eval_count as number) / ((data.eval_duration as number) / 1e9))
promptEvalDurationMs: data.prompt_eval_duration
? (data.prompt_eval_duration as number) / 1e6
: undefined,
evalDurationMs: data.eval_duration ? (data.eval_duration as number) / 1e6 : undefined,
tokensPerSecond:
data.eval_count && data.eval_duration
? (data.eval_count as number) / ((data.eval_duration as number) / 1e9)
: undefined,
},
},
content: (message?.content as string) ?? '',
@@ -652,7 +706,10 @@ export class OllamaAdapter extends BaseAdapter {
const rawArgs = fn?.arguments;
let args: Record<string, unknown> = {};
try {
args = typeof rawArgs === 'string' ? JSON.parse(rawArgs) : (rawArgs as Record<string, unknown>) ?? {};
args =
typeof rawArgs === 'string'
? JSON.parse(rawArgs)
: ((rawArgs as Record<string, unknown>) ?? {});
} catch (parseErr) {
// v0.6.4: 非流式路径截断自愈对齐 —— 原 catch 静默降级 {},与流式修复后的
// 行为不一致。统一转为 _truncatedArguments 错误参数。
@@ -677,7 +734,10 @@ export class OllamaAdapter extends BaseAdapter {
outputTokens: (data.eval_count as number) ?? 0,
totalTokens: ((data.prompt_eval_count as number) ?? 0) + ((data.eval_count as number) ?? 0),
},
finishReason: mapOllamaDoneReason(data.done_reason as string | undefined, !!message?.tool_calls),
finishReason: mapOllamaDoneReason(
data.done_reason as string | undefined,
!!message?.tool_calls,
),
};
}
}
@@ -693,10 +753,15 @@ function mapOllamaDoneReason(
): MetonaFinishReason {
if (hasToolCalls) return MetonaFinishReason.TOOL_CALLS;
switch (reason) {
case 'stop': return MetonaFinishReason.STOP;
case 'length': return MetonaFinishReason.LENGTH;
case 'load': return MetonaFinishReason.STOP; // 冷启动加载完成,非错误
case 'unload': return MetonaFinishReason.STOP;
default: return MetonaFinishReason.STOP;
case 'stop':
return MetonaFinishReason.STOP;
case 'length':
return MetonaFinishReason.LENGTH;
case 'load':
return MetonaFinishReason.STOP; // 冷启动加载完成,非错误
case 'unload':
return MetonaFinishReason.STOP;
default:
return MetonaFinishReason.STOP;
}
}
@@ -15,7 +15,10 @@ import type { IMetonaProviderAdapter, MetonaResponse, MetonaStreamEvent } from '
import { MetonaStreamEventType } from '../../types';
/** 构造 Mock AdaptersendStream 按脚本产出事件 */
function createMockAdapter(scripts: MetonaStreamEvent[][], opts?: { failWith?: Error }): IMetonaProviderAdapter {
function createMockAdapter(
scripts: MetonaStreamEvent[][],
opts?: { failWith?: Error },
): IMetonaProviderAdapter {
let call = 0;
return {
providerId: 'mock',
@@ -23,12 +26,20 @@ function createMockAdapter(scripts: MetonaStreamEvent[][], opts?: { failWith?: E
supportsToolCalling: true,
supportsThinking: false,
getContextWindow: () => 1_000_000,
send: vi.fn(async (): Promise<MetonaResponse> => ({
meta: { requestId: 'r_test', provider: 'mock', model: 'mock-model', latencyMs: 1, timestamp: Date.now() },
content: 'ok',
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
finishReason: 'stop' as never,
})),
send: vi.fn(
async (): Promise<MetonaResponse> => ({
meta: {
requestId: 'r_test',
provider: 'mock',
model: 'mock-model',
latencyMs: 1,
timestamp: Date.now(),
},
content: 'ok',
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
finishReason: 'stop' as never,
}),
),
sendStream: vi.fn(async function* (): AsyncIterable<MetonaStreamEvent> {
if (opts?.failWith) throw opts.failWith;
const script = scripts[call % scripts.length];
@@ -42,8 +53,23 @@ function createMockAdapter(scripts: MetonaStreamEvent[][], opts?: { failWith?: E
function textDoneEvent(text: string): MetonaStreamEvent[] {
return [
{ type: MetonaStreamEventType.TEXT_DELTA, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(), delta: text },
{ type: MetonaStreamEventType.DONE, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now() },
{
type: MetonaStreamEventType.TEXT_DELTA,
requestId: 'r1',
sessionId: 's1',
iteration: 1,
seq: 0,
timestamp: Date.now(),
delta: text,
},
{
type: MetonaStreamEventType.DONE,
requestId: 'r1',
sessionId: 's1',
iteration: 1,
seq: 1,
timestamp: Date.now(),
},
];
}
@@ -51,10 +77,21 @@ function toolCallEvent(name: string, args: Record<string, unknown>): MetonaStrea
return [
{
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId: 'r1', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(),
requestId: 'r1',
sessionId: 's1',
iteration: 1,
seq: 0,
timestamp: Date.now(),
toolCall: { id: 'tc_test', name, args, iteration: 1, timestamp: Date.now() },
},
{ type: MetonaStreamEventType.DONE, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now() },
{
type: MetonaStreamEventType.DONE,
requestId: 'r1',
sessionId: 's1',
iteration: 1,
seq: 1,
timestamp: Date.now(),
},
];
}
@@ -121,7 +158,9 @@ describe('AgentLoopEngine', () => {
});
it('不可重试错误直接 ERROR(无 fallback 时)', async () => {
const adapter = createMockAdapter([], { failWith: Object.assign(new Error('401 unauthorized'), { status: 401 }) });
const adapter = createMockAdapter([], {
failWith: Object.assign(new Error('401 unauthorized'), { status: 401 }),
});
const engine = new AgentLoopEngine({ retryCount: 0 }, adapter);
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
expect(output.terminationReason).toBe(TerminationReason.ERROR);
@@ -129,7 +168,9 @@ describe('AgentLoopEngine', () => {
it('P1 故障转移:主 Provider 失败后切换到 fallback Provider', async () => {
// 主 adapter 每次都失败(401 不可重试)
const primary = createMockAdapter([], { failWith: Object.assign(new Error('401 invalid key'), { status: 401 }) });
const primary = createMockAdapter([], {
failWith: Object.assign(new Error('401 invalid key'), { status: 401 }),
});
// fallback 正常返回
const fallback = createMockAdapter([textDoneEvent('fallback answer')]);
@@ -151,8 +192,12 @@ describe('AgentLoopEngine', () => {
});
it('P1 故障转移仅触发一次(fallback 也失败不回切)', async () => {
const primary = createMockAdapter([], { failWith: Object.assign(new Error('401'), { status: 401 }) });
const fallback = createMockAdapter([], { failWith: Object.assign(new Error('500'), { status: 500 }) });
const primary = createMockAdapter([], {
failWith: Object.assign(new Error('401'), { status: 401 }),
});
const fallback = createMockAdapter([], {
failWith: Object.assign(new Error('500'), { status: 500 }),
});
const engine = new AgentLoopEngine({ retryCount: 0 }, primary);
engine.setFallbackAdapter(fallback);
@@ -162,3 +207,72 @@ describe('AgentLoopEngine', () => {
expect(output.terminationReason).toBe(TerminationReason.ERROR);
});
});
// ===== v0.7.3 P4-4 / P3-1: 死循环乒乓检测 + REFLECTING 状态接线 =====
describe('AgentLoopEngine — 死循环乒乓检测(ABABP4-4', () => {
it('最近 4 轮 A→B→A→B 交替(A≠B)触发 DEAD_LOOP(驻留模式抓不住的乒乓)', async () => {
const readScript = toolCallEvent('read_file', { file_path: 'x.ts' });
const writeScript = toolCallEvent('write_file', { file_path: 'x.ts' });
// 1:read 2:write 3:read 4:write ← 第 4 轮 PARSING 时滑窗构成 ABAB
const adapter = createMockAdapter([readScript, writeScript, readScript, writeScript]);
const engine = new AgentLoopEngine({ maxIterations: 6 }, adapter);
const deadLoopEvents: unknown[] = [];
engine.on('deadLoop', (d) => deadLoopEvents.push(d));
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
expect(output.terminationReason).toBe(TerminationReason.DEAD_LOOP);
expect(deadLoopEvents.length).toBe(1);
});
it('A→B→C 交替(无重复模式)不误报,按 MAX_ITERATIONS 终止', async () => {
const scripts = [
toolCallEvent('read_file', { file_path: 'a.ts' }),
toolCallEvent('write_file', { file_path: 'a.ts' }),
toolCallEvent('lint_code', {}),
];
const adapter = createMockAdapter(scripts);
const engine = new AgentLoopEngine({ maxIterations: 4 }, adapter);
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
expect(output.terminationReason).toBe(TerminationReason.MAX_ITERATIONS);
});
it('A→B→B→B 前缀不构成 ABAB(A≠B 约束),由驻留模式在 3 连 B 时接管', async () => {
const readScript = toolCallEvent('read_file', { file_path: 'x.ts' });
const writeScript = toolCallEvent('write_file', { file_path: 'x.ts' });
// 1:read 2:write 3:write 4:write —— 第 4 轮时 ABAB 不成立,但 3 连 write 命中驻留模式
const adapter = createMockAdapter([readScript, writeScript, writeScript, writeScript]);
const engine = new AgentLoopEngine({ maxIterations: 6 }, adapter);
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
expect(output.terminationReason).toBe(TerminationReason.DEAD_LOOP);
});
});
describe('AgentLoopEngine — REFLECTING 状态接线(P3-1 enableReflection', () => {
const collectStates = async (config: Record<string, unknown>): Promise<string[]> => {
const adapter = createMockAdapter([
toolCallEvent('read_file', { file_path: 'a.ts' }),
textDoneEvent('done'),
]);
const engine = new AgentLoopEngine(config as never, adapter);
const states: string[] = [];
engine.on('stateChange', (d: { state?: string; current?: string }) => {
const s = d.state ?? d.current ?? '';
if (!states.includes(s)) states.push(s);
});
await engine.runStream(userMessage, 's1', [], systemPrompt);
return states;
};
it('enableReflection=true 时工具执行后进入 REFLECTING 状态', async () => {
const states = await collectStates({ maxIterations: 2, enableReflection: true });
expect(states).toContain('REFLECTING');
expect(states).toContain('EXECUTING');
expect(states).toContain('OBSERVING');
});
it('enableReflection=false(默认)时不进入 REFLECTING', async () => {
const states = await collectStates({ maxIterations: 2, enableReflection: false });
expect(states).not.toContain('REFLECTING');
});
});
+32 -15
View File
@@ -50,7 +50,6 @@ class DeadLoopError extends Error {
const DEFAULT_CONFIG: AgentLoopConfig = {
maxIterations: 20,
timeoutMs: 120_000,
totalTimeoutMs: 600_000,
enableReflection: false,
compressionThreshold: 0.8,
@@ -563,7 +562,7 @@ export class AgentLoopEngine extends EventEmitter {
});
// 抛出特殊错误,主循环捕获后以 DEAD_LOOP 原因终止
throw new DeadLoopError(
`Detected a potential infinite loop: the same tool calls were repeated for 3 consecutive iterations. Please refine the approach or provide more specific instructions.`,
`Detected a potential infinite loop: the same tool calls were repeated for 3 consecutive iterations, or two alternating call patterns kept cycling (A→B→A→B) without progress. Please refine the approach or provide more specific instructions.`,
);
}
}
@@ -620,7 +619,11 @@ export class AgentLoopEngine extends EventEmitter {
await this.transitionTo(AgentLoopState.OBSERVING);
// === v0.2.0: REFLECTING 状态 — 观察工具结果,决定是否继续 ===
// 如果有工具调用且需要后续推理,进入 REFLECTING 状态
// v0.7.3 接线说明:REFLECTING 分支此前依赖 enableReflection 配置,但该配置
// 全链路无任何置 true 的路径(死配置)。现由 agent.enableReflection 配置
// 真实驱动(main.ts baseConfig → updateConfigAll → 本分支),启用后每轮
// 工具执行完毕会经过 REFLECTING 状态:工具结果存在失败时记录告警日志,
// 供 SLO 与排障观察(不阻断循环——错误结果已由 CE-2 路径回传模型自愈)。
if (this.config.enableReflection && step.toolCalls && step.toolCalls.length > 0) {
await this.transitionTo(AgentLoopState.REFLECTING);
// 检查工具执行是否有错误,如果有严重错误可以提前终止
@@ -1087,9 +1090,13 @@ export class AgentLoopEngine extends EventEmitter {
/**
* v0.3.0: 死循环检测
*
* 检测策略:
* 将每轮的工具调用序列化为签名字符串,检查最近3轮的签名是否完全相同。
* 如果连续3轮使用完全相同的参数调用相同的工具,判定为死循环。
* 检测策略v0.7.3 起双模式)
* 1. 驻留模式 — 将每轮的工具调用序列化为签名字符串,检查最近3轮的签名是否完全相同。
* 如果连续3轮使用完全相同的参数调用相同的工具,判定为死循环。
* 2. 乒乓模式(v0.7.3 新增)— 最近4轮构成 ABAB 交替(r1===r3 && r2===r4 && r1!==r2)。
* 典型场景:模型在"读文件 A → 写文件 B"两步之间无限往返(每次读完又改回),
* 单步签名各不相同,驻留模式永不命中;docs/Agentic-Loop详解.md 第五章将
* "两种状态间反复来回切换、毫无进展"列为必须检测的停滞模式。
*
* v0.3.0 修复:
* - 对 args 的键进行排序,避免 JSON.stringify 键顺序不一致导致漏报
@@ -1123,21 +1130,31 @@ export class AgentLoopEngine extends EventEmitter {
this.toolCallHistory.push(signature);
// 只保留最近5轮的记录(足够检测3轮重复,同时避免内存增长)
// 只保留最近5轮的记录(足够检测3轮重复与4轮乒乓,同时避免内存增长)
if (this.toolCallHistory.length > 5) {
this.toolCallHistory.shift();
}
// 需要至少3轮数据才能检测
if (this.toolCallHistory.length < 3) return false;
const len = this.toolCallHistory.length;
const r1 = this.toolCallHistory[len - 1]; // 当前轮
const r2 = this.toolCallHistory[len - 2]; // 上一轮
const r3 = this.toolCallHistory[len - 3]; // 上上一轮
// 连续3轮完全相同 → 死循环
return r1 === r2 && r2 === r3;
// 模式 1连续3轮完全相同 → 死循环
if (len >= 3) {
const r1 = this.toolCallHistory[len - 1]; // 当前轮
const r2 = this.toolCallHistory[len - 2]; // 上一轮
const r3 = this.toolCallHistory[len - 3]; // 上上一轮
if (r1 === r2 && r2 === r3) return true;
}
// 模式 2v0.7.3):最近4轮 ABAB 交替(A≠B)→ 乒乓死循环
if (len >= 4) {
const a1 = this.toolCallHistory[len - 4];
const b1 = this.toolCallHistory[len - 3];
const a2 = this.toolCallHistory[len - 2];
const b2 = this.toolCallHistory[len - 1];
if (a1 === a2 && b1 === b2 && a1 !== b1) return true;
}
return false;
}
/**
-1
View File
@@ -60,7 +60,6 @@ export interface TokenUsage {
export interface AgentLoopConfig {
maxIterations: number;
timeoutMs: number;
totalTimeoutMs: number;
enableReflection: boolean;
compressionThreshold: number;
@@ -0,0 +1,88 @@
/**
* ConfirmationHook forgetSession 测试(v0.7.3 P2-3
*
* 锁定会话终态清理契约:
* F1 forgetSession 清空该会话的决策记忆(拒绝记忆不再残留);
* F2 forgetSession 同时拒绝该会话等待中的确认(clearPending 语义);
* F3 会话隔离:清理 A 不影响 B;
* F4 空/未知 sessionId 幂等无副作用。
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// 模拟一个存活窗口 —— beforeExecute 的 hasAvailableWindow 守卫需要它,
// 否则确认请求在创建 pending 之前即被短路(测不到记忆/pending 路径)
const fakeWindow = {
isDestroyed: () => false,
webContents: { send: vi.fn() },
};
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: vi.fn(() => [fakeWindow]) },
}));
import { ConfirmationHook } from '../confirmation-hook';
import type { MetonaToolCall, MetonaToolDef } from '../../../harness/types';
import { MetonaRiskLevel, MetonaToolCategory } from '../../../harness/types';
function makeToolCall(name: string, id = `tc_${name}`): MetonaToolCall {
return { id, name, args: {}, iteration: 1, timestamp: Date.now() };
}
const NEEDS_CONFIRM_DEF: MetonaToolDef = {
name: 'run_command',
description: 'test',
parameters: { type: 'object', properties: {}, required: [] },
category: MetonaToolCategory.CODE_EXECUTION,
riskLevel: MetonaRiskLevel.HIGH,
requiresPermission: true,
timeoutMs: 1000,
};
describe('ConfirmationHook — forgetSessionP2-3', () => {
let hook: ConfirmationHook;
beforeEach(() => {
vi.useFakeTimers();
hook = new ConfirmationHook(null, null);
hook.setToolDefs([NEEDS_CONFIRM_DEF]);
});
it('F1: 会话删除后决策记忆被清空(拒绝记忆不再跨生命周期残留)', async () => {
const pending = hook.beforeExecute(makeToolCall('run_command'), 'sess-A');
hook.resolveConfirmation(`tc_run_command`, false, true); // 记住拒绝
await pending;
expect(hook.getRememberedDenials('sess-A')).toHaveLength(1);
hook.forgetSession('sess-A');
expect(hook.getRememberedDenials('sess-A')).toHaveLength(0);
});
it('F2: forgetSession 拒绝该会话等待中的确认(clearPending 语义)', async () => {
const p1 = hook.beforeExecute(makeToolCall('run_command'), 'sess-A');
// resolve pending(拒绝)后走记忆清理路径
hook.forgetSession('sess-A');
await expect(p1).resolves.toMatchObject({ blocked: true });
// pending 已清空 —— getPendingConfirmations 无残留
expect(hook.getPendingConfirmations()).toHaveLength(0);
});
it('F3: 会话隔离 —— 清理 A 不影响 B 的决策记忆', async () => {
const p1 = hook.beforeExecute(makeToolCall('run_command', 'tc_A'), 'sess-A');
hook.resolveConfirmation('tc_A', false, true);
await p1;
const p2 = hook.beforeExecute(makeToolCall('run_command', 'tc_B'), 'sess-B');
hook.resolveConfirmation('tc_B', false, true);
await p2;
hook.forgetSession('sess-A');
expect(hook.getRememberedDenials('sess-A')).toHaveLength(0);
expect(hook.getRememberedDenials('sess-B')).toHaveLength(1);
});
it('F4: 空 sessionId 幂等无副作用;未知会话不抛错', () => {
expect(() => hook.forgetSession('')).not.toThrow();
expect(() => hook.forgetSession('nonexistent')).not.toThrow();
expect(hook.getRememberedDenials()).toHaveLength(0);
});
});
@@ -550,6 +550,22 @@ export class ConfirmationHook implements PreToolHook {
}
}
/**
* v0.7.3 P2-3: 会话生命周期终态清理(会话删除 / SubAgent 终结时调用)。
*
* 此前 rememberedDecisions 两级 Map 只增不减 —— 会话删除/子任务终结后其
* 决策记忆永久残留,长期运行实例随会话数缓慢泄漏。本方法与 clearPending
* 的区别:clearPending 只处理等待中的确认(会话中断时用,会话本身仍存活),
* 本方法面向"会话已终结"的终态,同时清空 pending 与决策记忆。
*
* @param sessionId 会话 ID(主会话或 SubAgent taskId
*/
forgetSession(sessionId: string): void {
if (!sessionId) return;
this.clearPending(sessionId);
this.rememberedDecisions.delete(sessionId);
}
// ===== 私有辅助(v0.5.0: 会话隔离) =====
/** 获取(或创建)指定会话的决策记忆表 */
@@ -0,0 +1,97 @@
/**
* 记忆固化触发决策测试(v0.7.3 P1-5
*
* 锁定 shouldConsolidate 的四类判定:总开关 / 内容门控(回答长度 ∨ 成功工具调用)/
* 频率窗口(含首次不限)/ 配置兜底(非法数值回退安全下限)。
*/
import { describe, it, expect } from 'vitest';
import {
shouldConsolidate,
MIN_CONSOLIDATION_INTERVAL_MS,
MIN_CONSOLIDATION_MIN_CHARS,
} from '../consolidation-policy';
const BASE = {
enabled: true,
answerChars: 500,
minChars: 200,
hadSuccessfulToolCall: false,
lastConsolidationAt: 0,
now: 1_000_000,
intervalMs: 600_000,
};
describe('shouldConsolidate', () => {
it('总开关显式关闭 → disabled', () => {
expect(shouldConsolidate({ ...BASE, enabled: false })).toEqual({
consolidate: false,
reason: 'disabled',
});
});
it('回答够长且首次固化 → 允许(lastConsolidationAt=0 不受频率限制)', () => {
expect(shouldConsolidate(BASE)).toEqual({
consolidate: true,
reason: 'content-and-frequency-pass',
});
});
it('回答过短且无成功工具调用 → below-threshold(短寒暄不触发固化)', () => {
expect(shouldConsolidate({ ...BASE, answerChars: 50, hadSuccessfulToolCall: false })).toEqual({
consolidate: false,
reason: 'below-threshold',
});
});
it('回答过短但存在成功工具调用 → 允许(事实性上下文可沉淀)', () => {
expect(shouldConsolidate({ ...BASE, answerChars: 50, hadSuccessfulToolCall: true })).toEqual({
consolidate: true,
reason: 'content-and-frequency-pass',
});
});
it('频率窗口内重复触发 → throttled', () => {
expect(
shouldConsolidate({
...BASE,
lastConsolidationAt: BASE.now - 60_000, // 1 分钟前刚固化过
}),
).toEqual({ consolidate: false, reason: 'throttled' });
});
it('频率窗口已过 → 允许', () => {
expect(
shouldConsolidate({
...BASE,
lastConsolidationAt: BASE.now - 600_001,
}),
).toEqual({ consolidate: true, reason: 'content-and-frequency-pass' });
});
it('配置兜底:minChars=0 不会让纯寒暄触发(安全下限生效)', () => {
expect(shouldConsolidate({ ...BASE, minChars: 0, answerChars: 10 })).toEqual({
consolidate: false,
reason: 'below-threshold',
});
expect(MIN_CONSOLIDATION_MIN_CHARS).toBeGreaterThan(0);
});
it('配置兜底:intervalMs=0 不会退化为每条消息固化(安全下限生效)', () => {
expect(
shouldConsolidate({
...BASE,
intervalMs: 0,
lastConsolidationAt: BASE.now - 30_000, // 30 秒前刚固化
}),
).toEqual({ consolidate: false, reason: 'throttled' });
expect(MIN_CONSOLIDATION_INTERVAL_MS).toBeGreaterThanOrEqual(60_000);
});
it('配置兜底:minChars/intervalMs 为 NaN 时按默认值处理', () => {
expect(shouldConsolidate({ ...BASE, minChars: Number.NaN, intervalMs: Number.NaN })).toEqual({
consolidate: true,
reason: 'content-and-frequency-pass',
});
});
});
@@ -0,0 +1,74 @@
/**
* Consolidation Policy — 记忆固化触发决策(v0.7.3 P1-5
*
* 背景:MemoryConsolidator 在每次 run 完成后无条件发起一次非流式 LLM 请求
* (30s 超时)判断本次对话是否有值得持久化的记忆。短寒暄/单轮问答同样触发,
* 纯成本浪费且对 Provider 构成无意义请求压力。
*
* 本模块把触发决策收敛为纯函数(可表测),决策输入:
* - 总开关 memory.consolidationEnabledfail-secure:仅显式 false 才关闭)
* - 内容门控:本次回答 ≥ minChars 字符 **或** 本次 run 存在成功的工具调用
* (工具调用意味着产生了可沉淀的事实性上下文)
* - 频率门控:距该会话上次固化 ≥ intervalMs(首次不设限,但仍受内容门控约束)
*
* 决策与执行解耦:本模块不做 IO,调用方(ipc/agent.ts)持有会话级
* lastConsolidationAt 状态并执行 consolidate。
*/
export interface ConsolidationDecisionInput {
/** 总开关(memory.consolidationEnabledundefined/null 视为开启) */
enabled: boolean | null | undefined;
/** 本次 Agent 最终回答的字符数 */
answerChars: number;
/** 内容门控阈值(memory.consolidationMinChars,默认 200 */
minChars: number;
/** 本次 run 是否存在成功的工具调用 */
hadSuccessfulToolCall: boolean;
/** 该会话上次固化的时间戳(0 = 从未固化) */
lastConsolidationAt: number;
/** 当前时间戳 */
now: number;
/** 频率门控窗口(memory.consolidationIntervalMs,默认 600000 */
intervalMs: number;
}
export type ConsolidationDecision =
| { consolidate: true; reason: 'content-and-frequency-pass' }
| { consolidate: false; reason: 'disabled' | 'below-threshold' | 'throttled' };
/** 频率窗口合法下限(防误配 0/负值导致门控失效——0 等价于每条消息都固化) */
export const MIN_CONSOLIDATION_INTERVAL_MS = 60_000;
/** 内容门控合法下限(防误配 0 导致纯寒暄也固化) */
export const MIN_CONSOLIDATION_MIN_CHARS = 20;
/**
* 判定本次 run 是否应触发记忆固化。
*/
export function shouldConsolidate(input: ConsolidationDecisionInput): ConsolidationDecision {
// 1. 总开关 —— fail-secure 语义由调用方负责(!== false 才视为开启后传入布尔)
if (input.enabled === false) {
return { consolidate: false, reason: 'disabled' };
}
// 2. 内容门控:回答够长 或 有成功的工具调用(事实性上下文)
const minChars = Math.max(
MIN_CONSOLIDATION_MIN_CHARS,
Number.isFinite(input.minChars) ? input.minChars : 200,
);
const contentWorthy = input.answerChars >= minChars || input.hadSuccessfulToolCall;
if (!contentWorthy) {
return { consolidate: false, reason: 'below-threshold' };
}
// 3. 频率门控:上次固化距今不足窗口 → 跳过(首次 lastConsolidationAt=0 不受限)
const intervalMs = Math.max(
MIN_CONSOLIDATION_INTERVAL_MS,
Number.isFinite(input.intervalMs) ? input.intervalMs : 600_000,
);
if (input.lastConsolidationAt > 0 && input.now - input.lastConsolidationAt < intervalMs) {
return { consolidate: false, reason: 'throttled' };
}
return { consolidate: true, reason: 'content-and-frequency-pass' };
}
@@ -167,6 +167,8 @@ export class TaskOrchestrator extends EventEmitter {
thinkingEffort: this.defaultConfig?.thinkingEffort ?? 'medium',
contextLength: this.defaultConfig?.contextLength,
contextWindow: this.defaultConfig?.contextWindow ?? 128_000,
// v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflectionREFLECTING 状态开关)
enableReflection: this.defaultConfig?.enableReflection ?? false,
},
this.engines.createAdapter(),
this.toolRegistry,
@@ -64,11 +64,23 @@ describe('ContextBuilder — isUsingFallbackRole 首次降级通知语义', () =
});
describe('ContextBuilder — 动态区注入', () => {
it('注入当前日期时间(含本地时区', () => {
it('v0.7.3 P1-1: 不再注入日期时间(prompt cache 前缀稳定性', () => {
const cb = new ContextBuilder();
const prompt = cb.buildSystemPrompt({ soul: 'x', memory: '' });
expect(prompt.dynamicReminders).toContain('## Current Date & Time');
expect(prompt.dynamicReminders).toMatch(/UTC[+-]/);
expect(prompt.dynamicReminders).not.toContain('## Current Date & Time');
expect(prompt.dynamicReminders).not.toMatch(/UTC[+-]/);
});
it('v0.7.3 P1-1: system 输出跨调用字节级稳定(同输入 → 同字节)', () => {
const cb = new ContextBuilder();
const files = { soul: 'x', memory: '## 用户偏好\n- 偏好深色主题' };
const a = cb.buildSystemPrompt(files, '/tmp/ws-demo');
const b = cb.buildSystemPrompt(files, '/tmp/ws-demo');
// 跨 run 缓存命中的前提:四分区逐字节一致(日期时间已移入用户消息前置块)
expect(a.roleDefinition).toBe(b.roleDefinition);
expect(a.outputConstraints).toBe(b.outputConstraints);
expect(a.safetyGuidelines).toBe(b.safetyGuidelines);
expect(a.dynamicReminders).toBe(b.dynamicReminders);
});
it('注入工作空间路径(动态区,路径可切换)', () => {
@@ -0,0 +1,94 @@
/**
* 用户上下文前置块测试(v0.7.3 P1-1
*
* 锁定三类动态内容(日期时间 / 记忆 / 附件提示)在用户消息前置块的
* 分组结构与空值收缩行为 —— 它们从 system prompt 迁出的契约面。
*/
import { describe, it, expect } from 'vitest';
import { buildUserContextPrefix, withUserContextPrefix } from '../user-context';
describe('buildUserContextPrefix', () => {
it('恒含头部说明与日期时间分区(唯一无条件分区)', () => {
const prefix = buildUserContextPrefix({ now: Date.UTC(2026, 7, 30, 6, 30) });
expect(prefix).toContain('[Contextual information for this message');
expect(prefix).toContain('## Current Date & Time');
});
it('无记忆/附件时不产出对应分区(空值收缩)', () => {
const prefix = buildUserContextPrefix({ now: Date.now() });
expect(prefix).not.toContain('## Relevant Memories (Retrieved)');
expect(prefix).not.toContain('## User Attachments (Direct Upload)');
});
it('记忆分区:条目格式与截断口径(沿用原 system 注入契约)', () => {
const prefix = buildUserContextPrefix({
now: Date.now(),
memories: [
{
id: 'm1',
type: 'semantic',
content: 'x'.repeat(500),
source: 'agent_thought',
importance: 0.9,
score: 0.8,
createdAt: Date.now(),
},
],
});
expect(prefix).toContain('## Relevant Memories (Retrieved)');
expect(prefix).toMatch(/\[1\] \(semantic, 重要度: 0\.9\)/);
// 内容截断到 200 字符
expect(prefix).toContain('x'.repeat(200));
expect(prefix).not.toContain('x'.repeat(201));
});
it('附件分区:图片提示禁止重复读图;文本截断标记透传', () => {
const prefix = buildUserContextPrefix({
now: Date.now(),
attachments: [
{ name: 'shot.png', type: 'image' },
{ name: 'big.log', type: 'text', truncated: true },
],
});
expect(prefix).toContain('## User Attachments (Direct Upload)');
expect(prefix).toContain('1. [image] shot.png');
expect(prefix).toContain('do NOT call view_image');
expect(prefix).toContain('2. [text file] big.log');
expect(prefix).toContain('TRUNCATED — only the first 512KB is included');
});
it('分区以 --- 分隔且以前缀分隔符收尾(调用方可直接拼接用户内容)', () => {
const prefix = buildUserContextPrefix({
now: Date.now(),
memories: [
{
id: 'm',
type: 'episodic',
content: 'c',
source: 'user_input',
importance: 0.5,
score: 0.5,
createdAt: Date.now(),
},
],
attachments: [{ name: 'a.txt', type: 'text' }],
});
expect(prefix).toMatch(/---\s*$/);
// 三个分区恰好两个内部 --- + 收尾 1 个 ---(共 3 个独立行)
expect(prefix.match(/^---$/gm)?.length ?? 0).toBe(3);
});
});
describe('withUserContextPrefix', () => {
it('前置块与用户内容拼接(前置块自带收尾分隔符)', () => {
const prefix = buildUserContextPrefix({ now: Date.now() });
const out = withUserContextPrefix(prefix, '你好,帮我写个脚本');
expect(out.startsWith(prefix)).toBe(true);
expect(out.endsWith('你好,帮我写个脚本')).toBe(true);
});
it('空前缀原样返回(契约防御)', () => {
expect(withUserContextPrefix('', 'hello')).toBe('hello');
});
});
+18 -17
View File
@@ -58,7 +58,10 @@ export class ContextBuilder {
*
* v0.3.14: 移除 AGENTS.md 和 USERS.md 的读取,SOUL.md 仅做角色定义
*/
buildSystemPrompt(workspaceFiles?: WorkspaceFiles, workspacePath?: string): {
buildSystemPrompt(
workspaceFiles?: WorkspaceFiles,
workspacePath?: string,
): {
roleDefinition: string;
outputConstraints: string;
safetyGuidelines: string;
@@ -76,23 +79,19 @@ export class ContextBuilder {
// ===== 动态区:记忆 =====
const dynamicParts: string[] = [];
// v0.3.14: 注入当前系统日期时间(每次构建时获取最新时间)
// 用于让 AI 准确理解"今天"、"昨天"等相对时间表达
// #43 修复: 时区硬编码 Asia/Shanghai 改为使用系统本地时区,跨时区用户显示正确
const now = new Date();
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'Asia/Shanghai';
// 审查修复: 恢复 UTC 偏移显示,在时区名后附加 UTC 偏移,避免丢失时区偏移信息
const offset = -now.getTimezoneOffset() / 60;
const offsetStr = offset >= 0 ? `UTC+${offset}` : `UTC${offset}`;
const dateTimeStr = now.toLocaleString('zh-CN', {
timeZone: localTimezone,
hour12: false,
});
dynamicParts.push(`## Current Date & Time\n${dateTimeStr} (${localTimezone}, ${offsetStr})`);
// v0.7.3 P1-1 根治: 当前日期时间不再注入 system prompt —— 此前每次构建都
// 产生不同字节(秒级时间戳 + 时区),导致跨 run 的 system 前缀永不一致,
// DeepSeek 自动上下文缓存 / Anthropic 显式缓存全部 miss。现移入用户消息
// 前置块(@see user-context.ts),system 保持跨 run 字节级稳定。
// MEMORY.md 的 `> 创建时间/最后更新` 元数据行由 extractContent 剥离,
// 正文提取不受时间戳更新影响 —— 此处无需额外处理。
// 注入当前工作空间路径(动态区,路径可能切换故不放入静态区
// 注入当前工作空间路径(动态区,路径可能切换故不放入静态区
// 会话期间路径恒定,不破坏缓存)
if (workspacePath) {
dynamicParts.push(`## Current Workspace\nWorkspace root path: \`${workspacePath}\`\n\nAll relative paths in tool calls are resolved against this workspace root. Use this path when absolute paths are required (e.g., in run_command).`);
dynamicParts.push(
`## Current Workspace\nWorkspace root path: \`${workspacePath}\`\n\nAll relative paths in tool calls are resolved against this workspace root. Use this path when absolute paths are required (e.g., in run_command).`,
);
}
if (workspaceFiles?.memory) {
@@ -138,7 +137,9 @@ export class ContextBuilder {
} else {
// v0.3.18 修复: 降级时打 WARN 日志 + 设置标志,供 IPC 层读取后发 toast
this.lastUsedFallbackRole = true;
log.warn('[ContextBuilder] SOUL.md is missing or empty, falling back to default Metona identity');
log.warn(
'[ContextBuilder] SOUL.md is missing or empty, falling back to default Metona identity',
);
// 兜底身份定义(Metona 灵魂定义)
parts.push(`# Metona — 灵魂定义
> "想清楚再动手,做对比做快重要"
+140
View File
@@ -0,0 +1,140 @@
/**
* User Context Prefix — 每条用户消息的系统上下文前置块(v0.7.3 P1-1 根治)
*
* 背景(Prompt Cache 被打穿的根因):
* 此前「当前日期时间」「检索到的相关记忆」「附件提示」三类**每条消息都在变**
* 的内容被追加进 systemPrompt.dynamicReminders —— OpenAI 兼容系将其拼进首条
* system 消息、Anthropic 写入顶层 system 字段。任何一次变化都会使整个 system
* 前缀失配,DeepSeek 自动上下文缓存 / Anthropic 显式缓存全部 miss。长 system
* SOUL + 安全准则 + MEMORY.md)× 每 run 最多 20 轮迭代 × 全量重算输入 token,
* 成本与首字延迟被系统性放大。
*
* 现契约(单一事实来源):
* - system prompt 只保留跨 run 字节级稳定的内容(SOUL / 约束 / 安全准则 /
* 工作空间路径 / MEMORY.md 正文——其易变的 `> 最后更新` 元数据行本就被
* extractContent 剥离);Anthropic 侧对该稳定前缀打 cache_control 断言;
* - 易变内容(日期时间 / 记忆 / 附件提示)由本模块构建为**用户消息前置块**,
* 随当次请求注入首条 user 消息(LLM 语义等价:Claude Code 同款上下文注入位);
* - DB 持久化 / 前端展示 / 记忆固化 / 注入检测均使用**原始干净内容**,
* 前置块只存在于发给引擎的副本上。
*
* 纯函数、零副作用:可在 node vitest 下直接表测(稳定性/分组/空值收缩)。
*/
import type { SearchResult } from '../memory/manager';
/** 附件提示所需的元信息子集(与 agent-store AttachmentInfo 对齐的渲染端子集) */
export interface AttachmentHint {
name: string;
type: string;
truncated?: boolean;
}
export interface UserContextPrefixInput {
/** 当前时间戳(前置块内降精度到分钟,减少无意义抖动) */
now?: number;
/** 检索到的相关记忆(空数组时不产出记忆分区) */
memories?: SearchResult[];
/** 用户附件元信息(空数组/undefined 时不产出附件分区) */
attachments?: AttachmentHint[];
/**
* 时区标签(如 "Asia/Shanghai (UTC+8)")。
* 由调用方计算(Intl.DateTimeFormat().resolvedOptions().timeZone)——
* 本模块保持纯函数语义,不做 Electron/Intl 环境依赖。
*/
timezoneLabel?: string;
}
/** 记忆注入条目的内容截断(与原 dynamicReminders 注入口径一致) */
const MEMORY_EXCERPT_CHARS = 200;
/** 附件提示上限(与输入侧 5 个附件的硬上限对齐) */
const MAX_ATTACHMENT_HINTS = 8;
/**
* 构建用户消息上下文前置块。
*
* 输出形态(各分区以 `\n\n---\n\n` 分隔,整体以分隔符结尾,
* 调用方直接 `${prefix}${userContent}` 拼接):
* ```
* [Contextual information for this message — system-generated metadata, not part of the user's request.]
*
* ## Current Date & Time
* 2026/8/30 14:30:00 (Asia/Shanghai, UTC+8)
*
* ---
*
* ## Relevant Memories (Retrieved)
* [1] (semantic, 重要度: 0.9) ...
*
* ---
*
* ## User Attachments (Direct Upload)
* ...
* ```
*/
export function buildUserContextPrefix(input: UserContextPrefixInput): string {
const parts: string[] = [];
// ===== 分区 1:当前日期时间(降精度到分钟) =====
const now = input.now ?? Date.now();
const timezoneLabel = input.timezoneLabel ?? 'UTC';
const dateStr = new Date(now).toLocaleString('sv-SE', {
timeZone: undefined,
hour12: false,
}); // sv-SE 给出 ISO 形态 "2026-08-30 14:30:00"
parts.push(`## Current Date & Time\n${dateStr} (${timezoneLabel})`);
// ===== 分区 2:相关记忆注入(沿用原 system 注入的展示口径) =====
const memories = (input.memories ?? []).slice(0, 5);
if (memories.length > 0) {
const memorySection = memories
.map(
(m, i) =>
`[${i + 1}] (${m.type}, 重要度: ${m.importance.toFixed(1)}) ${m.content.slice(0, MEMORY_EXCERPT_CHARS)}`,
)
.join('\n');
parts.push(`## Relevant Memories (Retrieved)\n${memorySection}`);
}
// ===== 分区 3:附件提示(沿用原 system 注入的语义与文案契约) =====
const attachments = (input.attachments ?? []).slice(0, MAX_ATTACHMENT_HINTS);
if (attachments.length > 0) {
const attachmentList = attachments
.map((att, i) => {
const typeLabel =
att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
// 文本附件被上传入口截断(512KB 上限)时,明确告知 LLM 内容不完整,
// 防止模型把残缺内容当作完整文件事实(v0.7.2 A5 契约延续)
const truncatedNote =
att.truncated === true
? ' (TRUNCATED — only the first 512KB is included; the full content is NOT available)'
: '';
const note =
att.type === 'image'
? 'already provided to you via vision capability — you can SEE it directly, do NOT call view_image or any tool to read it again'
: att.type === 'text'
? `content${truncatedNote} already inlined in the user message, do NOT search in workspace or read it again`
: 'uploaded directly by user, do NOT search in workspace';
return `${i + 1}. [${typeLabel}] ${att.name}${note}`;
})
.join('\n');
parts.push(
`## User Attachments (Direct Upload)\nThe following files were uploaded directly by the user to this conversation. They are inline attachments, NOT workspace files:\n${attachmentList}\n\n**IMPORTANT**: Images listed above are already visible to you in this conversation. Do NOT call \`view_image\`, \`read_file\`, or any file tool to read them — doing so wastes a tool call and may fail (they are not workspace files).`,
);
}
const header =
"[Contextual information for this message — system-generated metadata, not part of the user's request.]";
return `${header}\n\n${parts.join('\n\n---\n\n')}\n\n---\n\n`;
}
/**
* 将前置块与用户原始内容拼装为发送给引擎的消息内容。
* 空前缀(理论上不会发生——日期分区恒存在,但契约上防御)时原样返回。
*/
export function withUserContextPrefix(prefix: string, userContent: string): string {
if (!prefix) return userContent;
return `${prefix}${userContent}`;
}
@@ -0,0 +1,103 @@
/**
* SSRF DNS Pinning 测试(v0.7.3 P2-1
*
* 锁定三个单元:
* D1 createPinnedLookup —— 只返回校验阶段锁定的 IP 集合(过滤非法 family),
* 空集合返回 ENOTFOUND(防御)。
* D2 resolveRedirectTarget —— 重定向状态识别 + 相对 Location 解析 +
* 非法/缺失 Location 返回 null。
* D3 resolvePinnedIps —— IP 直连与私网拒绝(走 ssrf-guard 单一事实来源;
* 域名解析路径由 ssrf-guard 表测覆盖,此处不重复触网)。
*/
import { describe, it, expect } from 'vitest';
import { createPinnedLookup, resolveRedirectTarget, resolvePinnedIps } from '../ssrf-dispatcher';
import type { LookupCallback } from '../ssrf-dispatcher';
describe('createPinnedLookup', () => {
it('D1: 仅返回钉死的 IP 集合(忽略 hostname),family 正确标注', async () => {
const lookup = createPinnedLookup(['93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946']);
const result = await new Promise<{ address: string; family: number }[]>((resolve, reject) => {
const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses!));
lookup('attacker.example', {}, cb);
});
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ address: '93.184.216.34', family: 4 });
expect(result[1].family).toBe(6);
});
it('D1: 非法 family(非 IPv4/IPv6 字符串)被过滤', async () => {
const lookup = createPinnedLookup(['not-an-ip']);
await expect(
new Promise((resolve, reject) => {
const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses));
lookup('h', {}, cb as never);
}),
).rejects.toMatchObject({ code: 'ENOTFOUND' });
});
it('D1: 空集合 → ENOTFOUND(防御:调用方不应构造空 pin dispatcher', async () => {
const lookup = createPinnedLookup([]);
await expect(
new Promise((resolve, reject) => {
const cb: LookupCallback = (err, addresses) => (err ? reject(err) : resolve(addresses));
lookup('h', {}, cb as never);
}),
).rejects.toMatchObject({ code: 'ENOTFOUND' });
});
});
describe('resolveRedirectTarget', () => {
const makeResponse = (status: number, location?: string) => ({
status,
headers: {
get: (name: string) => (name.toLowerCase() === 'location' ? (location ?? null) : null),
},
});
it('D2: 301/302/303/307/308 识别并解析绝对 Location', () => {
for (const status of [301, 302, 303, 307, 308]) {
expect(
resolveRedirectTarget(makeResponse(status, 'https://cdn.example.com/x'), 'https://a.test/'),
).toBe('https://cdn.example.com/x');
}
});
it('D2: 相对 Location 以当前 URL 为基解析(RFC 7231', () => {
expect(resolveRedirectTarget(makeResponse(302, '/next?a=1'), 'https://a.test/dir/page')).toBe(
'https://a.test/next?a=1',
);
});
it('D2: 非 3xx 状态 → null(终态)', () => {
expect(resolveRedirectTarget(makeResponse(200), 'https://a.test/')).toBeNull();
expect(resolveRedirectTarget(makeResponse(404), 'https://a.test/')).toBeNull();
});
it('D2: 缺失/非法 Location → null', () => {
expect(resolveRedirectTarget(makeResponse(302), 'https://a.test/')).toBeNull();
expect(resolveRedirectTarget(makeResponse(302, ''), 'https://a.test/')).toBeNull();
expect(resolveRedirectTarget(makeResponse(302, 'http://[::bad'), 'https://a.test/')).toBeNull();
});
});
describe('resolvePinnedIps', () => {
it('D3: IP 直连 URL —— 公网 IP 直接返回', async () => {
const ips = await resolvePinnedIps('https://93.184.216.34/x');
expect(ips).toEqual(['93.184.216.34']);
});
it('D3: 私有/回环 IP 直连被拒(单一事实来源 ssrf-guard', async () => {
for (const host of ['127.0.0.1', '10.0.0.5', '169.254.169.254', '192.168.1.1', '[::1]']) {
await expect(resolvePinnedIps(`http://${host}/latest`)).rejects.toThrow(/Blocked SSRF/);
}
});
it('D3: 非 http/https 协议被拒', async () => {
await expect(resolvePinnedIps('ftp://example.com')).rejects.toThrow(/not allowed/);
});
it('D3: 非法 URL 被拒', async () => {
await expect(resolvePinnedIps('not a url')).rejects.toThrow(/Invalid URL/);
});
});
@@ -9,6 +9,8 @@
import { BrowserWindow, session } from 'electron';
import log from 'electron-log';
// v0.7.3 P2-2: CORS Origin 回显(纯函数在 network-utils,可表测)
import { corsAllowOrigin, extractOriginHeader } from './network-utils';
/** Agent 浏览器专用 session partition — 与主应用 default session 完全隔离 */
const AGENT_PARTITION = 'persist:metona-agent-browser';
@@ -128,12 +130,22 @@ export class BrowserWindowManager {
// v0.3.0 修复: 使用 CORS 放行替代 webSecurity: false
// 仅对 agent session 放行 CORS,不影响主应用
// v0.7.3 P2-2 收紧: ACAO 从通配 '*' 改为回显请求 Origin —— 通配值让任意
// 第三方页面都能借该分区跨域读取;回显等价保留截图/页面自身跨域能力,
// 并附加 Vary: Origin 防止共享缓存把定向值串到其他 Origin。
const agentSession = session.fromPartition(AGENT_PARTITION);
agentSession.webRequest.onHeadersReceived((details, callback) => {
// Electron 类型在此版本的 OnHeadersReceivedListenerDetails 上不暴露
// requestHeaders —— 显式声明读取面(Origin 大小写不敏感提取)
const requestHeaders = (
details as unknown as { requestHeaders?: Record<string, string | string[] | undefined> }
).requestHeaders;
const originHeader = extractOriginHeader(requestHeaders);
callback({
responseHeaders: {
...details.responseHeaders,
'Access-Control-Allow-Origin': ['*'],
'Access-Control-Allow-Origin': corsAllowOrigin(originHeader),
Vary: [...(details.responseHeaders?.Vary ?? []), 'Origin'],
},
});
});
+17 -39
View File
@@ -25,6 +25,9 @@ import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard';
import type { SandboxManager } from '../../sandbox/sandbox';
// v0.7.3 P3-2: 子进程环境净化收敛到 utils/safe-env.ts 单源
// (与 MCP stdio 启动共用同一黑名单,历史双实现已漂移过一次)
import { buildSafeChildEnv } from '../../../utils/safe-env';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@@ -52,47 +55,22 @@ function decodeBuffer(buf: Buffer): string {
/**
* #9 修复 + 审查修复: 构建安全的子进程环境变量
*
* 审查修复: 原白名单方案遗漏了 GIT_* / PYTHONPATH / HTTP_PROXY 等常用变量,导致子进程功能破坏。
* 改为黑名单方案:剔除包含敏感后缀的变量,保留其余。
* v0.7.3 P3-2: 实现收敛到 utils/safe-env.tsbuildSafeChildEnv)——
* 与 MCP stdio 启动共用同一黑名单,本文件仅保留 run_command 的运行时差异注入
* (Windows 中文编码变量)。黑名单方案的设计原因见 safe-env.ts 模块注释:
* 白名单方案会遗漏 GIT_* / PYTHONPATH / HTTP_PROXY 等常用变量导致子进程功能破坏。
*/
function buildSafeCommandEnv(isWindows: boolean): Record<string, string> {
// 敏感变量后缀黑名单
const SENSITIVE_SUFFIXES = [
'_API_KEY',
'_TOKEN',
'_SECRET',
'_PASSWORD',
'_PASSWD',
'_CREDENTIAL',
'_CREDENTIALS',
'_PRIVATE_KEY',
];
// 敏感变量名黑名单(精确匹配)
const SENSITIVE_KEYS = new Set([
'DEEPSEEK_API_KEY',
'AGNES_API_KEY',
'MIMO_API_KEY',
'GITEA_PASSWORD',
'DATABASE_PASSWORD',
]);
const env: Record<string, string> = {};
for (const [key, val] of Object.entries(process.env)) {
if (!val) continue;
if (SENSITIVE_KEYS.has(key)) continue;
if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue;
env[key] = val;
}
// 添加必要的运行时变量
env.NODE_ENV = 'production';
if (isWindows) {
env.PYTHONIOENCODING = 'utf-8';
env.LANG = 'zh_CN.UTF-8';
env.LC_ALL = 'zh_CN.UTF-8';
}
return env;
return buildSafeChildEnv({
runtime: isWindows
? {
NODE_ENV: 'production',
PYTHONIOENCODING: 'utf-8',
LANG: 'zh_CN.UTF-8',
LC_ALL: 'zh_CN.UTF-8',
}
: { NODE_ENV: 'production' },
});
}
/**
+21 -14
View File
@@ -14,7 +14,11 @@ import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
// v0.6.4 P2-2: SSRF 校验收敛到共享模块 ssrf-guard.ts —— 原实现是本文件私有逻辑,
// web_fetch 无校验造成工具层最大的安全不对称。单源后所有网络工具行为一致。
// v0.7.3 P2-1 根治: 请求层升级为 ssrfPinnedFetch —— 校验通过的 IP 集合 pin 到
// 连接层(undici connect.lookup),校验与连接共用同一批 IPDNS rebinding
// 窗口(M7 已知限制)就此关闭;代理激活时自动退化为仅入口校验(见模块注释)。
import { validateSSRF } from './ssrf-guard';
import { ssrfPinnedFetch } from './ssrf-dispatcher';
const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const;
const MAX_BODY_BYTES = 50 * 1024; // 50KB
@@ -52,7 +56,8 @@ const MAX_BODY_BYTES = 50 * 1024; // 50KB
export class HttpRequestTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'http_request',
description: 'Send an HTTP/REST API request. Supports GET/POST/PUT/PATCH/DELETE/HEAD methods with custom headers and body. Response body is truncated to 50KB.',
description:
'Send an HTTP/REST API request. Supports GET/POST/PUT/PATCH/DELETE/HEAD methods with custom headers and body. Response body is truncated to 50KB.',
parameters: {
type: 'object',
properties: {
@@ -64,7 +69,10 @@ export class HttpRequestTool implements IMetonaTool {
},
headers: { type: 'object', description: 'Request headers as key-value pairs' },
body: { type: 'string', description: 'Request body (string)' },
timeout: { type: 'number', description: 'Timeout in milliseconds (default 30000, max 60000)' },
timeout: {
type: 'number',
description: 'Timeout in milliseconds (default 30000, max 60000)',
},
},
required: ['url'],
},
@@ -102,15 +110,12 @@ export class HttpRequestTool implements IMetonaTool {
};
}
// 超时控制
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
// 超时控制由 ssrfPinnedFetch 内部管理(超时 → ETIMEDOUT;
// 工具执行层的 abort signal 经 context 传入 registry 兜底)
{
const fetchOptions: RequestInit = {
method,
headers,
signal: controller.signal,
// #10 修复: 禁用自动重定向跟随 — 防止重定向到内网地址绕过 SSRF 校验
// 重定向后的 URL 由用户自行处理(响应中会包含 Location 头)
redirect: 'manual',
@@ -120,7 +125,9 @@ export class HttpRequestTool implements IMetonaTool {
fetchOptions.body = body;
}
const response = await fetch(url, fetchOptions);
// v0.7.3 P2-1: pinned fetch —— 校验通过的 IP pin 到连接层,
// 关闭校验-连接之间的 DNS rebinding 窗口
const response = await ssrfPinnedFetch(url, fetchOptions, timeout);
const text = await response.text();
// 截断到 50KB
@@ -144,14 +151,14 @@ export class HttpRequestTool implements IMetonaTool {
body: safeBody,
truncated,
ok: response.ok,
success: true, // v0.3.1 修复 WARN-4: 成功路径添加 success 字段
success: true, // v0.3.1 修复 WARN-4: 成功路径添加 success 字段
};
} finally {
clearTimeout(timer);
}
} catch (error) {
// 区分超时AbortError)与其他网络错误
if (error instanceof Error && error.name === 'AbortError') {
// 区分超时与其他网络错误:AbortError(外部中断)与
// ETIMEDOUTssrfPinnedFetch 超时转译,v0.7.3 P2-1)均归为超时语义
const err = error as Error & { code?: string };
if (err?.name === 'AbortError' || err?.code === 'ETIMEDOUT') {
return { error: 'Request timeout', success: false };
}
const errMsg = error instanceof Error ? error.message : String(error);
+116 -48
View File
@@ -34,7 +34,8 @@ export const UA_POOL = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0',
];
export const MOBILE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
export const MOBILE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
export const ACCEPT_LANGUAGE_POOL = [
'zh-CN,zh;q=0.9,en;q=0.8',
@@ -54,21 +55,25 @@ export function buildAntiCrawlHeaders(
const userAgent = mobileUA ? MOBILE_UA : UA_POOL[uaIdx];
let origin = '';
try { origin = new URL(url).origin; } catch { /* ignore */ }
try {
origin = new URL(url).origin;
} catch {
/* ignore */
}
return {
'User-Agent': userAgent,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': ACCEPT_LANGUAGE_POOL[langIdx],
'Accept-Encoding': 'gzip, deflate, br',
'Cache-Control': 'no-cache',
'DNT': '1',
'Referer': origin || '',
DNT: '1',
Referer: origin || '',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Pragma': 'no-cache',
Pragma: 'no-cache',
};
}
@@ -107,7 +112,15 @@ export function normalizeUrl(url: string): string {
const u = new URL(url);
// 去除追踪参数
const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid'];
const trackingParams = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'gclid',
'fbclid',
];
for (const p of trackingParams) u.searchParams.delete(p);
// H-6 增强: 排序查询参数(确保参数顺序一致,便于去重)
@@ -125,7 +138,7 @@ export function normalizeUrl(url: string): string {
(u.protocol === 'https:' && u.port === '443') ||
(u.protocol === 'ws:' && u.port === '80') ||
(u.protocol === 'wss:' && u.port === '443');
const portSuffix = isDefaultPort ? '' : (u.port ? `:${u.port}` : '');
const portSuffix = isDefaultPort ? '' : u.port ? `:${u.port}` : '';
// 强制小写 host
return `${u.protocol}//${u.hostname.toLowerCase()}${portSuffix}${path}${u.search}${u.hash}`;
@@ -158,47 +171,68 @@ export function isInterceptedPage(html: string): boolean {
// ===== HTML → 纯文本转换 =====
const HTML_ENTITY_MAP: Record<string, string> = {
'&nbsp;': ' ', '&lt;': '<', '&gt;': '>', '&amp;': '&', '&quot;': '"',
'&apos;': "'", '&hellip;': '…', '&mdash;': '—', '&ndash;': '',
'&laquo;': '«', '&raquo;': '»', '&times;': '×', '&divide;': ',
'&copy;': '©', '&reg;': '®', '&trade;': '™', '&euro;': '€',
'&pound;': '£', '&yen;': '¥', '&cent;': '¢', '&deg;': ',
'&nbsp;': ' ',
'&lt;': '<',
'&gt;': '>',
'&amp;': '&',
'&quot;': '"',
'&apos;': "'",
'&hellip;': '…',
'&mdash;': '—',
'&ndash;': '',
'&laquo;': '«',
'&raquo;': '»',
'&times;': '×',
'&divide;': '÷',
'&copy;': '©',
'&reg;': '®',
'&trade;': '™',
'&euro;': '€',
'&pound;': '£',
'&yen;': '¥',
'&cent;': '¢',
'&deg;': '°',
};
export function htmlToText(html: string): string {
return html
// 移除噪声标签及内容
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<noscript[^>]*>[\s\S]*?<\/noscript>/gi, '')
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
.replace(/<aside[^>]*>[\s\S]*?<\/aside>/gi, '')
.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, '')
.replace(/<svg[^>]*>[\s\S]*?<\/svg>/gi, '')
// 移除 HTML 注释
.replace(/<!--[\s\S]*?-->/g, '')
// 块级标签转换行
.replace(/<\/?(p|div|h[1-6]|li|tr|blockquote|section|article|pre|br|hr)[^>]*>/gi, '\n')
// 表格单元格转制表符
.replace(/<\/?(td|th)[^>]*>/gi, '\t')
// 移除剩余标签
.replace(/<[^>]+>/g, '')
// 解码 HTML 实体
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
.replace(/&[a-z]+;/gi, (m) => HTML_ENTITY_MAP[m.toLowerCase()] ?? m)
// 清理空白
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]+/g, ' ')
.replace(/^[ \t]+/gm, '')
.trim();
return (
html
// 移除噪声标签及内容
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<noscript[^>]*>[\s\S]*?<\/noscript>/gi, '')
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
.replace(/<aside[^>]*>[\s\S]*?<\/aside>/gi, '')
.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, '')
.replace(/<svg[^>]*>[\s\S]*?<\/svg>/gi, '')
// 移除 HTML 注释
.replace(/<!--[\s\S]*?-->/g, '')
// 块级标签转换行
.replace(/<\/?(p|div|h[1-6]|li|tr|blockquote|section|article|pre|br|hr)[^>]*>/gi, '\n')
// 表格单元格转制表符
.replace(/<\/?(td|th)[^>]*>/gi, '\t')
// 移除剩余标签
.replace(/<[^>]+>/g, '')
// 解码 HTML 实体
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
.replace(/&[a-z]+;/gi, (m) => HTML_ENTITY_MAP[m.toLowerCase()] ?? m)
// 清理空白
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]+/g, ' ')
.replace(/^[ \t]+/gm, '')
.trim()
);
}
// ===== 流式读取(大文件保护,10MB 上限) =====
export async function readBodyWithLimit(response: Response, maxBytes = 10 * 1024 * 1024): Promise<string> {
export async function readBodyWithLimit(
response: Response,
maxBytes = 10 * 1024 * 1024,
): Promise<string> {
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength) > maxBytes) {
throw new Error(`Response too large: ${contentLength} bytes (limit: ${maxBytes})`);
@@ -249,6 +283,40 @@ export function logTool(toolName: string, message: string): void {
log.info(`[Tool:${toolName}] ${message}`);
}
// ===== v0.7.3 P2-2: Agent 浏览器 CORS Origin 回显 =====
/**
* 计算响应应携带的 Access-Control-Allow-Origin 值(纯函数,表测锁定)。
*
* 背景:Agent 浏览器专用 session 此前对所有响应注入 ACAO:* —— 任意被 Agent
* 打开的第三方页面都能借该分区无差别跨域读取。改为回显请求 Origin(等价能力:
* 页面对自己的 Origin 仍可跨域读,如截图所需),无 Origin(同源导航/非浏览器
* 客户端)回退 '*' 保持既有能力不回退。
*
* @param requestOrigin 请求头 Origin(可能为 undefined / 任意字符串)
* @returns 应写入响应的 ACAO 值(单元素数组,供 Electron responseHeaders 使用)
*/
export function corsAllowOrigin(requestOrigin: string | undefined | null): string[] {
const origin = requestOrigin?.trim();
if (origin && /^https?:\/\//i.test(origin)) {
return [origin];
}
return ['*'];
}
/** 从请求头集合中大小写不敏感地提取 Origin 值 */
export function extractOriginHeader(
requestHeaders: Record<string, string | string[] | undefined> | undefined,
): string | undefined {
if (!requestHeaders) return undefined;
for (const [key, value] of Object.entries(requestHeaders)) {
if (key.toLowerCase() === 'origin') {
return Array.isArray(value) ? value[0] : value;
}
}
return undefined;
}
// ===== v0.6.4 P4-4: HTML → Markdown 转换(web_fetch extract_mode='markdown' =====
//
// v0.6.4 收尾:私有 npm 凭据解锁后,按开发规范第一铁律把第一轮的临时自写实现
@@ -285,11 +353,9 @@ const LIST_LINE = /^\s*(?:- |\d+\. )/;
* 2. 仅当"空行两侧都是同一列表的条目行"时移除该空行(绝不吞条目、不影响段落间距)。
*/
function collapseListGaps(markdown: string): string {
const lines = markdown.split('\n').map((line) =>
line
.replace(/^(\s*)- {2,}/, '$1- ')
.replace(/^(\s*\d+\.)\s{2,}/, '$1 '),
);
const lines = markdown
.split('\n')
.map((line) => line.replace(/^(\s*)- {2,}/, '$1- ').replace(/^(\s*\d+\.)\s{2,}/, '$1 '));
const isListItem = (l: string | undefined): boolean => (l ?? '').length > 0 && LIST_LINE.test(l!);
@@ -321,5 +387,7 @@ export function htmlToMarkdown(html: string): string {
return '';
}
return collapseListGaps(md).replace(/\n{3,}/g, '\n\n').trim();
return collapseListGaps(md)
.replace(/\n{3,}/g, '\n\n')
.trim();
}
@@ -0,0 +1,170 @@
/**
* SSRF DNS Pinning Dispatcherv0.7.3 P2-1
*
* 关闭 M7 审查确认的 DNS rebinding 窗口:此前 validateSSRF 在校验阶段解析一次
* DNSfetch 实际连接时 undici 再次解析 —— 两次解析之间攻击者可切换 DNS 记录
* (TTL=0)把连接导向内网。原注释断言"Node fetch 下无法彻底关闭",该结论只对
* 全局 fetch 成立;主进程已依赖 undicinetwork-proxy 的 setGlobalDispatcher),
* undici 的 Agent 支持 connect.lookup 自定义 —— 校验通过的 IP 集合可精确 pin 到
* 连接层,TLS SNI/证书校验仍基于原始域名(undici 将 servername 保持为 hostname)。
*
* 契约:
* - resolvePublicAddressesssrf-guard)是校验与地址解析的唯一事实来源,
* 本模块 pin 的就是它返回的那批 IP —— 校验与连接同源,无双解析窗口;
* - 代理激活(network-proxy.isProxyActive())时 pinning 不可实现(DNS 在代理端
* 解析)且 per-request dispatcher 会旁路用户代理 —— 退化为"仅入口校验"
* 走全局 dispatcher(保持既有语义与代理兼容);
* - fetchWithTimeoutPinned 合并外部 abort signal 与超时控制,语义对齐
* BaseAdapter.fetchWithTimeout(超时 → ETIMEDOUT 可重试;外部中断原样抛出);
* - 每次 pinned 请求构造一次性 Agent 并在 finally 中 close(连接池即用即毁,
* 防止把"上一请求的 pin 集合"泄漏给后续请求)。
*/
import { Agent, fetch as undiciFetch } from 'undici';
import { isIP } from 'node:net';
import { resolvePublicAddresses } from './ssrf-guard';
import { fetchWithTimeout } from './network-utils';
import { isProxyActive } from '../../../utils/network-proxy';
import log from 'electron-log';
/** 标准 dns.lookup 回调签名(undici connect.lookup 消费) */
export type LookupCallback = (
err: NodeJS.ErrnoException | null,
addresses?: Array<{ address: string; family: number }>,
) => void;
/** undici connect.lookup 的函数签名形态 */
export type PinnedLookup = (hostname: string, options: unknown, callback: LookupCallback) => void;
/**
* 构造"钉死 IP 集合"的 lookup 函数:无论传入什么 hostname,都只返回校验阶段
* 锁定的公网地址(过滤非法 family)。集合为空时返回 ENOTFOUND(防御性——
* 调用方在集合为空时不应构造 dispatcher)。
*/
export function createPinnedLookup(allowedIps: string[]): PinnedLookup {
return (_hostname, _options, callback) => {
process.nextTick(() => {
const addresses = allowedIps
.map((ip) => ({ address: ip, family: isIP(ip) }))
.filter((a): a is { address: string; family: number } => a.family === 4 || a.family === 6);
if (addresses.length === 0) {
const err: NodeJS.ErrnoException = new Error('pinned lookup: no allowed addresses');
err.code = 'ENOTFOUND';
callback(err, undefined);
return;
}
callback(null, addresses);
});
};
}
/**
* 校验 URL 并返回 pinning 用的公网 IP 集合。
* 校验失败原样抛出(调用方按 SSRF 阻断处理)。
*/
export async function resolvePinnedIps(url: string): Promise<string[]> {
return resolvePublicAddresses(url);
}
/**
* 带 SSRF pinning 的 fetchhttp_request / web_fetch Phase1 / 可达性预检共用)。
*
* 行为:
* 1. 代理激活 → 退化为普通 fetchWithTimeout(仅入口校验语义,见模块注释);
* 2. 否则 → 解析并校验公网 IP → 一次性 undici Agentpinned lookup)发起请求;
* 3. 超时/外部中断语义与 fetchWithTimeout 对齐;
* 4. 返回 Response 与全局 fetch 兼容(status/ok/headers/text/url/body)。
*
* @param url 目标 URL(调用方已保证 http/https;本函数再做一次全量 SSRF 校验)
* @param init RequestInitredirect 等由调用方决定)
* @param timeoutMs 请求超时
* @param externalSignal 外部 abort 信号(引擎中断透传,可选)
*/
export async function ssrfPinnedFetch(
url: string,
init: RequestInit,
timeoutMs: number,
externalSignal?: AbortSignal,
): Promise<Response> {
const ips = await resolvePublicAddresses(url);
// 代理激活:DNS 在代理端解析,pinning 不可实现;走全局 dispatcher 保持代理语义
if (isProxyActive()) {
return fetchWithTimeout(url, init, timeoutMs);
}
// 外部信号已中止 → 直接抛 AbortError(对齐 fetchWithTimeout 行为)
if (externalSignal?.aborted) {
const err = new Error('Aborted');
err.name = 'AbortError';
throw err;
}
const controller = new AbortController();
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
const onExternalAbort = () => controller.abort();
if (externalSignal) {
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
}
// 一次性 pinned Agentconnect 超时对齐 network-proxy 的 15s 连接上限)
const dispatcher = new Agent({
connect: { timeout: 15_000, lookup: createPinnedLookup(ips) as never },
});
try {
const response = await undiciFetch(url, {
...(init as Record<string, unknown>),
signal: controller.signal,
dispatcher,
} as never);
return response as unknown as Response;
} catch (err) {
const externalAborted = externalSignal?.aborted === true;
if (timedOut && !externalAborted) {
const timeoutError = new Error(
`Request timed out after ${timeoutMs}ms (url=${String(url).slice(0, 120)})`,
);
(timeoutError as Error & { code: string }).code = 'ETIMEDOUT';
throw timeoutError;
}
throw err;
} finally {
clearTimeout(timer);
if (externalSignal) {
externalSignal.removeEventListener('abort', onExternalAbort);
}
// 一次性 dispatcher 用后即毁(连接池不跨请求复用,防止 pin 集合泄漏)
void dispatcher.close().catch((closeErr) => {
log.debug(`[SSRFDispatcher] dispatcher close failed: ${(closeErr as Error).message}`);
});
}
}
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
/**
* 解析重定向目标(纯函数,表测锁定)。
*
* @returns 下一跳绝对 URL;非重定向状态/缺失/非法 Location 返回 null
* (表示当前响应即终态或无法跟随,由调用方按既有语义处理)。
* 相对 Location 以 currentUrl 为基解析(RFC 7231)。
*/
export function resolveRedirectTarget(
response: { status: number; headers: { get(name: string): string | null } },
currentUrl: string,
): string | null {
if (!REDIRECT_STATUS.has(response.status)) return null;
const location = response.headers.get('location');
if (!location) return null;
try {
return new URL(location, currentUrl).toString();
} catch {
return null;
}
}
+35 -12
View File
@@ -30,21 +30,21 @@ export function isPrivateIP(ip: string): boolean {
// IPv4 直接检测
if (isIP(ip) === 4) {
const parts = ip.split('.').map(Number);
if (parts[0] === 127) return true; // 回环
if (parts[0] === 10) return true; // 内网
if (parts[0] === 127) return true; // 回环
if (parts[0] === 10) return true; // 内网
if (parts[0] === 192 && parts[1] === 168) return true; // 内网
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 内网
if (parts[0] === 169 && parts[1] === 254) return true; // 链路本地(含云元数据)
if (parts[0] === 0) return true; // 0.0.0.0/8
if (parts[0] >= 224) return true; // 组播 + 保留
if (parts[0] === 169 && parts[1] === 254) return true; // 链路本地(含云元数据)
if (parts[0] === 0) return true; // 0.0.0.0/8
if (parts[0] >= 224) return true; // 组播 + 保留
return false;
}
// IPv6 检测
if (isIP(ip) === 6) {
const lower = ip.toLowerCase();
if (lower === '::1') return true; // 回环
if (lower.startsWith('fe80:')) return true; // 链路本地
if (lower === '::1') return true; // 回环
if (lower.startsWith('fe80:')) return true; // 链路本地
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // 唯一本地
// ::ffff: 映射的 IPv4 — 提取 IPv4 部分递归检测
const v4MappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
@@ -57,16 +57,22 @@ export function isPrivateIP(ip: string): boolean {
}
/**
* SSRF 校验 — 解析 URL 域名并校验 IP
* SSRF 校验 + 公网地址解析(单一事实来源)
*
* v0.7.3 P2-1 重构:resolvePublicAddresses 承载全部校验逻辑并返回解析出的
* 公网 IP 集合;validateSSRF 成为它的"只要不抛错"薄包装。这样 pinning 层
* ssrf-dispatcher)能拿到与校验完全同一批 IP,避免"校验一次解析、连接
* 再解析一次"的双解析不一致。
*
* 1. 协议白名单:仅允许 http/https
* 2. hostname 为 IP 时直接检测
* 3. 域名 — DNS 解析后检测所有 IP;任意一个 IP 为私有即拒绝
* (防止 DNS rebinding 中只校验第一个 IP 的绕过)
*
* @returns 校验通过的全部公网 IP(供 DNS pinning 使用)
* @throws 如果 URL 指向私有/内网/回环地址或协议不被允许
*/
export async function validateSSRF(url: string): Promise<void> {
export async function resolvePublicAddresses(url: string): Promise<string[]> {
let parsed: URL;
try {
parsed = new URL(url);
@@ -86,7 +92,7 @@ export async function validateSSRF(url: string): Promise<void> {
if (isPrivateIP(hostname)) {
throw new Error(`Blocked SSRF: ${hostname} is a private/loopback address`);
}
return;
return [hostname];
}
// 域名 — DNS 解析后检测所有 IP
@@ -94,22 +100,39 @@ export async function validateSSRF(url: string): Promise<void> {
try {
addresses = await lookup(hostname, { all: true });
} catch (err) {
throw new Error(`Blocked SSRF: DNS resolution failed for ${hostname}: ${(err as Error).message}`);
throw new Error(
`Blocked SSRF: DNS resolution failed for ${hostname}: ${(err as Error).message}`,
);
}
if (addresses.length === 0) {
throw new Error(`Blocked SSRF: no DNS records for ${hostname}`);
}
const publicIps: string[] = [];
for (const { address } of addresses) {
if (isPrivateIP(address)) {
throw new Error(`Blocked SSRF: ${hostname} resolves to private IP ${address}`);
}
publicIps.push(address);
}
return publicIps;
}
/**
* SSRF 校验 — 解析 URL 域名并校验 IPv0.7.3 起为 resolvePublicAddresses 的
* "仅校验不取值"包装,校验逻辑单一来源在后者)
*
* @throws 如果 URL 指向私有/内网/回环地址或协议不被允许
*/
export async function validateSSRF(url: string): Promise<void> {
await resolvePublicAddresses(url);
}
/** validateSSRF 的不抛错包装:返回结构化结果供工具 execute 直接 return */
export async function safeValidateSSRF(url: string): Promise<{ ok: true } | { ok: false; error: string }> {
export async function safeValidateSSRF(
url: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
await validateSSRF(url);
return { ok: true };
+50 -14
View File
@@ -18,7 +18,6 @@ import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import {
fetchCache,
buildAntiCrawlHeaders,
fetchWithTimeout,
htmlToText,
isInterceptedPage,
readBodyWithLimit,
@@ -30,14 +29,21 @@ import { getBrowserManager } from './browser';
// v0.6.4 P2-2 根治安全不对称:web_fetch 此前完全没有 SSRF 校验(仅协议检查)且
// requiresPermission:false —— LLM 可直接抓取 http://127.0.0.1:<port>、
// http://169.254.169.254/latest/meta-data 等内网/云元数据地址,浏览器回退通道
// 同样可达内网。现复用共享 ssrf-guard 模块(与 http_request 同源同行为)
// 入口校验 + HTTP 重定向终态 URL 复检(堵 redirect:'follow' 绕道内网的口子)。
// 同样可达内网。现复用共享 ssrf-guard 模块(与 http_request 同源同行为)
// v0.7.3 P2-1 根治: 抓取层升级为 ssrfPinnedFetch —— 校验通过的 IP 集合 pin 到
// 连接层(undici connect.lookup),关闭校验-连接之间的 DNS rebinding 窗口;
// 重定向改为逐跳手动跟随,每一跳都先校验后连接(原 redirect:'follow' 下
// 中间跳转在"终态复检"之前已真实发出,可触达内网)。
import { validateSSRF } from './ssrf-guard';
import { resolveRedirectTarget, ssrfPinnedFetch } from './ssrf-dispatcher';
// ===== 跳过重试的状态码 =====
const SKIP_RETRY_STATUS = new Set([403, 429, 502, 503]);
/** v0.7.3 P2-1: 单次抓取允许的最大重定向跳数(每跳均经校验 + pinning) */
const MAX_REDIRECT_HOPS = 5;
// ===== WebFetchTool =====
export class WebFetchTool implements IMetonaTool {
@@ -188,25 +194,55 @@ export class WebFetchTool implements IMetonaTool {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const headers = buildAntiCrawlHeaders(url, attempt, mobileUA);
const response = await fetchWithTimeout(url, { headers, redirect: 'follow' }, 20_000);
// v0.6.4 P2-2: 重定向终态复检 —— redirect:'follow' 下 fetch 可能跟随跳转
// 到与入口校验不同的目标;SSRF 校验 initial URL 后再对 response.url(终态)
// 复检,堵住"外网跳内网"绕道。终态指向私有地址时按拦截处理转入浏览器通道
// 也会被浏览器侧域名校验拒绝。
if (response.url && response.url !== url) {
try {
await validateSSRF(response.url);
} catch (ssrfErr) {
// ===== v0.7.3 P2-1 根治: 手动逐跳重定向 + 每跳 SSRF 校验 + DNS pinning =====
// 原 redirect:'follow' 下 undici 在内核自动跟跳:跳转目标仅在"终态复检"
// 时被校验,中间跳转的请求已经真实发出(可触达内网/元数据地址)。
// 现改为逐跳手动跟随:每跳由 ssrfPinnedFetch 发出(校验通过 IP pin 到
// 连接层),Location 目标显式校验通过后才允许下一跳;私有地址/非法
// 协议目标按 blocked 语义立即终止(禁止重试与浏览器回退)。
let currentUrl = url;
let response: Response | null = null;
let redirectBlocked: string | null = null;
for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop++) {
response = await ssrfPinnedFetch(currentUrl, { headers, redirect: 'manual' }, 20_000);
const next = resolveRedirectTarget(response, currentUrl);
if (next === null) break; // 非重定向(或无/非法 Location)—— 当前响应即终态
if (hop === MAX_REDIRECT_HOPS) {
return {
success: false,
html: '',
text: '',
intercepted: false,
blocked: true,
reason: `Redirect target blocked by SSRF guard: ${(ssrfErr as Error).message}`,
reason: `Too many redirects (>${MAX_REDIRECT_HOPS})`,
};
}
// 下一跳目标显式校验(ssrfPinnedFetch 内部还会再次校验+pinning
// 这里提前拦截以保证 blocked 语义:不重试、不进浏览器回退)
try {
await validateSSRF(next);
} catch (ssrfErr) {
redirectBlocked = `Redirect target blocked by SSRF guard: ${(ssrfErr as Error).message}`;
break;
}
currentUrl = next;
response = null; // 丢弃中间跳转响应,下一跳重新抓取
}
if (redirectBlocked) {
return {
success: false,
html: '',
text: '',
intercepted: false,
blocked: true,
reason: redirectBlocked,
};
}
if (!response) {
// 防御性:循环正常结束必然携带终态响应
return { success: false, html: '', text: '', intercepted: false, reason: 'No response' };
}
// 跳过重试的状态码 → 直接进入浏览器回退
+14 -2
View File
@@ -26,6 +26,9 @@ import {
buildSearXNGAuthHeaders,
logTool,
} from './network-utils';
// v0.7.3 P2-1: 可达性预检经 SSRF 校验 + DNS pinning(结果 URL 是不可信外部输入)
import { safeValidateSSRF } from './ssrf-guard';
import { ssrfPinnedFetch } from './ssrf-dispatcher';
import type { WebFetchTool } from './web-fetch';
// ===== 类型定义 =====
@@ -330,6 +333,10 @@ function parse360Regex(html: string): SearchResult[] {
}
// ===== 可达性预检 =====
// v0.7.3 P2-1: 可达性预检的 URL 来自不可信的搜索结果 —— HEAD 探测同样不得
// 触达内网/元数据地址。校验失败的 URL 直接标记不可达(不发起任何请求);
// 探测经 ssrfPinnedFetchDNS pinning),重定向不自动跟随(3xx 即视为可达 ——
// 链接活性已证明,且跟跳目标不再绕过校验)。
async function checkReachability(urls: string[], concurrency = 5): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>();
@@ -337,8 +344,13 @@ async function checkReachability(urls: string[], concurrency = 5): Promise<Map<s
const batch = urls.slice(i, i + concurrency);
const checks = batch.map(async (url) => {
try {
const resp = await fetchWithTimeout(url, { method: 'HEAD', redirect: 'follow' }, 3_000);
result.set(url, resp.ok);
const ssrf = await safeValidateSSRF(url);
if (!ssrf.ok) {
result.set(url, false);
return;
}
const resp = await ssrfPinnedFetch(url, { method: 'HEAD', redirect: 'manual' }, 3_000);
result.set(url, resp.ok || (resp.status >= 300 && resp.status < 400));
} catch {
result.set(url, false);
}
+7
View File
@@ -30,6 +30,13 @@ export interface MetonaModelInfo {
supportsToolCalling?: boolean;
/** 是否支持 Thinking / Reasoning */
supportsThinking?: boolean;
/**
* 是否支持视觉(图片输入)— v0.7.3 P1-4。
* undefined 表示未知(调用方保守放行,保持可用性);
* Ollama /api/show capabilities 探测成功时为真实布尔值,
* 供前端上传入口拒绝不支持图片的本地语言模型。
*/
supportsVision?: boolean;
/** 模型描述 */
description?: string;
}