feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m25s
CI / 全量测试 (Electron ABI, experimental) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 10m3s

工程化(从零到一):
- 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证
- 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁)
- 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync
- 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies

安全加固:
- PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额)
- ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道)
- Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面
- web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级)

缺陷修复(测试驱动发现):
- mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试
- 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢
- 百度复合类名重复收录:class="result c-container" 被双重匹配

测试补齐(113 → 194 用例):
- 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器
- 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批

体验升级:
- OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡)
- SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护)
- MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销)
- MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
This commit is contained in:
2026-08-21 13:58:48 +08:00
parent 2230bcec3f
commit 49c9b25538
41 changed files with 6254 additions and 2608 deletions
@@ -0,0 +1,245 @@
/**
* BaseAdapter 单元测试(v0.4.1 测试补齐)
* 覆盖:错误映射(mapError)、HTTP 错误识别(throwHttpError)、
* ContentFilterError、上下文窗口读取、fetchWithTimeout 超时与清理
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { BaseAdapter, ContentFilterError } from '../base-adapter';
import { MetonaErrorCode, MetonaStreamEventType } from '../../types';
import type {
IMetonaProviderAdapter,
AdapterConfig,
MetonaRequest,
MetonaResponse,
MetonaStreamEvent,
} from '../../types';
/** 测试用具体 Adapter 实现(暴露 protected 方法供测试) */
class TestAdapter extends BaseAdapter {
readonly providerId = 'test';
readonly supportedModels = ['test-model-a', 'test-model-b'];
readonly supportsToolCalling = true;
readonly supportsThinking = false;
async send(_request: MetonaRequest): Promise<MetonaResponse> {
throw new Error('not implemented');
}
async *sendStream(_request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
// 空实现
}
/** 测试辅助: 暴露 protected mapError */
mapErrorPublic(error: unknown) {
return this.mapError(error);
}
/** 测试辅助: 暴露 protected throwHttpError */
async throwHttpErrorPublic(response: Response, context: string) {
return this.throwHttpError(response, context);
}
/** 测试辅助: 暴露 protected fetchWithTimeout */
async fetchWithTimeoutPublic(url: string, init: RequestInit, timeoutMs: number) {
return this.fetchWithTimeout(url, init, timeoutMs);
}
}
function makeAdapter(config: Partial<AdapterConfig> = {}): TestAdapter {
return new TestAdapter({
provider: 'test',
baseURL: 'https://api.test.com',
apiKey: 'sk-test',
defaultModel: 'test-model-a',
...config,
});
}
describe('BaseAdapter — mapError 错误映射', () => {
it('timeout 消息映射为 NETWORK_TIMEOUT 且可重试', () => {
const adapter = makeAdapter();
const err = adapter.mapErrorPublic(new Error('Request timeout after 30s'));
expect(err.code).toBe(MetonaErrorCode.NETWORK_TIMEOUT);
expect(err.retryable).toBe(true);
expect(err.provider).toBe('test');
});
it('ECONNREFUSED 映射为 NETWORK_ERROR 且可重试', () => {
const adapter = makeAdapter();
const err = adapter.mapErrorPublic(new Error('fetch failed: ECONNREFUSED 127.0.0.1:11434'));
expect(err.code).toBe(MetonaErrorCode.NETWORK_ERROR);
expect(err.retryable).toBe(true);
});
it('HTTP 401 优先按 status code 映射为 AUTH_INVALID 且不可重试', () => {
const adapter = makeAdapter();
const e = new Error('API error: 401 Unauthorized');
(e as Error & { status: number }).status = 401;
const err = adapter.mapErrorPublic(e);
expect(err.code).toBe(MetonaErrorCode.AUTH_INVALID);
expect(err.retryable).toBe(false);
});
it('HTTP 429 映射为 RATE_LIMITED 且可重试', () => {
const adapter = makeAdapter();
const e = new Error('429 Too Many Requests');
(e as Error & { status: number }).status = 429;
const err = adapter.mapErrorPublic(e);
expect(err.code).toBe(MetonaErrorCode.RATE_LIMITED);
expect(err.retryable).toBe(true);
expect(err.retryAfterMs).toBe(5000);
});
it('ContentFilterError 优先映射为 CONTENT_FILTERED', () => {
const adapter = makeAdapter();
const cf = new ContentFilterError('high risk content', 'MiMo');
const err = adapter.mapErrorPublic(cf);
expect(err.code).toBe(MetonaErrorCode.CONTENT_FILTERED);
expect(err.retryable).toBe(false);
});
it('普通 Error 映射为 UNKNOWN 且不可重试', () => {
const adapter = makeAdapter();
const err = adapter.mapErrorPublic(new Error('whatever'));
expect(err.code).toBe(MetonaErrorCode.UNKNOWN);
expect(err.retryable).toBe(false);
});
});
describe('BaseAdapter — throwHttpError', () => {
function makeResponse(status: number, body: string): Response {
return new Response(body, { status, statusText: 'Status' });
}
it('content_filter 错误体抛出 ContentFilterError(含 status', async () => {
const adapter = makeAdapter();
const body = JSON.stringify({ error: { code: 'content_filter', message: 'high risk' } });
await expect(
adapter.throwHttpErrorPublic(makeResponse(400, body), 'MiMo'),
).rejects.toBeInstanceOf(ContentFilterError);
try {
await adapter.throwHttpErrorPublic(makeResponse(400, body), 'MiMo');
} catch (e) {
expect((e as ContentFilterError & { status: number }).status).toBe(400);
expect((e as ContentFilterError).message).toContain('安全审核拦截');
}
});
it('普通错误体抛出带 status 属性的 Error(供 isRetryableError 判断)', async () => {
const adapter = makeAdapter();
await expect(
adapter.throwHttpErrorPublic(makeResponse(503, 'Service Unavailable'), 'DeepSeek'),
).rejects.toThrow('DeepSeek: 503');
try {
await adapter.throwHttpErrorPublic(makeResponse(503, ''), 'DeepSeek');
} catch (e) {
expect((e as Error & { status: number }).status).toBe(503);
}
});
});
describe('BaseAdapter — getContextWindow', () => {
it('配置了 contextWindow 时返回配置值', () => {
const adapter = makeAdapter({ contextWindow: 128_000 });
expect(adapter.getContextWindow()).toBe(128_000);
});
it('未配置时返回保守默认值 1M', () => {
const adapter = makeAdapter();
expect(adapter.getContextWindow()).toBe(1_000_000);
});
});
describe('BaseAdapter — listModels / healthCheck', () => {
it('listModels 将 supportedModels 映射为 MetonaModelInfo', async () => {
const adapter = makeAdapter();
const models = await adapter.listModels();
expect(models.map((m) => m.id)).toEqual(['test-model-a', 'test-model-b']);
});
it('healthCheck 成功返回 true', async () => {
const adapter = makeAdapter();
expect(await adapter.healthCheck()).toBe(true);
});
});
describe('BaseAdapter — fetchWithTimeout', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('正常请求返回 Response 并清理 timer', async () => {
const adapter = makeAdapter();
const mockResponse = new Response('{"ok":true}');
const fetchMock = vi.fn().mockResolvedValue(mockResponse);
vi.stubGlobal('fetch', fetchMock);
const result = await adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 5_000);
expect(result).toBe(mockResponse);
expect(fetchMock).toHaveBeenCalledOnce();
});
it('超时后 abort 请求(AbortError', async () => {
const adapter = makeAdapter();
vi.useFakeTimers();
const fetchMock = vi.fn(
(_url: string, init: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
}),
);
vi.stubGlobal('fetch', fetchMock);
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 100);
const expectation = expect(promise).rejects.toThrow('Aborted');
vi.advanceTimersByTime(150);
await expectation;
vi.useRealTimers();
});
it('外部 abort 信号触发请求中断', async () => {
const adapter = makeAdapter();
const controller = new AbortController();
adapter.setAbortSignal(controller.signal);
const fetchMock = vi.fn(
(_url: string, init: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
}),
);
vi.stubGlobal('fetch', fetchMock);
const promise = adapter.fetchWithTimeoutPublic('https://api.test.com/v1/chat', {}, 30_000);
const expectation = expect(promise).rejects.toThrow('Aborted');
controller.abort();
await expectation;
});
});
describe('BaseAdapter — 接口契约', () => {
it('providerId / 能力声明符合 IMetonaProviderAdapter 契约', () => {
const adapter: IMetonaProviderAdapter = makeAdapter();
expect(adapter.providerId).toBe('test');
expect(typeof adapter.send).toBe('function');
expect(typeof adapter.sendStream).toBe('function');
expect(typeof adapter.getContextWindow).toBe('function');
expect(typeof adapter.setAbortSignal).toBe('function');
});
it('sendStream 是 AsyncGenerator(可迭代)', async () => {
const adapter = makeAdapter();
const events: MetonaStreamEvent[] = [];
for await (const ev of adapter.sendStream({} as MetonaRequest)) {
events.push(ev);
}
expect(events).toHaveLength(0);
expect(MetonaStreamEventType.TEXT_DELTA).toBe('text_delta');
});
});
@@ -0,0 +1,238 @@
/**
* SSE 流式解析器单元测试(v0.4.1 测试补齐)
* 覆盖:TEXT_DELTA / REASONING_DELTA / TOOL_CALL 增量拼接 / USAGE /
* [DONE] / finish_reason=tool_calls 提前 flush / 坏 JSON 行容错 / 损坏工具调用跳过
*/
import { describe, it, expect } from 'vitest';
import { parseSSEStream, parseOpenAICompatibleResponse } from '../shared/sse-stream';
import { MetonaStreamEventType } from '../../types';
/** 构造 SSE 测试流 */
function makeStream(chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
start(controller) {
for (const c of chunks) controller.enqueue(encoder.encode(c));
controller.close();
},
});
}
async function collect(
stream: ReadableStream<Uint8Array>,
): Promise<Array<{ type: string; [k: string]: unknown }>> {
const events: Array<{ type: string; [k: string]: unknown }> = [];
for await (const ev of parseSSEStream(stream, 'req_test', 'sess_test', 1)) {
events.push(ev as unknown as { type: string; [k: string]: unknown });
}
return events;
}
describe('parseSSEStream — 文本与推理增量', () => {
it('TEXT_DELTA 事件按序产出', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"content":"你好"}}]}\n\n',
'data: {"choices":[{"delta":{"content":",世界"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
const deltas = events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
expect(deltas).toHaveLength(2);
expect(deltas[0].delta).toBe('你好');
expect(deltas[1].delta).toBe(',世界');
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
});
it('REASONING_DELTAThinking 模式)事件产出', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"reasoning_content":"让我想想"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
const reasoning = events.find((e) => e.type === MetonaStreamEventType.REASONING_DELTA);
expect(reasoning?.delta).toBe('让我想想');
});
it('同一个 chunk 中 content 和 reasoning_content 同时产出', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"content":"答","reasoning_content":"思考"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1);
expect(events.filter((e) => e.type === MetonaStreamEventType.REASONING_DELTA)).toHaveLength(1);
});
});
describe('parseSSEStream — 工具调用增量拼接', () => {
it('分段 arguments 拼接为完整 JSON 并在 finish_reason=tool_calls 时 flush', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"read_file","arguments":"{\\"pa"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"th\\": \\"a.ts\\"}"}}]}}]}\n\n',
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n',
'data: [DONE]\n\n',
]),
);
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
expect(completes).toHaveLength(1);
const tc = completes[0].toolCall as { name: string; args: Record<string, unknown> };
expect(tc.name).toBe('read_file');
expect(tc.args).toEqual({ path: 'a.ts' });
});
it('多个工具调用按 index 分别拼接', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"tool_a","arguments":"{}"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"name":"tool_b","arguments":"{}"}}]}}]}\n\n',
'data: [DONE]\n\n',
]),
);
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
expect(completes).toHaveLength(2);
const names = completes.map((e) => (e.toolCall as { name: string }).name);
expect(names).toEqual(['tool_a', 'tool_b']);
});
it('损坏的 args JSON 跳过该工具调用且不中断流', async () => {
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"bad_tool","arguments":"{invalid json"}}]}}]}\n\n',
'data: {"choices":[{"delta":{"content":"后续文本"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
// 坏 JSON 的工具调用被跳过(不产出 TOOL_CALL_COMPLETE
expect(events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE)).toHaveLength(
0,
);
// 流继续处理后续事件
expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true);
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
});
});
describe('parseSSEStream — USAGE 与容错', () => {
it('USAGE 事件解析 DeepSeek 缓存字段', async () => {
const events = await collect(
makeStream([
'data: {"choices":[],"usage":{"prompt_tokens":100,"completion_tokens":50,"total_tokens":150,"prompt_cache_hit_tokens":80,"prompt_cache_miss_tokens":20}}\n\n',
'data: [DONE]\n\n',
]),
);
const usageEvent = events.find((e) => e.type === MetonaStreamEventType.USAGE);
const usage = usageEvent?.usage as Record<string, number>;
expect(usage.inputTokens).toBe(100);
expect(usage.outputTokens).toBe(50);
expect(usage.totalTokens).toBe(150);
expect(usage.cacheHitTokens).toBe(80);
expect(usage.cacheMissTokens).toBe(20);
});
it('坏 JSON 行不中断后续事件(记录警告后继续)', async () => {
const events = await collect(
makeStream([
'data: {broken json\n\n',
'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true);
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
});
it('跨 chunk 分割的 SSE 行正确拼接', async () => {
// "data: {...}\n\n" 被切到两个网络 chunk 中
const events = await collect(
makeStream([
'data: {"choices":[{"delta":{"cont',
'ent":"拼接成功"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
const delta = events.find((e) => e.type === MetonaStreamEventType.TEXT_DELTA);
expect(delta?.delta).toBe('拼接成功');
});
it('空行与非 data 行被忽略', async () => {
const events = await collect(
makeStream([
': comment line\n\n',
'\n',
'data: {"choices":[{"delta":{"content":"x"}}]}\n\n',
'data: [DONE]\n\n',
]),
);
expect(events.filter((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1);
});
});
describe('parseOpenAICompatibleResponse — 非流式响应', () => {
it('解析普通文本响应', () => {
const result = parseOpenAICompatibleResponse({
choices: [{ message: { content: 'hello' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
});
expect(result.content).toBe('hello');
expect(result.finishReason).toBe('stop');
expect(result.usage.inputTokens).toBe(10);
expect(result.toolCalls).toBeUndefined();
});
it('解析 reasoning_contentThinking 模式)', () => {
const result = parseOpenAICompatibleResponse({
choices: [
{ message: { content: 'answer', reasoning_content: 'thinking...' }, finish_reason: 'stop' },
],
usage: {},
});
expect(result.reasoningContent).toBe('thinking...');
});
it('解析 tool_calls(字符串 arguments 反序列化)', () => {
const result = parseOpenAICompatibleResponse({
choices: [
{
message: {
content: null,
tool_calls: [{ id: 'tc_1', function: { name: 'run', arguments: '{"cmd":"ls"}' } }],
},
finish_reason: 'tool_calls',
},
],
usage: {},
});
expect(result.toolCalls).toHaveLength(1);
expect(result.toolCalls![0].name).toBe('run');
expect(result.toolCalls![0].args).toEqual({ cmd: 'ls' });
});
it('损坏的 tool_calls arguments 降级为空对象', () => {
const result = parseOpenAICompatibleResponse({
choices: [
{
message: {
content: null,
tool_calls: [{ id: 'tc_1', function: { name: 'run', arguments: '{bad' } }],
},
finish_reason: 'tool_calls',
},
],
usage: {},
});
expect(result.toolCalls![0].args).toEqual({});
});
it('mapOpenAIFinishReason 覆盖 MiMo repetition_truncation', () => {
const result = parseOpenAICompatibleResponse({
choices: [{ message: { content: 'x' }, finish_reason: 'repetition_truncation' }],
usage: {},
});
expect(result.finishReason).toBe('stop');
});
});
+18 -5
View File
@@ -124,7 +124,11 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
* @param init fetch init(不含 signal,由本方法内部管理)
* @param timeoutMs 超时时间(毫秒)
*/
protected async fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
protected async fetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -181,7 +185,11 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
*/
protected async throwHttpError(response: Response, context: string): Promise<never> {
let errorBody = '';
try { errorBody = await response.text(); } catch { /* body 可能已消费或为 null */ }
try {
errorBody = await response.text();
} catch {
/* body 可能已消费或为 null */
}
// v0.3.17: 解析 JSON 错误体,识别 content_filter
if (errorBody) {
@@ -204,7 +212,9 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
}
}
const error = new Error(`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`);
const error = new Error(
`${context}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ''}`,
);
(error as Error & { status: number }).status = response.status;
throw error;
}
@@ -226,7 +236,10 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
const msg = error.message.toLowerCase();
if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) {
// v0.4.1 修复: msg 已 toLowerCase,网络错误码常量必须用小写比较
//(原 'ETIMEDOUT'/'ECONNREFUSED' 等大写常量在小写消息上永不匹配,
// 导致网络错误全部落入 UNKNOWN,无法触发引擎的重试逻辑)
if (msg.includes('timeout') || msg.includes('etimedout')) {
return {
code: MetonaErrorCode.NETWORK_TIMEOUT,
message: error.message,
@@ -236,7 +249,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
};
}
if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND') || msg.includes('ECONNRESET')) {
if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('econnreset')) {
return {
code: MetonaErrorCode.NETWORK_ERROR,
message: error.message,
@@ -0,0 +1,317 @@
/**
* ConfirmationHook 单元测试(v0.4.1 测试补齐)
* 覆盖:自动执行放行、会话内记忆(批准/拒绝)、拒绝记忆 TTL 过期、
* 超时拒绝、用户批准、批量审批、pending 管理、恢复询问接口
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { BrowserWindow } from 'electron';
import { ConfirmationHook } from '../confirmation-hook';
import type { MetonaToolCall, MetonaToolDef } from '../../types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../types';
/** 需要确认的高风险工具定义 */
const HIGH_RISK_DEF: MetonaToolDef = {
name: 'run_command',
description: 'Execute shell command (test fixture)',
parameters: { type: 'object', properties: {}, required: [] },
category: MetonaToolCategory.CODE_EXECUTION,
riskLevel: MetonaRiskLevel.HIGH,
requiresPermission: true,
timeoutMs: 1_000,
};
/** 低风险工具定义(无需确认) */
const SAFE_DEF: MetonaToolDef = {
name: 'read_file',
description: 'Read file (test fixture)',
parameters: { type: 'object', properties: {}, required: [] },
category: MetonaToolCategory.FILE_SYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 1_000,
};
/** 第二个需确认的高风险工具(用于记忆互不干扰的测试) */
const HIGH_RISK_DEF_2: MetonaToolDef = {
name: 'delete_file',
description: 'Delete file (test fixture)',
parameters: { type: 'object', properties: {}, required: [] },
category: MetonaToolCategory.FILE_SYSTEM,
riskLevel: MetonaRiskLevel.HIGH,
requiresPermission: true,
timeoutMs: 1_000,
};
let idCounter = 0;
function makeToolCall(name = 'run_command'): MetonaToolCall {
return {
id: `tc_${++idCounter}`,
name,
args: { command: 'ls' },
iteration: 1,
timestamp: Date.now(),
};
}
function makeMockWindow(): BrowserWindow {
return {
isDestroyed: () => false,
webContents: { send: vi.fn() },
} as unknown as BrowserWindow;
}
describe('ConfirmationHook — 免确认路径', () => {
it('未注册工具定义时放行(由 ToolRegistry 处理未知工具错误)', async () => {
const hook = new ConfirmationHook(null, null);
const result = await hook.beforeExecute(makeToolCall('unknown_tool'), 'sess');
expect(result.blocked).toBe(false);
});
it('无需确认的工具直接放行', async () => {
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([SAFE_DEF]);
const result = await hook.beforeExecute(makeToolCall('read_file'), 'sess');
expect(result.blocked).toBe(false);
});
it('持久化自动执行(autoExecute)的工具放行', async () => {
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([HIGH_RISK_DEF]);
hook.setAutoExecute('run_command', true);
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(false);
expect(hook.getAutoExecuteList()).toContain('run_command');
});
it('记住批准(remember approved)后同会话放行', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// 第一次调用 → 等待确认 → 用户批准并记住
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const pending = hook.getPendingConfirmations();
expect(pending).toHaveLength(1);
hook.resolveConfirmation(pending[0].toolCallId, true, true, false);
expect((await p1).blocked).toBe(false);
// 第二次调用 — 记住的批准直接放行
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(false);
});
});
describe('ConfirmationHook — 拒绝与阻断', () => {
it('记住拒绝后同会话阻断(reason 含 previously denied', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const pending = hook.getPendingConfirmations();
hook.resolveConfirmation(pending[0].toolCallId, false, true, false);
expect((await p1).blocked).toBe(true);
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(true);
expect(result.reason).toContain('previously denied');
});
it('拒绝记忆 TTL 过期后恢复询问(v0.4.1', async () => {
vi.useFakeTimers();
try {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// 记住拒绝
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const pending1 = hook.getPendingConfirmations();
hook.resolveConfirmation(pending1[0].toolCallId, false, true, false);
await p1;
// 拒绝记忆立即生效
const blockedNow = await hook.beforeExecute(makeToolCall(), 'sess');
expect(blockedNow.blocked).toBe(true);
expect(blockedNow.reason).toContain('previously denied');
// 快进 11 分钟(TTL 10 分钟)→ 拒绝记忆过期,恢复询问流程
vi.setSystemTime(Date.now() + 11 * 60 * 1000);
// 撤掉主窗口,使询问流程以 'no main window' 阻断(证明走到了询问分支而非记忆分支)
hook.setMainWindow(null as unknown as BrowserWindow);
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(true);
expect(result.reason).toContain('no main window available');
// 过期记忆已被清理
expect(hook.getRememberedDenials()).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
it('无主窗口时安全阻断(fail-closed', async () => {
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([HIGH_RISK_DEF]);
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(true);
expect(result.reason).toContain('no main window available');
});
it('用户拒绝单次调用 → blocked 且 reason 含 User denied', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess');
const pending = hook.getPendingConfirmations();
hook.resolveConfirmation(pending[0].toolCallId, false, false, false);
const result = await p;
expect(result.blocked).toBe(true);
expect(result.reason).toContain('User denied');
});
});
describe('ConfirmationHook — 超时行为', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('确认超时视为拒绝(blocked', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
hook.setConfirmationTimeout(30_000); // 最小值 30s
const p = hook.beforeExecute(makeToolCall(), 'sess');
// 快进超过超时时间
vi.advanceTimersByTime(31_000);
const result = await p;
expect(result.blocked).toBe(true);
expect(result.reason).toContain('User denied');
// pending 已被超时清理
expect(hook.getPendingConfirmations()).toHaveLength(0);
});
it('确认超时与用户点击的竞态:先到者赢(settled 标志)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
hook.setConfirmationTimeout(30_000);
const p = hook.beforeExecute(makeToolCall(), 'sess');
// timer 回调已入队但未执行时,用户点击批准
vi.advanceTimersByTime(30_000);
// 超时已 resolve(false) — 后续 resolveConfirmation 无效果
const pending = hook.getPendingConfirmations();
expect(pending).toHaveLength(0);
const result = await p;
expect(result.blocked).toBe(true);
});
});
describe('ConfirmationHook — 批量审批(v0.3.2', () => {
it('批量批准并行工具调用', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const p2 = hook.beforeExecute(makeToolCall(), 'sess');
expect(hook.getPendingConfirmations()).toHaveLength(2);
const ids = hook.getPendingConfirmations().map((r) => r.toolCallId);
const resolved = hook.resolveConfirmationsBatch(ids, true, false, false);
expect(resolved).toHaveLength(2);
expect((await p1).blocked).toBe(false);
expect((await p2).blocked).toBe(false);
});
it('批量拒绝 + 记住 → 同工具后续调用被记忆阻断', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const p2 = hook.beforeExecute(makeToolCall(), 'sess');
const ids = hook.getPendingConfirmations().map((r) => r.toolCallId);
hook.resolveConfirmationsBatch(ids, false, true, false);
expect((await p1).blocked).toBe(true);
expect((await p2).blocked).toBe(true);
const after = await hook.beforeExecute(makeToolCall(), 'sess');
expect(after.blocked).toBe(true);
expect(after.reason).toContain('previously denied');
});
it('批量批准 + autoExecute → 写入持久化自动执行列表', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess');
const ids = hook.getPendingConfirmations().map((r) => r.toolCallId);
hook.resolveConfirmationsBatch(ids, true, false, true);
expect((await p).blocked).toBe(false);
expect(hook.getAutoExecuteList()).toContain('run_command');
});
});
describe('ConfirmationHook — 拒绝记忆管理接口(v0.4.1', () => {
it('getRememberedDenials 只返回拒绝记忆(含剩余时间)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF, HIGH_RISK_DEF_2]);
// 记住一个批准(run_command)、一个拒绝(delete_file
const pApprove = hook.beforeExecute(makeToolCall('run_command'), 'sess');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, true, false);
await pApprove;
const pDeny = hook.beforeExecute(makeToolCall('delete_file'), 'sess');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await pDeny;
const denials = hook.getRememberedDenials();
expect(denials).toHaveLength(1);
expect(denials[0].toolName).toBe('delete_file');
expect(denials[0].expiresInSeconds).toBeGreaterThan(0);
expect(denials[0].expiresInSeconds).toBeLessThanOrEqual(600);
});
it('resetRememberedDenial 重置后恢复询问', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await p;
expect(hook.getRememberedDenials()).toHaveLength(1);
// 重置 → 拒绝记忆清空
expect(hook.resetRememberedDenial('run_command')).toBe(true);
expect(hook.getRememberedDenials()).toHaveLength(0);
// 后续调用恢复询问(有窗口 → 产生新 pending)
const p2 = hook.beforeExecute(makeToolCall(), 'sess');
expect(hook.getPendingConfirmations()).toHaveLength(1);
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
expect((await p2).blocked).toBe(false);
});
it('resetRememberedDenial 对无拒绝记忆的工具返回 false', () => {
const hook = new ConfirmationHook(null, null);
expect(hook.resetRememberedDenial('run_command')).toBe(false);
});
});
describe('ConfirmationHook — clearPending', () => {
it('清空所有等待中的确认(全部视为拒绝)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const p2 = hook.beforeExecute(makeToolCall(), 'sess');
hook.clearPending();
expect((await p1).blocked).toBe(true);
expect((await p2).blocked).toBe(true);
expect(hook.getPendingConfirmations()).toHaveLength(0);
});
});
+90 -24
View File
@@ -40,23 +40,35 @@ export class ConfirmationHook implements PreToolHook {
/** 工具定义缓存(由外部设置) */
private toolDefs = new Map<string, MetonaToolDef>();
/** 用户选择记忆(同一会话内不再重复询问) */
private rememberedDecisions = new Map<string, boolean>();
/** 用户选择记忆(同一会话内不再重复询问)— v0.4.1: 值扩展为 { approved, at } 以支持拒绝记忆 TTL */
private rememberedDecisions = new Map<string, { approved: boolean; at: number }>();
/**
* v0.4.1: 会话内拒绝记忆的 TTL10 分钟)
*
* 历史问题:用户勾选"记住拒绝"后,该工具在本会话永久被拒且无恢复入口,
* 用户只能重启会话。现给拒绝记忆加 TTL——过期后恢复询问;
* 批准记忆不受 TTL 影响(记住批准是低风险决定,保留原语义)。
*/
private static readonly DENIAL_TTL_MS = 10 * 60 * 1000;
/** 持久化自动执行的工具集合(从 ConfigService 加载,跨会话生效) */
private autoExecuteTools = new Set<string>();
/** 等待确认的 Promise 解析器(含完整请求信息,供 getPendingConfirmations 返回) */
private pendingConfirmations = new Map<string, {
resolve: (v: boolean) => void;
timer: NodeJS.Timeout;
toolName: string;
expiresAt: number;
/** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */
args?: Record<string, unknown>;
riskLevel?: string;
reason?: string;
}>();
private pendingConfirmations = new Map<
string,
{
resolve: (v: boolean) => void;
timer: NodeJS.Timeout;
toolName: string;
expiresAt: number;
/** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */
args?: Record<string, unknown>;
riskLevel?: string;
reason?: string;
}
>();
/** 确认超时时间(可从配置读取,默认 120 秒) */
private confirmationTimeoutMs = 120_000;
@@ -168,7 +180,12 @@ export class ConfirmationHook implements PreToolHook {
* @param remember 会话内记住决定
* @param autoExecute 永久自动执行(持久化)
*/
resolveConfirmation(toolCallId: string, approved: boolean, remember: boolean, autoExecute: boolean = false): void {
resolveConfirmation(
toolCallId: string,
approved: boolean,
remember: boolean,
autoExecute: boolean = false,
): void {
const pending = this.pendingConfirmations.get(toolCallId);
if (pending) {
clearTimeout(pending.timer);
@@ -177,9 +194,9 @@ export class ConfirmationHook implements PreToolHook {
if (autoExecute && approved) {
this.setAutoExecute(pending.toolName, true);
}
// 会话内记忆
// 会话内记忆(v0.4.1: 拒绝记忆带时间戳,用于 TTL 过期)
if (remember) {
this.rememberedDecisions.set(pending.toolName, approved);
this.rememberedDecisions.set(pending.toolName, { approved, at: Date.now() });
}
this.pendingConfirmations.delete(toolCallId);
}
@@ -225,7 +242,7 @@ export class ConfirmationHook implements PreToolHook {
this.setAutoExecute(pending.toolName, true);
}
if (remember) {
this.rememberedDecisions.set(pending.toolName, approved);
this.rememberedDecisions.set(pending.toolName, { approved, at: Date.now() });
}
}
}
@@ -257,6 +274,44 @@ export class ConfirmationHook implements PreToolHook {
return result;
}
/**
* v0.4.1: 获取本会话内记住"拒绝"的工具列表(含剩余有效期,供前端展示恢复入口)
*
* 拒绝记忆有 TTL(默认 10 分钟),到期自动恢复询问;
* 此方法返回未过期的拒绝记忆,前端可提供"重新询问"按钮主动重置。
*/
getRememberedDenials(): Array<{ toolName: string; expiresInSeconds: number }> {
const now = Date.now();
const result: Array<{ toolName: string; expiresInSeconds: number }> = [];
for (const [toolName, decision] of this.rememberedDecisions) {
if (decision.approved) continue;
const elapsed = now - decision.at;
if (elapsed >= ConfirmationHook.DENIAL_TTL_MS) {
// 已过期 — 顺手清理,避免列表返回过期条目
this.rememberedDecisions.delete(toolName);
continue;
}
result.push({
toolName,
expiresInSeconds: Math.ceil((ConfirmationHook.DENIAL_TTL_MS - elapsed) / 1000),
});
}
return result;
}
/**
* v0.4.1: 重置指定工具的会话内拒绝记忆(恢复询问)
* @returns true 表示重置成功(存在该工具的拒绝记忆);false 表示没有可重置的记忆
*/
resetRememberedDenial(toolName: string): boolean {
const decision = this.rememberedDecisions.get(toolName);
if (decision && !decision.approved) {
this.rememberedDecisions.delete(toolName);
return true;
}
return false;
}
async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise<HookResult> {
const def = this.toolDefs.get(toolCall.name);
if (!def) {
@@ -265,8 +320,8 @@ export class ConfirmationHook implements PreToolHook {
}
// 检查是否需要确认
const needsConfirmation = def.requiresPermission ||
ConfirmationHook.REQUIRES_CONFIRMATION.includes(def.riskLevel);
const needsConfirmation =
def.requiresPermission || ConfirmationHook.REQUIRES_CONFIRMATION.includes(def.riskLevel);
if (!needsConfirmation) {
return { blocked: false };
@@ -277,11 +332,21 @@ export class ConfirmationHook implements PreToolHook {
return { blocked: false };
}
// 检查是否有记住的决策
// 检查是否有记住的决策v0.4.1: 拒绝记忆带 TTL,过期后恢复询问)
const remembered = this.rememberedDecisions.get(toolCall.name);
if (remembered !== undefined) {
if (remembered) return { blocked: false };
return { blocked: true, reason: `User previously denied tool "${toolCall.name}"` };
const isExpiredDenial =
!remembered.approved && Date.now() - remembered.at > ConfirmationHook.DENIAL_TTL_MS;
if (isExpiredDenial) {
// 拒绝记忆已过期 — 移除并继续走正常确认流程
this.rememberedDecisions.delete(toolCall.name);
} else {
if (remembered.approved) return { blocked: false };
return {
blocked: true,
reason: `User previously denied tool "${toolCall.name}" (remembered in this session; expires in ${Math.ceil((ConfirmationHook.DENIAL_TTL_MS - (Date.now() - remembered.at)) / 60000)} min)`,
};
}
}
// 如果没有主窗口,安全起见阻止执行
@@ -347,9 +412,10 @@ export class ConfirmationHook implements PreToolHook {
this.lastTimeoutToastAt = now;
// 统计当前还有多少 pending(含本次刚超时的)
const pendingCount = this.pendingConfirmations.size + 1;
const message = pendingCount > 1
? `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),${pendingCount} 个工具未执行`
: `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`;
const message =
pendingCount > 1
? `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),${pendingCount} 个工具未执行`
: `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`;
this.mainWindow.webContents.send('toast:show', {
type: 'warning',
message,
+4 -3
View File
@@ -21,15 +21,16 @@ export interface PreToolHook {
export class PermissionCheckHook implements PreToolHook {
constructor(private policyEngine: PolicyEngine) {}
async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise<HookResult> {
const result = this.policyEngine.checkAuthorization(toolCall.name, toolCall.args);
async beforeExecute(toolCall: MetonaToolCall, sessionId: string): Promise<HookResult> {
// v0.4.1: 透传 sessionId 使频率限制按会话隔离(多会话并发时各自独立配额)
const result = this.policyEngine.checkAuthorization(toolCall.name, toolCall.args, sessionId);
if (!result.authorized) {
return { blocked: true, reason: result.reason };
}
// v0.3.0 修复: 授权成功后记录调用,使频率限制功能生效
// 在授权检查通过后立即记录,即使后续工具执行失败也计入频率
// 这样可以防止通过故意制造错误来绕过频率限制
this.policyEngine.recordCall(toolCall.name);
this.policyEngine.recordCall(toolCall.name, sessionId);
return { blocked: false };
}
}
@@ -9,12 +9,34 @@ import { PolicyEngine, DEFAULT_POLICIES } from '../permissions';
describe('PolicyEngine 默认策略', () => {
it('所有内置工具均有策略配置', () => {
const knownTools = [
'read_file', 'write_file', 'list_directory', 'search_files', 'delete_file',
'file_move', 'file_info', 'file_editor', 'code_search', 'diff_viewer',
'web_search', 'web_fetch', 'web_browser', 'http_request',
'memory_store', 'memory_search', 'run_command', 'task_manager',
'delegate_task', 'git_status', 'git_diff', 'git_log', 'git_commit',
'lint_code', 'run_tests', 'project_info', 'think', 'view_image',
'read_file',
'write_file',
'list_directory',
'search_files',
'delete_file',
'file_move',
'file_info',
'file_editor',
'code_search',
'diff_viewer',
'web_search',
'web_fetch',
'web_browser',
'http_request',
'memory_store',
'memory_search',
'run_command',
'task_manager',
'delegate_task',
'git_status',
'git_diff',
'git_log',
'git_commit',
'lint_code',
'run_tests',
'project_info',
'think',
'view_image',
];
for (const tool of knownTools) {
expect(DEFAULT_POLICIES.some((p) => p.toolName === tool)).toBe(true);
@@ -57,7 +79,9 @@ describe('deniedPatterns 深度扫描', () => {
it('正常路径不误判', () => {
const engine = new PolicyEngine();
expect(engine.checkAuthorization('read_file', { file_path: 'src/main.ts' }).authorized).toBe(true);
expect(engine.checkAuthorization('read_file', { file_path: 'src/main.ts' }).authorized).toBe(
true,
);
});
});
@@ -91,3 +115,45 @@ describe('通配符策略(mcp_*', () => {
expect(result.requiresConfirmation).toBe(true);
});
});
describe('频率限制会话隔离(v0.4.1', () => {
it('不同会话各自独立配额(一个会话耗尽不影响另一个)', () => {
const engine = new PolicyEngine();
// web_search 默认 maxFrequency: 10
// 会话 A 耗尽全部配额
for (let i = 0; i < 10; i++) {
expect(engine.checkAuthorization('web_search', { query: 'x' }, 'session-A').authorized).toBe(
true,
);
engine.recordCall('web_search', 'session-A');
}
// 会话 A 已被限流
expect(engine.checkAuthorization('web_search', { query: 'x' }, 'session-A').authorized).toBe(
false,
);
// 会话 B 配额不受影响
expect(engine.checkAuthorization('web_search', { query: 'x' }, 'session-B').authorized).toBe(
true,
);
expect(engine.recordCall('web_search', 'session-B') === undefined).toBe(true);
});
it('recordCall 与 checkAuthorization 使用相同的会话 key', () => {
const engine = new PolicyEngine();
// 会话 A 记录 10 次
for (let i = 0; i < 10; i++) engine.recordCall('web_search', 'session-A');
// 会话 A 限流,会话 B 不限
expect(engine.checkAuthorization('web_search', {}, 'session-A').authorized).toBe(false);
expect(engine.checkAuthorization('web_search', {}, 'session-B').authorized).toBe(true);
});
it('无 sessionId 时计入 global 桶(向后兼容)', () => {
const engine = new PolicyEngine();
// 旧式调用(无 sessionId)共享 global 桶
for (let i = 0; i < 10; i++) {
engine.recordCall('web_search');
}
expect(engine.checkAuthorization('web_search', {}).authorized).toBe(false);
expect(engine.checkAuthorization('web_search', {}, 'any-session').authorized).toBe(true);
});
});
+123 -20
View File
@@ -32,21 +32,65 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
// H-5 修复: 移除 /MEMORY\.md/i 粗粒度正则 — 之前会误拦子目录的 MEMORY.md
// 改为在 engine.ts executeToolSafely 中进行精确的根目录校验(仅保护 workspacePath/MEMORY.md
// @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected
{ toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i] },
{
toolName: 'read_file',
requiredLevel: PermissionLevel.READ,
deniedPatterns: [
/\/etc(?:\/|["'\s,}]|$)/,
/\/proc(?:\/|["'\s,}]|$)/,
/C:\\Windows\\/i,
/C:\\System32\\/i,
],
},
{ toolName: 'web_search', requiredLevel: PermissionLevel.READ, maxFrequency: 10 },
{ toolName: 'list_directory', requiredLevel: PermissionLevel.READ },
{ toolName: 'search_files', requiredLevel: PermissionLevel.READ },
{ toolName: 'memory_search', requiredLevel: PermissionLevel.READ },
{ toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i], requireConfirmation: true, maxFrequency: 5 },
{
toolName: 'write_file',
requiredLevel: PermissionLevel.WRITE,
deniedPatterns: [
/\/etc(?:\/|["'\s,}]|$)/,
/\/proc(?:\/|["'\s,}]|$)/,
/\/System(?:\/|["'\s,}]|$)/,
/C:\\Windows\\/i,
/C:\\System32\\/i,
],
requireConfirmation: true,
maxFrequency: 5,
},
{ toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE },
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 10 },
{
toolName: 'run_command',
requiredLevel: PermissionLevel.EXTERNAL_ACTION,
deniedPatterns: [/MEMORY\.md/i],
requireConfirmation: true,
maxFrequency: 10,
},
{ toolName: 'web_fetch', requiredLevel: PermissionLevel.READ },
// web_browser — 统一浏览器工具(合并自 9 个独立 browser_* 工具)
// 由于该工具可执行 JS、点击元素等高风险操作,统一设为 EXTERNAL_ACTION
{ toolName: 'web_browser', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 20 },
{
toolName: 'web_browser',
requiredLevel: PermissionLevel.EXTERNAL_ACTION,
requireConfirmation: true,
maxFrequency: 20,
},
// v0.3.0 修复: 补全缺失的工具策略 — 之前这5个工具未配置策略,导致被 PolicyEngine 拦截
// file_editor — 精准文件编辑(WRITE),与 write_file 同级安全约束
{ toolName: 'file_editor', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i], requireConfirmation: true, maxFrequency: 10 },
{
toolName: 'file_editor',
requiredLevel: PermissionLevel.WRITE,
deniedPatterns: [
/\/etc(?:\/|["'\s,}]|$)/,
/\/proc(?:\/|["'\s,}]|$)/,
/\/System(?:\/|["'\s,}]|$)/,
/C:\\Windows\\/i,
/C:\\System32\\/i,
],
requireConfirmation: true,
maxFrequency: 10,
},
// code_search — 基于 ripgrep 的只读搜索(READ
{ toolName: 'code_search', requiredLevel: PermissionLevel.READ },
// diff_viewer — 文件/文本差异对比(只读,READ)
@@ -54,17 +98,32 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
// task_manager — 任务管理(数据库读写,低风险 WRITE)
{ toolName: 'task_manager', requiredLevel: PermissionLevel.WRITE },
// delegate_task — 子任务委派(启动 SubAgentEXTERNAL_ACTION
{ toolName: 'delegate_task', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: false, maxFrequency: 5 },
{
toolName: 'delegate_task',
requiredLevel: PermissionLevel.EXTERNAL_ACTION,
requireConfirmation: false,
maxFrequency: 5,
},
// C-7 修复: MCP 工具通配符策略 — MCP 工具名称动态生成(mcp_{serverName}_{toolName}
// 无法预先配置精确策略,使用 mcp_* 通配符匹配所有 MCP 工具
// @see project_memory.md — All tools must have a configured policy in DEFAULT_POLICIES
{ toolName: 'mcp_*', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 20 },
{
toolName: 'mcp_*',
requiredLevel: PermissionLevel.EXTERNAL_ACTION,
requireConfirmation: true,
maxFrequency: 20,
},
// v0.3.1: Git 工具集(4 个)
{ toolName: 'git_status', requiredLevel: PermissionLevel.READ },
{ toolName: 'git_diff', requiredLevel: PermissionLevel.READ },
{ toolName: 'git_log', requiredLevel: PermissionLevel.READ },
{ toolName: 'git_commit', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 10 },
{
toolName: 'git_commit',
requiredLevel: PermissionLevel.WRITE,
requireConfirmation: true,
maxFrequency: 10,
},
// v0.3.1: 开发工具集(3 个)
{ toolName: 'lint_code', requiredLevel: PermissionLevel.READ },
@@ -81,10 +140,20 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
{ toolName: 'view_image', requiredLevel: PermissionLevel.READ },
// v0.3.2: 文件删除工具(1 个)— 破坏性操作,必须确认
{ toolName: 'delete_file', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 },
{
toolName: 'delete_file',
requiredLevel: PermissionLevel.WRITE,
requireConfirmation: true,
maxFrequency: 30,
},
// v0.3.3: 文件移动/重命名工具(1 个)— 可能覆盖目标,需确认
{ toolName: 'file_move', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 },
{
toolName: 'file_move',
requiredLevel: PermissionLevel.WRITE,
requireConfirmation: true,
maxFrequency: 30,
},
// v0.3.3: 文件信息查询工具(1 个)— 只读
{ toolName: 'file_info', requiredLevel: PermissionLevel.READ },
@@ -93,12 +162,22 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
export class PolicyEngine {
private policies: Map<string, PermissionPolicy> = new Map();
/** v0.3.0: 工具调用频率追踪 — 工具名 -> 调用时间戳列表 */
/**
* v0.4.1: 工具调用频率追踪 — 频率 key -> 调用时间戳列表
* key 格式: `${sessionId}:${toolName}`(会话隔离)
* 历史问题:v0.3.0 以 toolName 为 key,所有会话共享同一配额——
* P2-10 支持多会话并发后,一个会话可耗尽另一个会话的配额(如 web_search 10 次/分钟)
*/
private callFrequency: Map<string, number[]> = new Map();
/** v0.3.0: 频率限制的时间窗口(1分钟 = 60秒) */
private readonly FREQ_WINDOW_MS = 60_000;
/** v0.4.1: 构造会话隔离的频率 keysessionId 缺失时回退 'global' 保持兼容) */
private freqKey(toolName: string, sessionId?: string): string {
return `${sessionId || 'global'}:${toolName}`;
}
/**
* v0.3.0 修复:customPolicies 与 DEFAULT_POLICIES 合并而非完全覆盖
*
@@ -120,7 +199,20 @@ export class PolicyEngine {
}
}
checkAuthorization(toolName: string, args: Record<string, unknown>): {
/**
* 权限校验
*
* v0.4.1: 新增可选 sessionId 参数 — 频率限制按会话隔离(多会话并发时各自独立配额)
*
* @param toolName 工具名
* @param args 工具参数
* @param sessionId 会话 ID(可选;缺失时频率配额计入 'global' 桶保持向后兼容)
*/
checkAuthorization(
toolName: string,
args: Record<string, unknown>,
sessionId?: string,
): {
authorized: boolean;
reason?: string;
level: PermissionLevel;
@@ -223,9 +315,9 @@ export class PolicyEngine {
}
}
// v0.3.0: 频率限制检查
// v0.3.0: 频率限制检查v0.4.1: 按会话隔离)
if (policy.maxFrequency !== undefined) {
const freqCheck = this.checkFrequency(toolName, policy.maxFrequency);
const freqCheck = this.checkFrequency(toolName, policy.maxFrequency, sessionId);
if (!freqCheck.allowed) {
return {
authorized: false,
@@ -252,23 +344,31 @@ export class PolicyEngine {
* v0.3.0 修复:
* - 将 validCalls 写回 Map,避免 callFrequency 数组无限增长(内存泄漏)
*
* v0.4.1: 新增可选 sessionId 参数 — 频率配额按会话隔离
*
* @param toolName 工具名称
* @param maxFreq 最大频率(每分钟)
* @param sessionId 会话 ID(可选;缺失时计入 'global' 桶)
* @returns 检查结果
*/
checkFrequency(toolName: string, maxFreq?: number): { allowed: boolean; reason?: string } {
checkFrequency(
toolName: string,
maxFreq?: number,
sessionId?: string,
): { allowed: boolean; reason?: string } {
const policy = this.policies.get(toolName);
const limit = maxFreq ?? policy?.maxFrequency;
if (limit === undefined) return { allowed: true };
const key = this.freqKey(toolName, sessionId);
const now = Date.now();
const calls = this.callFrequency.get(toolName) ?? [];
const calls = this.callFrequency.get(key) ?? [];
// 移除时间窗口外的调用记录
const validCalls = calls.filter((t) => now - t < this.FREQ_WINDOW_MS);
// v0.3.0 修复:将清理后的 validCalls 写回 Map,避免数组无限增长
if (validCalls.length !== calls.length) {
this.callFrequency.set(toolName, validCalls);
this.callFrequency.set(key, validCalls);
}
if (validCalls.length >= limit) {
@@ -284,16 +384,19 @@ export class PolicyEngine {
* v0.3.0: 记录工具调用(工具成功执行后调用)
*
* v0.3.0 修复:同时清理过期记录,防止数组无限增长
* v0.4.1: 新增可选 sessionId 参数 — 与 checkFrequency 的会话隔离配对使用
*
* @param toolName 工具名称
* @param sessionId 会话 ID(可选;缺失时计入 'global' 桶)
*/
recordCall(toolName: string): void {
recordCall(toolName: string, sessionId?: string): void {
const key = this.freqKey(toolName, sessionId);
const now = Date.now();
const calls = this.callFrequency.get(toolName) ?? [];
const calls = this.callFrequency.get(key) ?? [];
// v0.3.0 修复:记录新调用时同时清理过期记录
const validCalls = calls.filter((t) => now - t < this.FREQ_WINDOW_MS);
validCalls.push(now);
this.callFrequency.set(toolName, validCalls);
this.callFrequency.set(key, validCalls);
}
// v0.3.0 修复: cleanupFrequencyRecords 已删除 — checkFrequency 和 recordCall 已做内联清理,
@@ -15,7 +15,9 @@ describe('RunCommandTool.validateCommand', () => {
const tool = new RunCommandTool();
// 访问私有方法
const validate = (cmd: string) =>
(tool as unknown as { validateCommand: (c: string) => { allowed: boolean; reason?: string } }).validateCommand(cmd);
(
tool as unknown as { validateCommand: (c: string) => { allowed: boolean; reason?: string } }
).validateCommand(cmd);
const blocked = (cmd: string) => {
const result = validate(cmd);
@@ -87,3 +89,28 @@ describe('RunCommandTool.validateCommand', () => {
allowed('rm -rf node_modules');
});
});
describe('RunCommandTool — Windows execFile 白名单(v0.4.1', () => {
const tool = new RunCommandTool();
const parseSimple = (cmd: string) =>
(
tool as unknown as {
parseCommandSimple: (c: string) => { command: string; args: string[] } | null;
}
).parseCommandSimple(cmd);
it('白名单命令解析为简单命令(无 shell 运算符)', () => {
const npm = parseSimple('npm install');
expect(npm).toEqual({ command: 'npm', args: ['install'] });
const git = parseSimple('git commit -m "fix: bug"');
expect(git).toEqual({ command: 'git', args: ['commit', '-m', 'fix: bug'] });
const node = parseSimple('node dist/main.js');
expect(node).toEqual({ command: 'node', args: ['dist/main.js'] });
});
it('含 shell 运算符的命令不解析为简单命令(继续走 exec 双层校验)', () => {
expect(parseSimple('npm install && npm test')).toBeNull();
expect(parseSimple('git log | head -5')).toBeNull();
expect(parseSimple('echo hi > out.txt')).toBeNull();
});
});
@@ -0,0 +1,174 @@
/**
* web_search 搜索引擎 HTML 解析器单元测试(v0.4.1 测试补齐)
* 覆盖:node-html-parser 结构化解析(主层)、自域名链接过滤、
* 相对链接补全、空/异常 HTML 容错
*/
import { describe, it, expect } from 'vitest';
import { parseBing, parseBaidu, parseSogou, parse360 } from '../web-search';
describe('parseBing — 结构化解析', () => {
const BING_HTML = `
<html><body>
<ol id="b_results">
<li class="b_algo">
<h2><a href="https://example.com/article-1">第一篇 TypeScript 文章</a></h2>
<p>这是第一条结果的摘要内容,讲述 TypeScript 高级用法。</p>
</li>
<li class="b_algo">
<h2><a href="https://example.com/article-2">第二篇 Node.js 文章</a></h2>
<div class="b_caption"><p>第二条结果的摘要。</p></div>
</li>
<li class="b_algo">
<!-- 自身域名链接应被过滤 -->
<h2><a href="https://www.bing.com/video?q=x">Bing 内部视频链接</a></h2>
<p>不应出现在结果中。</p>
</li>
</ol>
</body></html>
`;
it('解析结果块并提取 title/url/snippet', () => {
const results = parseBing(BING_HTML);
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({
title: '第一篇 TypeScript 文章',
url: 'https://example.com/article-1',
snippet: '这是第一条结果的摘要内容,讲述 TypeScript 高级用法。',
engine: 'bing',
weight: 90,
});
expect(results[1].url).toBe('https://example.com/article-2');
});
it('过滤指向 bing.com 自身域名的链接', () => {
const results = parseBing(BING_HTML);
expect(results.some((r) => r.url.includes('bing.com'))).toBe(false);
});
it('空 HTML 返回空数组', () => {
expect(parseBing('')).toHaveLength(0);
expect(parseBing('<html><body></body></html>')).toHaveLength(0);
});
});
describe('parseBaidu — 结构化解析', () => {
const BAIDU_HTML = `
<html><body>
<div id="content_left">
<div class="result c-container new-pmd" srcid="1">
<h3 class="t"><a data-url="https://example.com/real-url-1" href="https://www.baidu.com/link?url=xyz">百度结果一</a></h3>
<span class="content-right_8Zs40">第一条摘要内容。</span>
</div>
<div class="result c-container" srcid="2">
<h3><a href="https://www.baidu.com/link?url=abc">跳转链接结果</a></h3>
<span class="content-right_8Zs40">无 data-url 的结果(跳转链接被过滤)。</span>
</div>
</div>
</body></html>
`;
it('优先使用 data-url 真实链接', () => {
const results = parseBaidu(BAIDU_HTML);
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
title: '百度结果一',
url: 'https://example.com/real-url-1',
engine: '百度',
weight: 80,
});
});
it('baidu.com/link 跳转链接被过滤', () => {
const results = parseBaidu(BAIDU_HTML);
expect(results.some((r) => r.url.includes('baidu.com/link'))).toBe(false);
});
it('空 HTML 返回空数组', () => {
expect(parseBaidu('')).toHaveLength(0);
});
});
describe('parseSogou — 结构化解析', () => {
const SOGOU_HTML = `
<html><body>
<div class="results">
<div class="vrwrap">
<h3><a href="/link?url=sogou-internal-1">搜狗结果一</a></h3>
<div class="str_info">搜狗结果一的摘要文本。</div>
</div>
<div class="rb">
<h3><a href="https://example.com/direct">搜狗直链结果</a></h3>
<p class="space-txt">直链结果的摘要。</p>
</div>
</div>
</body></html>
`;
it('相对链接补全 sogou.com 前缀', () => {
const results = parseSogou(SOGOU_HTML);
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({
title: '搜狗结果一',
url: 'https://www.sogou.com/link?url=sogou-internal-1',
engine: '搜狗',
weight: 75,
});
});
it('http 开头的直链不补全前缀', () => {
const results = parseSogou(SOGOU_HTML);
expect(results[1].url).toBe('https://example.com/direct');
expect(results[1].snippet).toBe('直链结果的摘要。');
});
it('空 HTML 返回空数组', () => {
expect(parseSogou('')).toHaveLength(0);
});
});
describe('parse360 — 结构化解析', () => {
const SO360_HTML = `
<html><body>
<div class="res-list">
<ul>
<li class="res-list">
<h3 class="res-title"><a href="https://example.com/360-result">360 结果一</a></h3>
<p class="res-desc">360 搜索结果的摘要描述。</p>
</li>
<li class="res-list">
<h3><a href="https://www.so.com/internal">360 内部链接</a></h3>
<p class="res-desc">不应出现。</p>
</li>
</ul>
</div>
</body></html>
`;
it('解析结果并过滤 so.com 自身链接', () => {
const results = parse360(SO360_HTML);
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
title: '360 结果一',
url: 'https://example.com/360-result',
snippet: '360 搜索结果的摘要描述。',
engine: '360搜索',
weight: 75,
});
});
it('空 HTML 返回空数组', () => {
expect(parse360('')).toHaveLength(0);
});
});
describe('解析器降级路径', () => {
it('结构化解析无结果且正则也无结果时返回空数组(不抛错)', () => {
// 非搜索结果页 HTML(如错误页/验证码页)
const notSearchPage = '<html><body><div class="captcha">请输入验证码</div></body></html>';
expect(parseBing(notSearchPage)).toHaveLength(0);
expect(parseBaidu(notSearchPage)).toHaveLength(0);
expect(parseSogou(notSearchPage)).toHaveLength(0);
expect(parse360(notSearchPage)).toHaveLength(0);
});
});
+113 -27
View File
@@ -58,13 +58,22 @@ function decodeBuffer(buf: Buffer): string {
function buildSafeCommandEnv(isWindows: boolean): Record<string, string> {
// 敏感变量后缀黑名单
const SENSITIVE_SUFFIXES = [
'_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD',
'_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY',
'_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',
'DEEPSEEK_API_KEY',
'AGNES_API_KEY',
'MIMO_API_KEY',
'GITEA_PASSWORD',
'DATABASE_PASSWORD',
]);
const env: Record<string, string> = {};
@@ -86,12 +95,31 @@ function buildSafeCommandEnv(isWindows: boolean): Record<string, string> {
return env;
}
/**
* v0.4.1: Windows 白名单命令集合 — 这些工具的简单命令(无 shell 运算符)走
* execFile('cmd.exe', ['/c', ...words]) 执行:参数以数组形式显式传递,不经过
* shell 解析,从根本上去掉 exec() 的字符串拼接注入面(无法通过参数注入新命令)。
*
* 仅收录最常见的开发工具(小步灰度);其余命令仍走 exec + 双层校验的既有路径。
* Node 18.20+/Electron 35 在 Windows 上直接 spawn .cmd 批处理会被拒绝(EINVAL),
* 因此必须通过 cmd.exe /c 中转,但参数分离已足够收窄注入面。
*/
const WINDOWS_EXEC_FILE_WHITELIST = new Set(['git', 'node', 'npm', 'npx', 'pnpm', 'yarn', 'tsc']);
/** v0.4.1: 提取命令 basename(处理 C:\Program Files\nodejs\npm.cmd 等路径形式) */
function commandBasename(cmd: string): string {
const base = cmd.split(/[\\/]/).pop() ?? cmd;
// 去掉 .exe/.cmd/.bat 扩展名(大小写不敏感)
return base.replace(/\.(exe|cmd|bat)$/i, '');
}
// ===== 9. run_command =====
export class RunCommandTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'run_command',
description: 'Execute a shell command in a sandboxed environment. Commands run in the workspace directory. High-risk commands require user confirmation. Passes through SandboxManager static code scan and path validation.',
description:
'Execute a shell command in a sandboxed environment. Commands run in the workspace directory. High-risk commands require user confirmation. Passes through SandboxManager static code scan and path validation.',
parameters: {
type: 'object',
properties: {
@@ -123,7 +151,11 @@ export class RunCommandTool implements IMetonaTool {
// 安全校验:workdir 必须在工作空间内
const resolvedWorkdir = resolve(context.workspacePath, workdir);
if (!isPathWithinWorkspace(workdir, context.workspacePath)) {
return { success: false, error: `Working directory must be within workspace: ${workdir}`, command };
return {
success: false,
error: `Working directory must be within workspace: ${workdir}`,
command,
};
}
// v0.2.0: SandboxManager 双重安全校验 — fail-closed 设计
@@ -183,19 +215,32 @@ export class RunCommandTool implements IMetonaTool {
let stderr: Buffer;
// #8 修复 + 审查修复: 简单命令使用 execFile(不经过 shell,防止命令注入)
// 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法执行(ENOENT
// 因此 Windows 上仍用 exec(已有 SandboxManager.scanCode + validateCommand 双层校验)
// 非 Windows 上对简单命令用 execFile
// 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法直接执行(ENOENT/EINVAL
// v0.4.1: Windows 上白名单工具(git/node/npm/npx/pnpm/yarn/tsc)的简单命令改用
// execFile('cmd.exe', ['/c', ...args]) — 参数显式分离传递,不经 shell 字符串解析,
// 相比 exec() 的整串拼接显著收窄注入面
// 非 Windows 上对简单命令直接 execFile
if (simpleCmd && !isWindows) {
const result = await execFileAsync(simpleCmd.command, simpleCmd.args, execOpts);
stdout = result.stdout;
stderr = result.stderr;
} else if (
simpleCmd &&
isWindows &&
WINDOWS_EXEC_FILE_WHITELIST.has(commandBasename(simpleCmd.command))
) {
// v0.4.1: 白名单工具通过 cmd.exe /c + 参数数组执行(参数不经 shell 解析)
const result = await execFileAsync(
'cmd.exe',
['/c', simpleCmd.command, ...simpleCmd.args],
execOpts,
);
stdout = result.stdout;
stderr = result.stderr;
} else {
// 复杂命令(含管道/重定向/&& 等 shell 语法)或 Windows — 使用 exec
// 已有 SandboxManager.scanCode + validateCommand 双层安全校验
const finalCommand = isWindows
? `chcp 65001 >nul 2>&1 && ${command}`
: command;
const finalCommand = isWindows ? `chcp 65001 >nul 2>&1 && ${command}` : command;
const result = await execAsync(finalCommand, execOpts);
stdout = result.stdout;
stderr = result.stderr;
@@ -236,7 +281,11 @@ export class RunCommandTool implements IMetonaTool {
// 受保护文件检查:禁止通过命令行读写工作空间根目录的 MEMORY.md
if (commandTouchesProtectedFile(command)) {
return { allowed: false, reason: 'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via command execution' };
return {
allowed: false,
reason:
'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via command execution',
};
}
// P0-5: 剥离 Windows chcp 前缀("chcp 65001 >nul 2>&1 &&" 会破坏 shell-quote
@@ -256,33 +305,61 @@ export class RunCommandTool implements IMetonaTool {
const hardBlocks = [
// 文件系统破坏
{ pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' },
{ pattern: /\brm\s+-rf?\s+\/(?:[^|;&\s]*\s)*?(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/i, reason: 'rm on system directories is forbidden' },
{
pattern:
/\brm\s+-rf?\s+\/(?:[^|;&\s]*\s)*?(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/i,
reason: 'rm on system directories is forbidden',
},
{ pattern: /\b(sudo|su|doas)\b/, reason: 'Privilege escalation commands are forbidden' },
// 系统控制
{ pattern: /\b(shutdown|reboot|halt|poweroff)\b/, reason: 'System shutdown commands are forbidden' },
{
pattern: /\b(shutdown|reboot|halt|poweroff)\b/,
reason: 'System shutdown commands are forbidden',
},
{ pattern: /\b(killall|pkill)\s+-9\b/, reason: 'Force kill all processes is forbidden' },
// 远程代码执行
{ pattern: /curl.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' },
{ pattern: /wget.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' },
{ pattern: /\bcurl\s+.*\s*-o\s+\/etc\//i, reason: 'Writing to system directories via curl is forbidden' },
{
pattern: /\bcurl\s+.*\s*-o\s+\/etc\//i,
reason: 'Writing to system directories via curl is forbidden',
},
// 设备文件
{ pattern: /\bdd\b.*of=\/dev\//, reason: 'Writing to device files is forbidden' },
// 磁盘格式化
{ pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' },
// 权限滥用
{ pattern: /\bchmod\s+777\b/, reason: 'chmod 777 is forbidden' },
{ pattern: /\bchown\s+-R\s+\S+\s+\/(?:\s|$)/i, reason: 'Recursive chown on root is forbidden' },
{
pattern: /\bchown\s+-R\s+\S+\s+\/(?:\s|$)/i,
reason: 'Recursive chown on root is forbidden',
},
// 环境变量窃取
{ pattern: /\b(env|export|printenv)\s*\|.*\b(curl|wget|nc|ncat)\b/i, reason: 'Exfiltrating environment variables is forbidden' },
{
pattern: /\b(env|export|printenv)\s*\|.*\b(curl|wget|nc|ncat)\b/i,
reason: 'Exfiltrating environment variables is forbidden',
},
// 反向 shell
{ pattern: /\b(bash|sh|zsh)\s+-i\s+>\s*&\s*\/dev\/tcp\//i, reason: 'Reverse shell via /dev/tcp is forbidden' },
{
pattern: /\b(bash|sh|zsh)\s+-i\s+>\s*&\s*\/dev\/tcp\//i,
reason: 'Reverse shell via /dev/tcp is forbidden',
},
{ pattern: /\bnc\s+.*\s+-e\s+(bash|sh)/i, reason: 'Reverse shell via netcat is forbidden' },
// Windows 危险命令
{ pattern: /\b(format|diskpart)\b/i, reason: 'Disk formatting commands are forbidden' },
{ pattern: /\bshutdown\s*\//i, reason: 'System shutdown commands are forbidden' },
{ pattern: /\breg\s+(add|delete|import|restore)/i, reason: 'Registry modification commands are forbidden' },
{ pattern: /\b(taskkill|kill)\s*\//i, reason: 'Process termination with system flags is forbidden' },
{ pattern: /\bpowershell\s+-enc\s+/i, reason: 'PowerShell encoded command execution is forbidden' },
{
pattern: /\breg\s+(add|delete|import|restore)/i,
reason: 'Registry modification commands are forbidden',
},
{
pattern: /\b(taskkill|kill)\s*\//i,
reason: 'Process termination with system flags is forbidden',
},
{
pattern: /\bpowershell\s+-enc\s+/i,
reason: 'PowerShell encoded command execution is forbidden',
},
// 后台进程与管道炸弹
{ pattern: /&\s*\(/, reason: 'Background subshell execution is forbidden' },
{ pattern: /\|\s*&/, reason: 'Pipe to background process is forbidden' },
@@ -342,20 +419,29 @@ export class RunCommandTool implements IMetonaTool {
prevWasPipe = false;
} else if (typeof obj.op === 'string') {
// 跟踪管道运算符,用于下一轮检测 `| sh`
prevWasPipe = (obj.op === '|');
prevWasPipe = obj.op === '|';
}
}
}
// 危险命令名 token(精确匹配,大小写不敏感)
const dangerousCommands = new Set([
'sudo', 'su', 'doas',
'shutdown', 'reboot', 'halt', 'poweroff',
'mkfs', 'fdisk', 'format', 'diskpart',
'sudo',
'su',
'doas',
'shutdown',
'reboot',
'halt',
'poweroff',
'mkfs',
'fdisk',
'format',
'diskpart',
]);
// 危险参数 token
const dangerousArgs = new Set([
'-enc', '-encodedcommand', // PowerShell 编码执行
'-enc',
'-encodedcommand', // PowerShell 编码执行
]);
for (const word of words) {
+235 -45
View File
@@ -6,9 +6,15 @@
* 智能排序:引擎权重(50%) + 可达性(30%) + 摘要质量(20%)
* 自动抓取:对前 N 条结果调用 web_fetch 获取完整正文
*
* v0.4.1: HTML 解析迁移至 node-html-parser(结构化解析)
* 主层使用 DOM 结构解析(引擎改版时选择器更精确、可维护性远优于正则),
* 正则解析保留为降级路径(结构化解析无结果时兜底)。
* 此前纯正则方案违反项目开发规范第一铁律(HTML 解析应使用成熟库)。
*
* @see docs/Agent网络工具通用设计-v2.md — 第 2 章 web_search 搜索设计
*/
import { parse as parseHtmlDom, type HTMLElement } from 'node-html-parser';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
@@ -64,7 +70,9 @@ const ENGINES: EngineDef[] = [
name: 'bing',
weight: 90,
searchUrl: (q, tr) => {
const freshness = tr ? `&filters=ex1:"ez${tr === 'day' ? '1' : tr === 'week' ? '2' : tr === 'month' ? '3' : '4'}"` : '';
const freshness = tr
? `&filters=ex1:"ez${tr === 'day' ? '1' : tr === 'week' ? '2' : tr === 'month' ? '3' : '4'}"`
: '';
return `https://www.bing.com/search?q=${encodeURIComponent(q)}${freshness}&count=20`;
},
parse: parseBing,
@@ -89,9 +97,150 @@ const ENGINES: EngineDef[] = [
},
];
// ===== HTML 解析器(正则实现,后续可迁移至 cheerio =====
// ===== HTML 解析器(v0.4.1: node-html-parser 结构化解析为主层,正则为降级层 =====
function parseBing(html: string): SearchResult[] {
/**
* v0.4.1: 从结果块中提取标题链接 — 跳过指向搜索引擎自身域名的链接(favicon/子导航等)
*/
function extractTitleLink(
block: HTMLElement,
selfDomain: string,
): { url: string; title: string } | null {
for (const a of block.querySelectorAll('a[href]')) {
const url = a.getAttribute('href') ?? '';
const title = a.text.trim();
if (title && url && !url.includes(selfDomain) && url.startsWith('http')) {
return { url, title };
}
}
return null;
}
/** v0.4.1: 提取第一个非空文本的选择器(按优先级尝试多个候选选择器) */
function extractText(block: HTMLElement, selectors: string[]): string {
for (const sel of selectors) {
const el = block.querySelector(sel);
if (el) {
const text = el.text.trim();
if (text) return text;
}
}
return '';
}
/** v0.4.1: Bing 结构化解析 — li.b_algo 结果块 */
function parseBingStructured(html: string): SearchResult[] {
const results: SearchResult[] = [];
const root = parseHtmlDom(html);
for (const block of root.querySelectorAll('li.b_algo')) {
const link = extractTitleLink(block, 'bing.com');
if (!link) continue;
const snippet = extractText(block, ['p', '.b_caption']);
results.push({ title: link.title, url: link.url, snippet, engine: 'bing', weight: 90 });
}
return results;
}
/** v0.4.1: 百度结构化解析 — div.result / div.c-container 结果块,优先 a[data-url] 真实链接 */
function parseBaiduStructured(html: string): SearchResult[] {
const results: SearchResult[] = [];
const root = parseHtmlDom(html);
// 复合选择器去重:class="result c-container" 的元素同时命中两个类名,
// 分别查询再拼接会重复收录同一结果块
const blocks = root.querySelectorAll('div.result, div.c-container');
for (const block of blocks) {
// 百度标题链接: 优先 data-url 属性(真实目标 URL),href 通常是 baidu.com/link 跳转
const dataUrlLink = block.querySelector('a[data-url]');
let url = dataUrlLink?.getAttribute('data-url') ?? '';
let title = dataUrlLink?.text.trim() ?? '';
if (!url || !title) {
const fallback = block.querySelector('h3 a[href]') ?? block.querySelector('a[href]');
if (fallback) {
const href = fallback.getAttribute('href') ?? '';
url = href.startsWith('http') ? href : href ? `https://${href}` : '';
title = fallback.text.trim();
}
}
const snippet = extractText(block, ['.c-abstract', '[class^="content-right"]']);
if (title && url && !url.includes('baidu.com/link')) {
results.push({ title, url, snippet, engine: '百度', weight: 80 });
}
}
return results;
}
/**
* v0.4.1: 搜狗结构化解析 — div.vrwrap / div.rb 结果块(相对链接补全 sogou.com 前缀)
*
* v0.4.1 修复(原正则实现遗留缺陷): 搜狗结果链接是 sogou.com/link?url=... 跳转形式,
* 原 `!url.includes('sogou.com')` 过滤条件把所有跳转结果一并丢弃(相对链接补全后必含 sogou.com),
* 导致搜狗引擎基本无法返回结果。现仅过滤 sogou 自身页面链接,保留 /link 跳转结果
* (可达性预检会跟随重定向验证)。
*/
function parseSogouStructured(html: string): SearchResult[] {
const results: SearchResult[] = [];
const root = parseHtmlDom(html);
// 复合选择器避免同一元素命中两个类名时重复收录
const blocks = root.querySelectorAll('div.vrwrap, div.rb');
for (const block of blocks) {
const a = block.querySelector('h3 a[href]') ?? block.querySelector('a[href]');
if (!a) continue;
const href = a.getAttribute('href') ?? '';
const url = href.startsWith('http') ? href : `https://www.sogou.com${href}`;
const title = a.text.trim();
const snippet = extractText(block, ['.star-wiki', '.space-txt', '.str_info']);
// 过滤搜狗自身页面(保留 /link 跳转结果)
const isSelfPage = url.includes('sogou.com') && !url.includes('/link');
if (title && url && !isSelfPage) {
results.push({ title, url, snippet, engine: '搜狗', weight: 75 });
}
}
return results;
}
/** v0.4.1: 360 结构化解析 — li.res-list / div.result 结果块 */
function parse360Structured(html: string): SearchResult[] {
const results: SearchResult[] = [];
const root = parseHtmlDom(html);
// 复合选择器避免同一元素命中多个类名时重复收录
const blocks = root.querySelectorAll('li.res-list, div.result');
for (const block of blocks) {
const link = extractTitleLink(block, 'so.com');
if (!link) continue;
const snippet = extractText(block, ['.res-desc', '.res-rich', '.res-summary', 'dd']);
results.push({ title: link.title, url: link.url, snippet, engine: '360搜索', weight: 75 });
}
return results;
}
/** v0.4.1: 结构化解析 + 正则降级的组合入口(供 ENGINES 引用,测试导出) */
export function parseBing(html: string): SearchResult[] {
const structured = parseBingStructured(html);
if (structured.length > 0) return structured;
return parseBingRegex(html);
}
export function parseBaidu(html: string): SearchResult[] {
const structured = parseBaiduStructured(html);
if (structured.length > 0) return structured;
return parseBaiduRegex(html);
}
export function parseSogou(html: string): SearchResult[] {
const structured = parseSogouStructured(html);
if (structured.length > 0) return structured;
return parseSogouRegex(html);
}
export function parse360(html: string): SearchResult[] {
const structured = parse360Structured(html);
if (structured.length > 0) return structured;
return parse360Regex(html);
}
// ===== 正则降级解析器(v0.4.1 前的主实现,结构化解析无结果时兜底) =====
function parseBingRegex(html: string): SearchResult[] {
const results: SearchResult[] = [];
const blocks = html.split(/<li[^>]*class="b_algo"/i).slice(1);
for (const block of blocks) {
@@ -99,7 +248,9 @@ function parseBing(html: string): SearchResult[] {
if (!titleMatch) continue;
const url = titleMatch[1];
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
const snippetMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || block.match(/class="b_caption"[^>]*>([\s\S]*?)<\/div>/i);
const snippetMatch =
block.match(/<p[^>]*>([\s\S]*?)<\/p>/i) ||
block.match(/class="b_caption"[^>]*>([\s\S]*?)<\/div>/i);
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
if (title && url && !url.includes('bing.com')) {
results.push({ title, url, snippet, engine: 'bing', weight: 90 });
@@ -108,17 +259,19 @@ function parseBing(html: string): SearchResult[] {
return results;
}
function parseBaidu(html: string): SearchResult[] {
function parseBaiduRegex(html: string): SearchResult[] {
const results: SearchResult[] = [];
const blocks = html.split(/<div[^>]*class="result[^"]*"/i).slice(1);
for (const block of blocks) {
const titleMatch = block.match(/<a[^>]*data-url="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i)
|| block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
const titleMatch =
block.match(/<a[^>]*data-url="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i) ||
block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
if (!titleMatch) continue;
const url = titleMatch[1].startsWith('http') ? titleMatch[1] : `https://${titleMatch[1]}`;
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
const snippetMatch = block.match(/class="c-abstract[^"]*"[^>]*>([\s\S]*?)<\/span>/i)
|| block.match(/class="content-right[^"]*"[^>]*>([\s\S]*?)<\/div>/i);
const snippetMatch =
block.match(/class="c-abstract[^"]*"[^>]*>([\s\S]*?)<\/span>/i) ||
block.match(/class="content-right[^"]*"[^>]*>([\s\S]*?)<\/div>/i);
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
if (title && url && !url.includes('baidu.com/link')) {
results.push({ title, url, snippet, engine: '百度', weight: 80 });
@@ -127,18 +280,23 @@ function parseBaidu(html: string): SearchResult[] {
return results;
}
function parseSogou(html: string): SearchResult[] {
function parseSogouRegex(html: string): SearchResult[] {
const results: SearchResult[] = [];
const blocks = html.split(/<div[^>]*class="vrwrap"/i).slice(1)
const blocks = html
.split(/<div[^>]*class="vrwrap"/i)
.slice(1)
.concat(html.split(/<div[^>]*class="rb"/i).slice(1));
for (const block of blocks) {
const titleMatch = block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
if (!titleMatch) continue;
const url = titleMatch[1].startsWith('http') ? titleMatch[1] : `https://www.sogou.com${titleMatch[1]}`;
const url = titleMatch[1].startsWith('http')
? titleMatch[1]
: `https://www.sogou.com${titleMatch[1]}`;
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
const snippetMatch = block.match(/class="star-wiki[^"]*"[^>]*>([\s\S]*?)<\/div>/i)
|| block.match(/class="space-txt[^"]*"[^>]*>([\s\S]*?)<\/p>/i)
|| block.match(/class="str_info[^"]*"[^>]*>([\s\S]*?)<\/p>/i);
const snippetMatch =
block.match(/class="star-wiki[^"]*"[^>]*>([\s\S]*?)<\/div>/i) ||
block.match(/class="space-txt[^"]*"[^>]*>([\s\S]*?)<\/p>/i) ||
block.match(/class="str_info[^"]*"[^>]*>([\s\S]*?)<\/p>/i);
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
if (title && url && !url.includes('sogou.com')) {
results.push({ title, url, snippet, engine: '搜狗', weight: 75 });
@@ -147,19 +305,22 @@ function parseSogou(html: string): SearchResult[] {
return results;
}
function parse360(html: string): SearchResult[] {
function parse360Regex(html: string): SearchResult[] {
const results: SearchResult[] = [];
const blocks = html.split(/<li[^>]*class="res-list"/i).slice(1)
const blocks = html
.split(/<li[^>]*class="res-list"/i)
.slice(1)
.concat(html.split(/<div[^>]*class="result"/i).slice(1));
for (const block of blocks) {
const titleMatch = block.match(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
if (!titleMatch) continue;
const url = titleMatch[1];
const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
const snippetMatch = block.match(/class="res-desc[^"]*"[^>]*>([\s\S]*?)<\/p>/i)
|| block.match(/class="res-rich[^"]*"[^>]*>([\s\S]*?)<\/div>/i)
|| block.match(/class="res-summary[^"]*"[^>]*>([\s\S]*?)<\/p>/i)
|| block.match(/<dd[^>]*>([\s\S]*?)<\/dd>/i);
const snippetMatch =
block.match(/class="res-desc[^"]*"[^>]*>([\s\S]*?)<\/p>/i) ||
block.match(/class="res-rich[^"]*"[^>]*>([\s\S]*?)<\/div>/i) ||
block.match(/class="res-summary[^"]*"[^>]*>([\s\S]*?)<\/p>/i) ||
block.match(/<dd[^>]*>([\s\S]*?)<\/dd>/i);
const snippet = snippetMatch ? snippetMatch[1].replace(/<[^>]+>/g, '').trim() : '';
if (title && url && !url.includes('so.com')) {
results.push({ title, url, snippet, engine: '360搜索', weight: 75 });
@@ -192,7 +353,7 @@ async function checkReachability(urls: string[], concurrency = 5): Promise<Map<s
function smartSort(results: SearchResult[]): SearchResult[] {
for (const r of results) {
const reachability = r.reachable ? 30 : -20;
const snippetQuality = Math.min(r.snippet.length, 100) / 100 * 20;
const snippetQuality = (Math.min(r.snippet.length, 100) / 100) * 20;
const weightScore = (r.weight / 100) * 50;
r._score = weightScore + reachability + snippetQuality;
}
@@ -223,13 +384,20 @@ function computeRelevance(query: string, result: SearchResult): number {
export class WebSearchTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'web_search',
description: 'Search the web for information. Returns titles, snippets, and URLs. When SearXNG is enabled, uses the configured SearXNG instance; otherwise uses built-in engines (Bing, Baidu, Sogou, 360). Automatically fetches full content for top results.',
description:
'Search the web for information. Returns titles, snippets, and URLs. When SearXNG is enabled, uses the configured SearXNG instance; otherwise uses built-in engines (Bing, Baidu, Sogou, 360). Automatically fetches full content for top results.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query keywords' },
time_range: { type: 'string', description: 'Time filter: day, week, month, year (optional)' },
enhance_snippets: { type: 'boolean', description: 'Auto-enhance short snippets (default true)' },
time_range: {
type: 'string',
description: 'Time filter: day, week, month, year (optional)',
},
enhance_snippets: {
type: 'boolean',
description: 'Auto-enhance short snippets (default true)',
},
},
required: ['query'],
},
@@ -265,7 +433,10 @@ export class WebSearchTool implements IMetonaTool {
? Math.min(8, Math.max(3, searxngConfig.fetch_count > 0 ? searxngConfig.fetch_count : 5))
: 5;
logTool('web_search', `Mode=${useSearXNG ? 'searxng' : 'builtin'}, maxResults=${maxResults}, fetchTop=${fetchTop}`);
logTool(
'web_search',
`Mode=${useSearXNG ? 'searxng' : 'builtin'}, maxResults=${maxResults}, fetchTop=${fetchTop}`,
);
// 缓存检查(key 含模式 + maxResults + fetchTop,避免配置变更后返回旧缓存)
const cacheKey = `${searxngConfig.enabled ? 'searxng' : 'builtin'}:${maxResults}:${fetchTop}:${normalizeUrl(query).toLowerCase()}`;
@@ -338,7 +509,10 @@ export class WebSearchTool implements IMetonaTool {
// 写入缓存
searchCache.set(cacheKey, output);
logTool('web_search', `Completed: ${sorted.length} results, ${fetchedContent.length} fetched, mode=${mode}`);
logTool(
'web_search',
`Completed: ${sorted.length} results, ${fetchedContent.length} fetched, mode=${mode}`,
);
return output;
}
@@ -394,7 +568,7 @@ export class WebSearchTool implements IMetonaTool {
}
}
} else {
const data = await response.json() as { results?: Array<Record<string, unknown>> };
const data = (await response.json()) as { results?: Array<Record<string, unknown>> };
for (const item of data.results ?? []) {
const url = item.url as string;
const title = item.title as string;
@@ -418,7 +592,10 @@ export class WebSearchTool implements IMetonaTool {
}
results.push(...pageResults);
logTool('web_search', `[SearXNG] Page ${page}: +${pageResults.length} (total ${results.length})`);
logTool(
'web_search',
`[SearXNG] Page ${page}: +${pageResults.length} (total ${results.length})`,
);
page++;
}
@@ -440,12 +617,17 @@ export class WebSearchTool implements IMetonaTool {
const searchPromises = ENGINES.map(async (engine) => {
try {
const url = engine.searchUrl(query, timeRange);
const response = await fetchWithTimeout(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
const response = await fetchWithTimeout(
url,
{
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
},
},
}, 8_000);
8_000,
);
if (!response.ok) {
logTool('web_search', `[内置] ${engine.name} HTTP ${response.status}`);
@@ -501,10 +683,10 @@ export class WebSearchTool implements IMetonaTool {
if (enhanced >= maxEnhance) break;
if (r.snippet.length < 30 && r.reachable) {
try {
const fetchResult = await this.webFetchTool.execute(
const fetchResult = (await this.webFetchTool.execute(
{ url: r.url },
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
) as { success: boolean; content?: string };
)) as { success: boolean; content?: string };
if (fetchResult.success && fetchResult.content) {
const text = fetchResult.content.slice(0, 200);
@@ -537,7 +719,10 @@ export class WebSearchTool implements IMetonaTool {
fetchTop: number,
): Promise<Array<{ url: string; title: string; content: string }>> {
// 相关性评分(不过滤,relevance=0 的结果也参与抓取候选)
const withRelevance = results.map((r) => ({ result: r, relevance: computeRelevance(query, r) }));
const withRelevance = results.map((r) => ({
result: r,
relevance: computeRelevance(query, r),
}));
const filtered = withRelevance.length > 0 ? withRelevance : [];
// 确定抓取数量:fetchTop 已在 execute() 中综合了配置面板和工具参数
@@ -554,27 +739,30 @@ export class WebSearchTool implements IMetonaTool {
}
toFetch = shuffled.slice(0, topN);
} else {
toFetch = filtered
.sort((a, b) => b.relevance - a.relevance)
.slice(0, topN);
toFetch = filtered.sort((a, b) => b.relevance - a.relevance).slice(0, topN);
}
const fetched: Array<{ url: string; title: string; content: string }> = [];
const fetchOne = async (item: { result: SearchResult }): Promise<{ url: string; title: string; content: string } | null> => {
const fetchOne = async (item: {
result: SearchResult;
}): Promise<{ url: string; title: string; content: string } | null> => {
try {
// 委托给 WebFetchTool — 享受三阶段回退策略(HTTP + 反爬 + 浏览器渲染)
const fetchResult = await this.webFetchTool.execute(
const fetchResult = (await this.webFetchTool.execute(
{ url: item.result.url },
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
) as { success: boolean; content?: string };
)) as { success: boolean; content?: string };
if (fetchResult.success && fetchResult.content) {
return { url: item.result.url, title: item.result.title, content: fetchResult.content };
}
return null;
} catch (err) {
logTool('web_search', `Auto-fetch failed for ${item.result.url}: ${(err as Error).message}`);
logTool(
'web_search',
`Auto-fetch failed for ${item.result.url}: ${(err as Error).message}`,
);
return null;
}
};
@@ -601,7 +789,9 @@ export class WebSearchTool implements IMetonaTool {
lines.push(`${i + 1}. ${r.title}`);
lines.push(` URL: ${r.url}`);
if (r.snippet) lines.push(` 摘要: ${r.snippet.slice(0, 150)}`);
lines.push(` 来源: ${r.engine}${r.reachable === false ? ' (不可达)' : ''}${r._enhanced ? ' [已增强]' : ''}\n`);
lines.push(
` 来源: ${r.engine}${r.reachable === false ? ' (不可达)' : ''}${r._enhanced ? ' [已增强]' : ''}\n`,
);
});
return lines.join('\n');
}
+2 -5
View File
@@ -29,15 +29,12 @@ export type {
MetonaResponseMeta,
MetonaTokenUsage,
MetonaStreamEvent,
MetonaValidationPayload,
MetonaThinking,
MetonaError,
} from './metona-response';
export {
MetonaFinishReason,
MetonaStreamEventType,
MetonaErrorCode,
} from './metona-response';
export { MetonaFinishReason, MetonaStreamEventType, MetonaErrorCode } from './metona-response';
// ===== 上下文与记忆 =====
export type { MetonaContext, MetonaMemoryItem } from './metona-context';
+19
View File
@@ -92,6 +92,8 @@ export enum MetonaStreamEventType {
ERROR = 'error',
DONE = 'done',
USAGE = 'usage',
/** v0.4.1: 输出验证结果(OutputValidator 检出的疑似幻觉/事实矛盾/敏感信息,不阻断输出) */
VALIDATION = 'validation',
}
export interface MetonaStreamEvent {
@@ -131,10 +133,27 @@ export interface MetonaStreamEvent {
/** ERROR */
error?: MetonaError;
/** VALIDATION — 输出验证结果(v0.4.1: 疑似问题提示,不阻断输出) */
validation?: MetonaValidationPayload;
/** DONE — 终止原因(前端可据此区分正常完成/错误/中断) */
terminationReason?: string;
}
/** v0.4.1: 输出验证事件载荷 — OutputValidator 检出的疑似问题(幻觉/事实矛盾/敏感信息/格式) */
export interface MetonaValidationPayload {
/** 质量分数 0-1(越高越好) */
score: number;
/** 检出的问题列表(仅 warning 及以上级别才推送前端,info 级噪声不推送) */
issues: Array<{
severity: 'error' | 'warning';
/** 问题类型(fact_inconsistency / hallucination / sensitive_* / unsafe / format */
type: string;
/** 人类可读描述 */
message: string;
}>;
}
// ===== 思考内容 =====
export interface MetonaThinking {
+415
View File
@@ -0,0 +1,415 @@
/**
* IPC Agent Handlers — sendMessage 编排链路测试(v0.4.1 测试补齐)
*
* 覆盖 sendMessage 的主编排逻辑:
* 1. 参数校验(无效 sessionId / userMessage → ERROR+DONE 流事件,防止前端 isStreaming 卡死)
* 2. Adapter 加载失败中止
* 3. Prompt 注入阻断(riskScore >= 7
* 4. 成功路径(消息持久化 / 审计 / 记忆固化 / 摘要评估 / Token 统计)
* 5. 引擎异常路径(审计错误 + ERROR 流事件)
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { EventEmitter } from 'events';
// ===== Mock electronipcMain =====
const ipcMainHandleMock = vi.fn();
const ipcMainOnMock = vi.fn();
vi.mock('electron', () => ({
ipcMain: {
handle: (...args: unknown[]) => ipcMainHandleMock(...args),
on: (...args: unknown[]) => ipcMainOnMock(...args),
},
}));
// ===== Mock broadcastipc/context =====
const broadcastMock = vi.fn();
vi.mock('../context', () => ({
broadcast: (...args: unknown[]) => broadcastMock(...args),
}));
import { registerAgentHandlers } from '../agent';
import type { IPCContext } from '../context';
import type { MetonaMessage } from '../../harness/types';
// ===== Mock 依赖工厂 =====
function makeEngineMock(overrides: Record<string, unknown> = {}) {
return {
runStream: vi.fn().mockResolvedValue({
finalAnswer: '这是最终回答',
terminationReason: 'completed',
iterations: [
{
iteration: 1,
state: 'OBSERVING',
startedAt: 1,
completedAt: 2,
thought: {
id: 'thought-1',
content: '本轮思考文本',
reasoningContent: '推理过程',
timestamp: 1,
iteration: 1,
},
toolCalls: [],
toolResults: [],
},
],
totalTokenUsage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 },
durationMs: 1234,
metadata: {},
}),
...overrides,
};
}
function makeCtx(overrides: Record<string, unknown> = {}) {
const engine = makeEngineMock();
const engineManager = new EventEmitter() as EventEmitter & {
getEngine: Mock;
abort: Mock;
waitForAbort: Mock;
};
(engineManager as unknown as { getEngine: Mock }).getEngine = vi.fn(() => engine);
(engineManager as unknown as { abort: Mock }).abort = vi.fn();
(engineManager as unknown as { waitForAbort: Mock }).waitForAbort = vi
.fn()
.mockResolvedValue(true);
const ctx = {
agentEngineManager: engineManager,
sessionRecorder: {
startRecording: vi.fn(),
stopRecording: vi.fn(),
recordContextBuilt: vi.fn(),
recordToolCall: vi.fn(),
recordToolResult: vi.fn(),
recordLLMResponse: vi.fn(),
recordIterationStart: vi.fn(),
recordIterationEnd: vi.fn(),
recordLLMRequest: vi.fn(),
},
configService: { get: vi.fn(() => '') },
sessionService: {
saveMessage: vi.fn(),
getMessages: vi.fn(() => []),
updateTokenUsage: vi.fn(),
},
workspaceService: {
getFiles: vi.fn(() => ({ soul: '# Metona', memory: '# Memory' })),
getPath: vi.fn(() => '/workspace'),
updateMemoryTimestamp: vi.fn(),
},
contextBuilder: {
buildSystemPrompt: vi.fn(() => ({
roleDefinition: 'role',
outputConstraints: 'constraints',
safetyGuidelines: 'safety',
dynamicReminders: 'reminders',
})),
isUsingFallbackRole: vi.fn(() => false),
},
auditService: {
logSessionStart: vi.fn(),
logSessionEnd: vi.fn(),
log: vi.fn(),
},
memoryManager: { search: vi.fn(() => []) },
promptInjectionDefender: {
detect: vi.fn(() => ({
isInjection: false,
riskScore: 0,
findings: [],
recommendation: 'PASS: ok',
})),
},
outputValidator: {
validate: vi.fn().mockResolvedValue({ valid: true, issues: [], score: 1 }),
},
memoryConsolidator: {
consolidate: vi.fn().mockResolvedValue({ appended: 0, entries: [], skipped: 0 }),
isRunning: vi.fn(() => false),
},
sessionSummaryService: {
buildHistoryMessages: vi.fn(() => []),
maybeSummarize: vi.fn().mockResolvedValue(undefined),
},
orchestrator: { abortByParent: vi.fn() },
confirmationHook: { clearPending: vi.fn() },
reloadAdapter: vi.fn(() => true),
...overrides,
};
return { ctx: ctx as unknown as IPCContext, engine, engineManager, ctxRaw: ctx };
}
function getHandler(channel: string): (...args: unknown[]) => Promise<unknown> {
const call = ipcMainHandleMock.mock.calls.find(([ch]) => ch === channel);
if (!call) throw new Error(`IPC handler not registered: ${channel}`);
return call[1] as (...args: unknown[]) => Promise<unknown>;
}
const VALID_MESSAGE: MetonaMessage = {
role: 'user',
content: '你好,请帮我分析这个项目',
timestamp: Date.now(),
};
beforeEach(() => {
ipcMainHandleMock.mockClear();
ipcMainOnMock.mockClear();
broadcastMock.mockClear();
});
describe('agent:sendMessage — 参数校验', () => {
it('无效 sessionId 拒绝并发送 ERROR + DONE 流事件(防止前端 isStreaming 卡死)', async () => {
const { ctx } = makeCtx();
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
const result = await handler(null, VALID_MESSAGE, '');
expect(result).toEqual({ success: false, error: 'Invalid sessionId' });
// ERROR + DONE 两个流事件都应广播
const eventTypes = broadcastMock.mock.calls.map(([, ev]) => (ev as { type: string }).type);
expect(eventTypes).toContain('error');
expect(eventTypes).toContain('done');
});
it('无效 userMessage(非对象 / content 非字符串)拒绝', async () => {
const { ctx } = makeCtx();
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
const result = await handler(null, { content: 123 }, 'sess_1');
expect(result).toEqual({ success: false, error: 'Invalid message format' });
expect(broadcastMock).toHaveBeenCalled();
});
});
describe('agent:sendMessage — 前置检查', () => {
it('Adapter 加载失败时中止并停止录制', async () => {
const { ctx, ctxRaw } = makeCtx({ reloadAdapter: vi.fn(() => false) });
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
const result = await handler(null, VALID_MESSAGE, 'sess_1');
expect((result as { success: boolean }).success).toBe(false);
expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled();
// 不应调用引擎
expect(ctxRaw.agentEngineManager.getEngine).not.toHaveBeenCalled();
});
it('注入风险 riskScore >= 7 时阻断消息', async () => {
const { ctx, ctxRaw } = makeCtx({
promptInjectionDefender: {
detect: vi.fn(() => ({
isInjection: true,
riskScore: 8,
findings: [{ pattern: 'x', matched: 'ignore previous instructions', severity: 'high' }],
recommendation: 'BLOCK: High-risk injection detected',
})),
},
});
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
const result = await handler(null, VALID_MESSAGE, 'sess_1');
expect((result as { success: boolean }).success).toBe(false);
expect((result as { error: string }).error).toContain('blocked by prompt injection defense');
// 用户消息不保存(在注入检测前已保存?—— 现实现:先保存再检测,验证已保存)
expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith(
expect.objectContaining({ role: 'user', sessionId: 'sess_1' }),
);
// 引擎不启动
expect(ctxRaw.agentEngineManager.getEngine).not.toHaveBeenCalled();
});
it('SOUL.md 缺失降级时发送 toast 提示', async () => {
const { ctx } = makeCtx({
contextBuilder: {
buildSystemPrompt: vi.fn(() => ({
roleDefinition: 'fallback',
outputConstraints: 'c',
safetyGuidelines: 's',
})),
isUsingFallbackRole: vi.fn(() => true),
},
});
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
await handler(null, VALID_MESSAGE, 'sess_1');
const toastCall = broadcastMock.mock.calls.find(([ch]) => ch === 'toast:show');
expect(toastCall).toBeDefined();
expect((toastCall![1] as { message: string }).message).toContain('SOUL.md');
});
});
describe('agent:sendMessage — 成功路径', () => {
it('完整编排:保存消息 → 运行引擎 → 持久化 assistant 消息 → 审计 → 异步固化', async () => {
const { ctx, ctxRaw, engine } = makeCtx();
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
const result = await handler(null, VALID_MESSAGE, 'sess_1');
expect(result).toEqual({ success: true });
// 1. 用户消息保存
expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: 'user',
content: VALID_MESSAGE.content,
sessionId: 'sess_1',
}),
);
// 2. 引擎启动(每会话引擎)
expect(ctxRaw.agentEngineManager.getEngine).toHaveBeenCalledWith('sess_1');
expect(engine.runStream).toHaveBeenCalledWith(
VALID_MESSAGE,
'sess_1',
[],
expect.objectContaining({ roleDefinition: 'role' }),
);
// 3. assistant 消息保存(含思考内容)
expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: 'assistant',
content: '本轮思考文本',
reasoningContent: '推理过程',
}),
);
// 4. Token 统计更新
expect(ctxRaw.sessionService.updateTokenUsage).toHaveBeenCalledWith('sess_1', 150);
// 5. MEMORY.md 时间戳更新
expect(ctxRaw.workspaceService.updateMemoryTimestamp).toHaveBeenCalled();
// 6. 审计 + 录制结束
expect(ctxRaw.auditService.logSessionEnd).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: 'sess_1', terminationReason: 'completed' }),
);
expect(ctxRaw.sessionRecorder.stopRecording).toHaveBeenCalled();
// 7. 输出验证执行
expect(ctxRaw.outputValidator.validate).toHaveBeenCalledWith('这是最终回答', expect.anything());
// 8. 摘要评估(异步触发)
await vi.waitFor(() =>
expect(ctxRaw.sessionSummaryService.maybeSummarize).toHaveBeenCalledWith('sess_1'),
);
// 9. 记忆固化(异步触发)
await vi.waitFor(() => expect(ctxRaw.memoryConsolidator.consolidate).toHaveBeenCalled());
});
it('注入相关记忆到 System Prompt 动态区', async () => {
const { ctx, ctxRaw } = makeCtx({
memoryManager: {
search: vi.fn(() => [
{
id: 'm1',
type: 'semantic',
content: '用户偏好深色主题',
importance: 0.9,
createdAt: Date.now(),
score: 0.8,
},
]),
},
});
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
await handler(null, VALID_MESSAGE, 'sess_1');
expect(ctxRaw.memoryManager.search).toHaveBeenCalled();
// 引擎收到的 systemPrompt 应包含记忆块
const prompt = engine_runStreamPrompt(ctxRaw);
expect(prompt.dynamicReminders).toContain('用户偏好深色主题');
});
it('验证发现 warning 级问题时广播 VALIDATION 流事件', async () => {
const { ctx, ctxRaw } = makeCtx({
outputValidator: {
validate: vi.fn().mockResolvedValue({
valid: false,
score: 0.7,
issues: [
{ severity: 'warning', type: 'hallucination', message: 'Path not found in context' },
{ severity: 'info', type: 'format', message: 'noise' },
],
}),
},
});
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
await handler(null, VALID_MESSAGE, 'sess_1');
const validationCall = broadcastMock.mock.calls.find(
([ch, ev]) => ch === 'agent:streamEvent' && (ev as { type: string }).type === 'validation',
);
expect(validationCall).toBeDefined();
const payload = (validationCall![1] as { validation: { issues: unknown[] } }).validation;
// info 级噪声不推送
expect(payload.issues).toHaveLength(1);
expect(ctxRaw.outputValidator.validate).toHaveBeenCalled();
});
});
describe('agent:sendMessage — 异常路径', () => {
it('引擎抛错时返回失败并记录审计错误', async () => {
const engine = makeEngineMock({
runStream: vi.fn().mockRejectedValue(new Error('LLM connection failed')),
});
const engineManager = new EventEmitter() as EventEmitter & {
getEngine: Mock;
abort: Mock;
waitForAbort: Mock;
};
(engineManager as unknown as { getEngine: Mock }).getEngine = vi.fn(() => engine);
(engineManager as unknown as { abort: Mock }).abort = vi.fn();
(engineManager as unknown as { waitForAbort: Mock }).waitForAbort = vi
.fn()
.mockResolvedValue(true);
const { ctx, ctxRaw } = makeCtx({
agentEngineManager: engineManager,
});
registerAgentHandlers(ctx);
const handler = getHandler('agent:sendMessage');
const result = await handler(null, VALID_MESSAGE, 'sess_1');
expect((result as { success: boolean }).success).toBe(false);
expect((result as { error: string }).error).toBe('LLM connection failed');
// 审计记录错误
expect(ctxRaw.auditService.log).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: 'sess_1', eventType: 'error', outcome: 'error' }),
);
// ERROR 流事件广播
const errorCall = broadcastMock.mock.calls.find(
([ch, ev]) => ch === 'agent:streamEvent' && (ev as { type: string }).type === 'error',
);
expect(errorCall).toBeDefined();
});
});
describe('agent:abortSession — 中断编排', () => {
it('联动 SubAgent 中断 + 引擎中断 + 清理确认', async () => {
const { ctx, ctxRaw } = makeCtx();
registerAgentHandlers(ctx);
const handler = getHandler('agent:abortSession');
const result = await handler(null, 'sess_1');
expect(result).toEqual({ success: true });
expect(ctxRaw.orchestrator.abortByParent).toHaveBeenCalledWith('sess_1');
expect(ctxRaw.confirmationHook.clearPending).toHaveBeenCalled();
expect(ctxRaw.auditService.log).toHaveBeenCalledWith(
expect.objectContaining({ eventType: 'session_end', outcome: 'denied' }),
);
});
});
/** 从 runStream 调用参数中提取 systemPrompt */
function engine_runStreamPrompt(ctxRaw: Record<string, unknown>): {
roleDefinition: string;
dynamicReminders?: string;
} {
const engine = (ctxRaw.agentEngineManager as unknown as { getEngine: Mock }).getEngine() as {
runStream: Mock;
};
return engine.runStream.mock.calls[0][3];
}
+462 -345
View File
@@ -22,7 +22,10 @@ import log from 'electron-log';
/** 单会话的 text_delta 节流状态 */
interface ThrottleState {
buffer: string;
lastEventMeta: Pick<MetonaStreamEvent, 'requestId' | 'sessionId' | 'iteration' | 'seq' | 'timestamp' | 'runId'> | null;
lastEventMeta: Pick<
MetonaStreamEvent,
'requestId' | 'sessionId' | 'iteration' | 'seq' | 'timestamp' | 'runId'
> | null;
flushTimer: ReturnType<typeof setTimeout> | null;
}
@@ -37,10 +40,20 @@ interface IterationTrace {
export function registerAgentHandlers(ctx: IPCContext): void {
const {
agentEngineManager, sessionRecorder, configService, sessionService,
workspaceService, contextBuilder, auditService, memoryManager,
promptInjectionDefender, outputValidator, memoryConsolidator,
sessionSummaryService, orchestrator, confirmationHook,
agentEngineManager,
sessionRecorder,
configService,
sessionService,
workspaceService,
contextBuilder,
auditService,
memoryManager,
promptInjectionDefender,
outputValidator,
memoryConsolidator,
sessionSummaryService,
orchestrator,
confirmationHook,
} = ctx;
// ===== 常驻事件管道:text_delta 按会话节流(F8 =====
@@ -88,7 +101,9 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// F8: text_delta 聚合,其他事件立即转发(先 flush 保证顺序)
if (event.type === MetonaStreamEventType.TEXT_DELTA && event.delta) {
const st = throttleStates.get(sessionId) ?? {
buffer: '', lastEventMeta: null, flushTimer: null,
buffer: '',
lastEventMeta: null,
flushTimer: null,
};
throttleStates.set(sessionId, st);
if (st.buffer === '') {
@@ -101,7 +116,12 @@ export function registerAgentHandlers(ctx: IPCContext): void {
runId: event.runId,
};
} else if (st.lastEventMeta) {
st.lastEventMeta = { ...st.lastEventMeta, seq: event.seq, timestamp: event.timestamp, runId: event.runId };
st.lastEventMeta = {
...st.lastEventMeta,
seq: event.seq,
timestamp: event.timestamp,
runId: event.runId,
};
}
st.buffer += event.delta;
if (st.flushTimer === null) {
@@ -133,9 +153,10 @@ export function registerAgentHandlers(ctx: IPCContext): void {
break;
case MetonaStreamEventType.TOOL_RESULT:
if (event.toolResult) {
const resultPreview = typeof event.toolResult.result === 'string'
? event.toolResult.result
: JSON.stringify(event.toolResult.result);
const resultPreview =
typeof event.toolResult.result === 'string'
? event.toolResult.result
: JSON.stringify(event.toolResult.result);
sessionRecorder.recordToolResult({
sessionId,
iteration: event.iteration,
@@ -167,21 +188,67 @@ export function registerAgentHandlers(ctx: IPCContext): void {
});
// ===== 常驻监听:状态变化(广播 + TRACE 迭代录制) =====
agentEngineManager.on('stateChange', (data: {
previous?: string; current?: string; state?: string;
sessionId?: string; iteration?: number; runId?: string;
}) => {
if (data.previous) log.info(`[AGENT] State: ${data.previous}${data.current}`);
broadcast('agent:stateChange', data);
agentEngineManager.on(
'stateChange',
(data: {
previous?: string;
current?: string;
state?: string;
sessionId?: string;
iteration?: number;
runId?: string;
}) => {
if (data.previous) log.info(`[AGENT] State: ${data.previous}${data.current}`);
broadcast('agent:stateChange', data);
const sessionId = data.sessionId;
if (!sessionId || data.iteration == null) return;
const stateValue = data.state ?? data.current ?? '';
const trace = iterationTraces.get(sessionId);
const sessionId = data.sessionId;
if (!sessionId || data.iteration == null) return;
const stateValue = data.state ?? data.current ?? '';
const trace = iterationTraces.get(sessionId);
// THINKING 且迭代号变化 → 新迭代开始(关闭上一迭代)
if (stateValue === 'THINKING' && (!trace || trace.iteration !== data.iteration)) {
if (trace && !trace.responded) {
// THINKING 且迭代号变化 → 新迭代开始(关闭上一迭代)
if (stateValue === 'THINKING' && (!trace || trace.iteration !== data.iteration)) {
if (trace && !trace.responded) {
sessionRecorder.recordLLMResponse({
sessionId,
iteration: trace.iteration,
content: trace.text,
finishReason: 'stop',
tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 },
});
}
if (trace) {
sessionRecorder.recordIterationEnd(sessionId, {
iteration: trace.iteration,
durationMs: Date.now() - trace.startedAt,
});
}
iterationTraces.set(sessionId, {
iteration: data.iteration,
startedAt: Date.now(),
text: '',
responded: false,
});
sessionRecorder.recordIterationStart(sessionId, data.iteration);
const provider = configService.get<string>('llm.provider') ?? '';
const model = configService.get<string>('llm.model') ?? '';
sessionRecorder.recordLLMRequest({
sessionId,
iteration: data.iteration,
provider,
model,
messageCount: data.iteration + 1,
});
}
// PARSING → 本轮流式结束,记录 llm_response
if (
stateValue === 'PARSING' &&
trace &&
trace.iteration === data.iteration &&
!trace.responded
) {
trace.responded = true;
sessionRecorder.recordLLMResponse({
sessionId,
iteration: trace.iteration,
@@ -190,76 +257,48 @@ export function registerAgentHandlers(ctx: IPCContext): void {
tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 },
});
}
if (trace) {
sessionRecorder.recordIterationEnd(sessionId, {
iteration: trace.iteration,
durationMs: Date.now() - trace.startedAt,
});
}
iterationTraces.set(sessionId, {
iteration: data.iteration,
startedAt: Date.now(),
text: '',
responded: false,
});
sessionRecorder.recordIterationStart(sessionId, data.iteration);
const provider = configService.get<string>('llm.provider') ?? '';
const model = configService.get<string>('llm.model') ?? '';
sessionRecorder.recordLLMRequest({
sessionId,
iteration: data.iteration,
provider,
model,
messageCount: data.iteration + 1,
});
}
// PARSING → 本轮流式结束,记录 llm_response
if (stateValue === 'PARSING' && trace && trace.iteration === data.iteration && !trace.responded) {
trace.responded = true;
sessionRecorder.recordLLMResponse({
sessionId,
iteration: trace.iteration,
content: trace.text,
finishReason: 'stop',
tokenUsage: trace.usage ?? { input: 0, output: 0, total: 0 },
});
}
// TERMINATED → 补记最终迭代的 iteration_end(正常流程只在下一轮 THINKING 补记,
// 最终轮无后续迭代,需在此补齐 TRACE 完整性)+ 兜底清理会话管道状态
if (stateValue === 'TERMINATED') {
if (trace) {
sessionRecorder.recordIterationEnd(sessionId, {
iteration: trace.iteration,
durationMs: Date.now() - trace.startedAt,
});
// TERMINATED → 补记最终迭代的 iteration_end(正常流程只在下一轮 THINKING 补记,
// 最终轮无后续迭代,需在此补齐 TRACE 完整性)+ 兜底清理会话管道状态
if (stateValue === 'TERMINATED') {
if (trace) {
sessionRecorder.recordIterationEnd(sessionId, {
iteration: trace.iteration,
durationMs: Date.now() - trace.startedAt,
});
}
cleanupSessionState(sessionId);
}
cleanupSessionState(sessionId);
}
});
},
);
// ===== 常驻监听:上下文压缩(toast + streamEvent 通知) =====
agentEngineManager.on('compressed', (data: {
sessionId?: string; iteration?: number; originalTokens?: number; compressedTokens?: number;
}) => {
const savedTokens = Math.max(0, (data.originalTokens ?? 0) - (data.compressedTokens ?? 0));
// toast 通知用户压缩已发生
broadcast('toast:show', {
type: 'info',
message: `上下文压缩: ${data.originalTokens ?? '?'}${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens}`,
});
// 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store
broadcast('agent:streamEvent', {
type: 'compressed',
sessionId: data.sessionId ?? '',
iteration: data.iteration ?? 0,
originalTokens: data.originalTokens,
compressedTokens: data.compressedTokens,
savedTokens,
timestamp: Date.now(),
});
});
agentEngineManager.on(
'compressed',
(data: {
sessionId?: string;
iteration?: number;
originalTokens?: number;
compressedTokens?: number;
}) => {
const savedTokens = Math.max(0, (data.originalTokens ?? 0) - (data.compressedTokens ?? 0));
// toast 通知用户压缩已发生
broadcast('toast:show', {
type: 'info',
message: `上下文压缩: ${data.originalTokens ?? '?'}${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens}`,
});
// 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store
broadcast('agent:streamEvent', {
type: 'compressed',
sessionId: data.sessionId ?? '',
iteration: data.iteration ?? 0,
originalTokens: data.originalTokens,
compressedTokens: data.compressedTokens,
savedTokens,
timestamp: Date.now(),
});
},
);
// ===== 常驻监听:死循环检测(toast 警告) =====
agentEngineManager.on('deadLoop', (data: { iteration?: number; sessionId?: string }) => {
@@ -271,300 +310,378 @@ export function registerAgentHandlers(ctx: IPCContext): void {
});
// ===== 常驻监听:Provider 故障转移(P1,通知前端 + toast =====
agentEngineManager.on('providerSwitched', (data: {
from?: string; to?: string; reason?: string; sessionId?: string;
}) => {
broadcast('agent:providerSwitched', {
from: data.from,
to: data.to,
reason: data.reason ?? 'failover',
sessionId: data.sessionId ?? '',
});
broadcast('toast:show', {
type: 'warning',
message: `Provider 故障转移: ${data.from ?? '?'}${data.to ?? '?'}(主 Provider 请求失败)`,
});
});
agentEngineManager.on(
'providerSwitched',
(data: { from?: string; to?: string; reason?: string; sessionId?: string }) => {
broadcast('agent:providerSwitched', {
from: data.from,
to: data.to,
reason: data.reason ?? 'failover',
sessionId: data.sessionId ?? '',
});
broadcast('toast:show', {
type: 'warning',
message: `Provider 故障转移: ${data.from ?? '?'}${data.to ?? '?'}(主 Provider 请求失败)`,
});
},
);
// ===== Agent 消息发送 =====
ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => {
// M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常
// P1-5 修复: 校验失败时也发 ERROR+DONE 流事件,防止 isStreaming 永久卡死
const sendErrorEvent = (message: string, sid: string): void => {
const errorEvent: MetonaStreamEvent = {
type: MetonaStreamEventType.ERROR,
requestId: '', sessionId: sid, iteration: 0, seq: 0, timestamp: Date.now(),
error: { code: MetonaErrorCode.UNKNOWN, message, retryable: false },
ipcMain.handle(
'agent:sendMessage',
async (_event, userMessage: MetonaMessage, sessionId: string) => {
// M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常
// P1-5 修复: 校验失败时也发 ERROR+DONE 流事件,防止 isStreaming 永久卡死
const sendErrorEvent = (message: string, sid: string): void => {
const errorEvent: MetonaStreamEvent = {
type: MetonaStreamEventType.ERROR,
requestId: '',
sessionId: sid,
iteration: 0,
seq: 0,
timestamp: Date.now(),
error: { code: MetonaErrorCode.UNKNOWN, message, retryable: false },
};
broadcast('agent:streamEvent', errorEvent);
broadcast('agent:streamEvent', { ...errorEvent, type: MetonaStreamEventType.DONE });
};
broadcast('agent:streamEvent', errorEvent);
broadcast('agent:streamEvent', { ...errorEvent, type: MetonaStreamEventType.DONE });
};
if (!sessionId || typeof sessionId !== 'string') {
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
sendErrorEvent('无效的会话 ID', sessionId ?? '');
return { success: false, error: 'Invalid sessionId' };
}
if (!userMessage || typeof userMessage !== 'object' || typeof userMessage.content !== 'string') {
log.warn('[AGENT] sendMessage rejected: invalid userMessage');
sendErrorEvent('无效的消息格式', sessionId);
return { success: false, error: 'Invalid message format' };
}
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
if (!ctx.reloadAdapter()) {
const errorMsg = 'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)';
log.error('[AGENT]', errorMsg);
sendErrorEvent(errorMsg, sessionId);
sessionRecorder.stopRecording(sessionId, { totalIterations: 0, totalTokens: 0, durationMs: 0, terminationReason: 'error' });
return { success: false, error: errorMsg };
}
// TRACE 层:开始录制 / TOOL 层:记录会话开始
sessionRecorder.startRecording(sessionId);
auditService.logSessionStart(sessionId);
// 保存用户消息到数据库
sessionService.saveMessage({
sessionId,
role: 'user',
content: userMessage.content,
attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments,
});
// P2-11: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文]
const history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1);
// 从工作空间文件构建 System Prompt
const workspaceFiles = workspaceService.getFiles();
const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles, workspaceService.getPath());
// v0.3.18 修复: SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户
if (contextBuilder.isUsingFallbackRole()) {
broadcast('toast:show', {
type: 'info',
message: '未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
});
}
// 检索与用户消息相关的记忆,注入到 System Prompt 动态区
try {
const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 });
if (memories.length > 0) {
const memorySection = memories.map((m, i) =>
`[${i + 1}] (${m.type}, 重要度: ${m.importance.toFixed(1)}) ${m.content.slice(0, 200)}`,
).join('\n');
const memoryBlock = `## Relevant Memories (Retrieved)\n${memorySection}`;
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
? `${systemPrompt.dynamicReminders}\n\n---\n\n${memoryBlock}`
: memoryBlock;
log.debug(`[AGENT] Injected ${memories.length} memories into system prompt`);
if (!sessionId || typeof sessionId !== 'string') {
log.warn('[AGENT] sendMessage rejected: invalid sessionId');
sendErrorEvent('无效的会话 ID', sessionId ?? '');
return { success: false, error: 'Invalid sessionId' };
}
} catch (err) {
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
}
if (
!userMessage ||
typeof userMessage !== 'object' ||
typeof userMessage.content !== 'string'
) {
log.warn('[AGENT] sendMessage rejected: invalid userMessage');
sendErrorEvent('无效的消息格式', sessionId);
return { success: false, error: 'Invalid message format' };
}
log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80));
// 附件提示注入:用户直接上传的文件/图片,避免 LLM 误以为需要在工作空间查找
const attachments = (userMessage as MetonaMessage & { attachments?: Array<{ name: string; type: string }> }).attachments;
if (Array.isArray(attachments) && attachments.length > 0) {
const attachmentList = attachments.map((att, i) => {
const typeLabel = att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
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 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');
const attachmentBlock = `## 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).`;
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
? `${systemPrompt.dynamicReminders}\n\n---\n\n${attachmentBlock}`
: attachmentBlock;
log.debug(`[AGENT] Injected ${attachments.length} attachment hints into system prompt`);
}
try {
// 提示注入检测(安全模块)
const injectionResult = promptInjectionDefender.detect(userMessage.content);
if (injectionResult.riskScore >= 7) {
log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings);
sendErrorEvent(`Message blocked by prompt injection defense: ${injectionResult.recommendation}`, sessionId);
// 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送)
if (!ctx.reloadAdapter()) {
const errorMsg =
'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)';
log.error('[AGENT]', errorMsg);
sendErrorEvent(errorMsg, sessionId);
sessionRecorder.stopRecording(sessionId, {
totalIterations: 0, totalTokens: 0, durationMs: 0, terminationReason: 'error',
totalIterations: 0,
totalTokens: 0,
durationMs: 0,
terminationReason: 'error',
});
return { success: false, error: 'Message blocked by prompt injection defense' };
}
if (injectionResult.riskScore >= 4) {
log.warn('[PromptInjectionDefender] Suspicious patterns detected:', injectionResult.findings);
return { success: false, error: errorMsg };
}
// TRACE 层:记录上下文构建
sessionRecorder.recordContextBuilt(sessionId, {
tokenCount: estimateMessagesTokens(history),
usageRatio: 0,
// TRACE 层:开始录制 / TOOL 层:记录会话开始
sessionRecorder.startRecording(sessionId);
auditService.logSessionStart(sessionId);
// 保存用户消息到数据库
sessionService.saveMessage({
sessionId,
role: 'user',
content: userMessage.content,
attachments: (userMessage as MetonaMessage & { attachments?: unknown[] }).attachments,
});
// 启动 Agent LoopP2-10: 每会话独立引擎)
const engine = agentEngineManager.getEngine(sessionId);
const output = await engine.runStream(userMessage, sessionId, history, systemPrompt);
// P2-11: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文]
const history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1);
// 输出验证(不阻塞响应,仅记录警告)
// v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测
try {
const toolResults = output.iterations
.flatMap((step) => step.toolResults ?? [])
.map((r) => (typeof r.result === 'string' ? r.result : JSON.stringify(r.result)));
const context = [...history, { role: 'user', content: userMessage.content }]
.map((m) => `${m.role}: ${m.content}`).join('\n');
const validation = await outputValidator.validate(output.finalAnswer, {
toolResults: toolResults.length > 0 ? toolResults : undefined,
context,
// 从工作空间文件构建 System Prompt
const workspaceFiles = workspaceService.getFiles();
const systemPrompt = contextBuilder.buildSystemPrompt(
workspaceFiles,
workspaceService.getPath(),
);
// v0.3.18 修复: SOUL.md 为空或不存在时降级到默认身份,向前端发 toast 提示用户
if (contextBuilder.isUsingFallbackRole()) {
broadcast('toast:show', {
type: 'info',
message:
'未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格',
});
if (!validation.valid || validation.issues.length > 0) {
log.warn('[OutputValidator] Validation issues:', validation.issues);
}
log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`);
} catch (err) {
log.error('[OutputValidator] Validation failed:', err);
}
// 保存每轮迭代的 assistant 消息到数据库(含思考内容和工具调用)
for (const step of output.iterations) {
if (!step.thought) continue;
// 检索与用户消息相关的记忆,注入到 System Prompt 动态区
try {
const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 });
if (memories.length > 0) {
const memorySection = memories
.map(
(m, i) =>
`[${i + 1}] (${m.type}, 重要度: ${m.importance.toFixed(1)}) ${m.content.slice(0, 200)}`,
)
.join('\n');
const memoryBlock = `## Relevant Memories (Retrieved)\n${memorySection}`;
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
? `${systemPrompt.dynamicReminders}\n\n---\n\n${memoryBlock}`
: memoryBlock;
log.debug(`[AGENT] Injected ${memories.length} memories into system prompt`);
}
} catch (err) {
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
}
const toolCallsWithResults = step.toolCalls?.map((tc) => {
const result = step.toolResults?.find((r) => r.toolCallId === tc.id);
return {
id: tc.id,
name: tc.name,
args: tc.args,
status: result?.success ? 'success' as const : 'error' as const,
result: result?.result,
durationMs: result?.durationMs,
error: result?.error,
};
});
// 附件提示注入:用户直接上传的文件/图片,避免 LLM 误以为需要在工作空间查找
const attachments = (
userMessage as MetonaMessage & { attachments?: Array<{ name: string; type: string }> }
).attachments;
if (Array.isArray(attachments) && attachments.length > 0) {
const attachmentList = attachments
.map((att, i) => {
const typeLabel =
att.type === 'image' ? 'image' : att.type === 'text' ? 'text file' : 'file';
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 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');
// 只有当有内容、思考内容或工具调用时才保存
if (step.thought.content || step.thought.reasoningContent || toolCallsWithResults?.length) {
// C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串)
const assistantContent = (toolCallsWithResults?.length && !step.thought.content)
? null
: step.thought.content;
sessionService.saveMessage({
const attachmentBlock = `## 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).`;
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
? `${systemPrompt.dynamicReminders}\n\n---\n\n${attachmentBlock}`
: attachmentBlock;
log.debug(`[AGENT] Injected ${attachments.length} attachment hints into system prompt`);
}
try {
// 提示注入检测(安全模块)
const injectionResult = promptInjectionDefender.detect(userMessage.content);
if (injectionResult.riskScore >= 7) {
log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings);
sendErrorEvent(
`Message blocked by prompt injection defense: ${injectionResult.recommendation}`,
sessionId,
role: 'assistant',
content: assistantContent,
reasoningContent: step.thought.reasoningContent || undefined,
toolCalls: toolCallsWithResults,
iteration: step.iteration,
);
sessionRecorder.stopRecording(sessionId, {
totalIterations: 0,
totalTokens: 0,
durationMs: 0,
terminationReason: 'error',
});
return { success: false, error: 'Message blocked by prompt injection defense' };
}
if (injectionResult.riskScore >= 4) {
log.warn(
'[PromptInjectionDefender] Suspicious patterns detected:',
injectionResult.findings,
);
}
// v0.3.0 修复: 保存 tool 结果消息到数据库
// OpenAI 兼容 API 要求 assistant 消息有 tool_calls 时,后续必须有对应的 tool 结果消息
if (step.toolResults) {
for (const result of step.toolResults) {
const resultContent = typeof result.result === 'string'
? result.result
: JSON.stringify(result.result);
// TRACE 层:记录上下文构建
sessionRecorder.recordContextBuilt(sessionId, {
tokenCount: estimateMessagesTokens(history),
usageRatio: 0,
});
// 启动 Agent LoopP2-10: 每会话独立引擎)
const engine = agentEngineManager.getEngine(sessionId);
const output = await engine.runStream(userMessage, sessionId, history, systemPrompt);
// 输出验证(不阻塞响应,仅记录警告)
// v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测
// v0.4.1: warning 及以上级别的 issue 通过 VALIDATION 流事件推送前端展示(此前仅写日志,用户不可感知)
try {
const toolResults = output.iterations
.flatMap((step) => step.toolResults ?? [])
.map((r) => (typeof r.result === 'string' ? r.result : JSON.stringify(r.result)));
const context = [...history, { role: 'user', content: userMessage.content }]
.map((m) => `${m.role}: ${m.content}`)
.join('\n');
const validation = await outputValidator.validate(output.finalAnswer, {
toolResults: toolResults.length > 0 ? toolResults : undefined,
context,
});
if (!validation.valid || validation.issues.length > 0) {
log.warn('[OutputValidator] Validation issues:', validation.issues);
}
log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`);
// v0.4.1: 推送验证结果到前端 — 只推送 warning/error 级(info 级为噪声)
const visibleIssues = validation.issues
.filter((i) => i.severity === 'warning' || i.severity === 'error')
.slice(0, 5);
if (visibleIssues.length > 0) {
broadcast('agent:streamEvent', {
type: MetonaStreamEventType.VALIDATION,
requestId: '',
sessionId,
iteration: output.iterations.length,
seq: 0,
timestamp: Date.now(),
validation: {
score: validation.score,
issues: visibleIssues.map((i) => ({
severity: i.severity,
type: i.type,
message: i.message,
})),
},
} satisfies MetonaStreamEvent);
}
} catch (err) {
log.error('[OutputValidator] Validation failed:', err);
}
// 保存每轮迭代的 assistant 消息到数据库(含思考内容和工具调用)
for (const step of output.iterations) {
if (!step.thought) continue;
const toolCallsWithResults = step.toolCalls?.map((tc) => {
const result = step.toolResults?.find((r) => r.toolCallId === tc.id);
return {
id: tc.id,
name: tc.name,
args: tc.args,
status: result?.success ? ('success' as const) : ('error' as const),
result: result?.result,
durationMs: result?.durationMs,
error: result?.error,
};
});
// 只有当有内容、思考内容或工具调用时才保存
if (
step.thought.content ||
step.thought.reasoningContent ||
toolCallsWithResults?.length
) {
// C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串)
const assistantContent =
toolCallsWithResults?.length && !step.thought.content ? null : step.thought.content;
sessionService.saveMessage({
sessionId,
role: 'tool',
content: result.error ?? resultContent,
toolResult: result,
role: 'assistant',
content: assistantContent,
reasoningContent: step.thought.reasoningContent || undefined,
toolCalls: toolCallsWithResults,
iteration: step.iteration,
});
}
}
}
// 更新 Token 统计
if (output.totalTokenUsage.totalTokens > 0) {
sessionService.updateTokenUsage(sessionId, output.totalTokenUsage.totalTokens);
}
// 更新 MEMORY.md 时间戳
workspaceService.updateMemoryTimestamp();
// 会话结束:AI 判断本次对话有哪些重要内容需要持久化到 MEMORY.md
// 异步执行,不阻塞主流程返回;失败仅记录日志
memoryConsolidator
.consolidate(userMessage.content, output.finalAnswer, output.iterations)
.then((result) => {
if (result.appended > 0) {
log.info(`[AGENT] Memory consolidated: ${result.appended} entries appended to MEMORY.md`);
broadcast('toast:show', {
type: 'info',
message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`,
});
// v0.3.0 修复: 保存 tool 结果消息到数据库
// OpenAI 兼容 API 要求 assistant 消息有 tool_calls 时,后续必须有对应的 tool 结果消息
if (step.toolResults) {
for (const result of step.toolResults) {
const resultContent =
typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
sessionService.saveMessage({
sessionId,
role: 'tool',
content: result.error ?? resultContent,
toolResult: result,
iteration: step.iteration,
});
}
}
})
.catch((err) => {
log.warn('[AGENT] Memory consolidation failed:', err);
}
// 更新 Token 统计
if (output.totalTokenUsage.totalTokens > 0) {
sessionService.updateTokenUsage(sessionId, output.totalTokenUsage.totalTokens);
}
// 更新 MEMORY.md 时间戳
workspaceService.updateMemoryTimestamp();
// 会话结束:AI 判断本次对话有哪些重要内容需要持久化到 MEMORY.md
// 异步执行,不阻塞主流程返回;失败仅记录日志
memoryConsolidator
.consolidate(userMessage.content, output.finalAnswer, output.iterations)
.then((result) => {
if (result.appended > 0) {
log.info(
`[AGENT] Memory consolidated: ${result.appended} entries appended to MEMORY.md`,
);
broadcast('toast:show', {
type: 'info',
message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`,
});
}
})
.catch((err) => {
log.warn('[AGENT] Memory consolidation failed:', err);
});
// TOOL 层:记录会话结束 / TRACE 层:停止录制
auditService.logSessionEnd({
sessionId,
totalIterations: output.iterations.length,
totalTokens: output.totalTokenUsage.totalTokens,
durationMs: output.durationMs,
terminationReason: output.terminationReason,
});
sessionRecorder.stopRecording(sessionId, {
totalIterations: output.iterations.length,
totalTokens: output.totalTokenUsage.totalTokens,
durationMs: output.durationMs,
terminationReason: output.terminationReason,
});
// TOOL 层:记录会话结束 / TRACE 层:停止录制
auditService.logSessionEnd({
sessionId,
totalIterations: output.iterations.length,
totalTokens: output.totalTokenUsage.totalTokens,
durationMs: output.durationMs,
terminationReason: output.terminationReason,
});
sessionRecorder.stopRecording(sessionId, {
totalIterations: output.iterations.length,
totalTokens: output.totalTokenUsage.totalTokens,
durationMs: output.durationMs,
terminationReason: output.terminationReason,
});
// P2-11: 会话结束后评估滚动摘要(fire-and-forget,失败仅记录)
sessionSummaryService.maybeSummarize(sessionId).catch((err) => {
log.warn('[AGENT] Session summary generation failed:', err);
});
// P2-11: 会话结束后评估滚动摘要(fire-and-forget,失败仅记录)
sessionSummaryService.maybeSummarize(sessionId).catch((err) => {
log.warn('[AGENT] Session summary generation failed:', err);
});
log.info(
`[AGENT] Completed: ${output.terminationReason}, ${output.iterations.length} iterations, ${output.durationMs}ms`,
);
log.info(`[AGENT] Completed: ${output.terminationReason}, ${output.iterations.length} iterations, ${output.durationMs}ms`);
return { success: true };
} catch (error) {
log.error('[AGENT] Error:', error);
return { success: true };
} catch (error) {
log.error('[AGENT] Error:', error);
// TOOL 层:记录错误
auditService.log({
sessionId,
eventType: 'error',
actor: 'agent',
target: 'agent_loop',
details: { error: (error as Error).message },
outcome: 'error',
});
// TOOL 层:记录错误
auditService.log({
sessionId,
eventType: 'error',
actor: 'agent',
target: 'agent_loop',
details: { error: (error as Error).message },
outcome: 'error',
});
// TRACE 层:停止录制
sessionRecorder.stopRecording(sessionId, {
totalIterations: 0,
totalTokens: 0,
durationMs: 0,
terminationReason: 'error',
});
// TRACE 层:停止录制
sessionRecorder.stopRecording(sessionId, {
totalIterations: 0, totalTokens: 0, durationMs: 0, terminationReason: 'error',
});
// 发送错误事件到 UI
const metonaError: MetonaError = {
code: MetonaErrorCode.UNKNOWN,
message: (error as Error).message,
retryable: false,
};
broadcast('agent:streamEvent', {
type: MetonaStreamEventType.ERROR,
requestId: '',
sessionId,
iteration: 0,
seq: 0,
timestamp: Date.now(),
error: metonaError,
} satisfies MetonaStreamEvent);
// 发送错误事件到 UI
const metonaError: MetonaError = {
code: MetonaErrorCode.UNKNOWN,
message: (error as Error).message,
retryable: false,
};
broadcast('agent:streamEvent', {
type: MetonaStreamEventType.ERROR,
requestId: '', sessionId, iteration: 0, seq: 0, timestamp: Date.now(),
error: metonaError,
} satisfies MetonaStreamEvent);
return { success: false, error: (error as Error).message };
}
});
return { success: false, error: (error as Error).message };
}
},
);
// ===== 中断会话 =====
+59 -40
View File
@@ -13,46 +13,65 @@ export function registerMCPHandlers(ctx: IPCContext): void {
return mcpManager.getServerStates();
});
ipcMain.handle('mcp:addServer', async (_event, config: { name: string; transport: string; command?: string; args?: string[]; url?: string }) => {
// M-38 修复: 完整参数校验,防止字段缺失或类型不符导致异常行为
if (!config || typeof config !== 'object') {
return { success: false, error: 'Invalid config' };
}
if (typeof config.name !== 'string' || !config.name.trim()) {
return { success: false, error: 'Server name is required' };
}
// M-5 修复: transport 运行时校验(替代 as 'stdio' | 'sse' 断言)
if (config.transport !== 'stdio' && config.transport !== 'sse') {
return { success: false, error: `Invalid transport: ${config.transport}. Must be 'stdio' or 'sse'` };
}
// stdio 类型必须有 command
if (config.transport === 'stdio' && (typeof config.command !== 'string' || !config.command.trim())) {
return { success: false, error: 'command is required for stdio transport' };
}
// sse 类型必须有合法 url
if (config.transport === 'sse') {
if (typeof config.url !== 'string' || !config.url.trim()) {
return { success: false, error: 'url is required for sse transport' };
ipcMain.handle(
'mcp:addServer',
async (
_event,
config: { name: string; transport: string; command?: string; args?: string[]; url?: string },
) => {
// M-38 修复: 完整参数校验,防止字段缺失或类型不符导致异常行为
if (!config || typeof config !== 'object') {
return { success: false, error: 'Invalid config' };
}
try { new URL(config.url); } catch {
return { success: false, error: 'Invalid url format' };
if (typeof config.name !== 'string' || !config.name.trim()) {
return { success: false, error: 'Server name is required' };
}
}
try {
await mcpManager.addServer({
name: config.name,
transport: config.transport, // 已校验,无需断言
command: config.command,
args: config.args,
url: config.url,
enabled: true,
});
log.info(`MCP server added: ${config.name}`);
return { success: true };
} catch (error) {
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
}
});
// M-5 修复: transport 运行时校验(替代 as 断言)
// v0.4.1: 新增 'streamable-http' 传输方式
if (
config.transport !== 'stdio' &&
config.transport !== 'sse' &&
config.transport !== 'streamable-http'
) {
return {
success: false,
error: `Invalid transport: ${config.transport}. Must be 'stdio', 'sse', or 'streamable-http'`,
};
}
// stdio 类型必须有 command
if (
config.transport === 'stdio' &&
(typeof config.command !== 'string' || !config.command.trim())
) {
return { success: false, error: 'command is required for stdio transport' };
}
// sse / streamable-http 类型必须有合法 url
if (config.transport === 'sse' || config.transport === 'streamable-http') {
if (typeof config.url !== 'string' || !config.url.trim()) {
return { success: false, error: `url is required for ${config.transport} transport` };
}
try {
new URL(config.url);
} catch {
return { success: false, error: 'Invalid url format' };
}
}
try {
await mcpManager.addServer({
name: config.name,
transport: config.transport, // 已校验,无需断言
command: config.command,
args: config.args,
url: config.url,
enabled: true,
});
log.info(`MCP server added: ${config.name}`);
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
);
ipcMain.handle('mcp:removeServer', async (_event, name: string) => {
// M-38 修复: name 校验
@@ -63,7 +82,7 @@ export function registerMCPHandlers(ctx: IPCContext): void {
await mcpManager.removeServer(name);
return { success: true };
} catch (error) {
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
});
@@ -79,7 +98,7 @@ export function registerMCPHandlers(ctx: IPCContext): void {
await mcpManager.toggleServer(name, enabled);
return { success: true };
} catch (error) {
return { success: false, error: (error instanceof Error ? error.message : String(error)) };
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
});
}
+31 -4
View File
@@ -57,7 +57,12 @@ export function registerToolHandlers(ctx: IPCContext): void {
log.warn('[IPC] tool:confirmationResponse rejected: invalid data');
return;
}
const req = data as { toolCallId?: unknown; approved?: unknown; remember?: unknown; autoExecute?: unknown };
const req = data as {
toolCallId?: unknown;
approved?: unknown;
remember?: unknown;
autoExecute?: unknown;
};
if (typeof req.toolCallId !== 'string' || !req.toolCallId) {
log.warn('[IPC] tool:confirmationResponse rejected: invalid toolCallId');
return;
@@ -69,7 +74,9 @@ export function registerToolHandlers(ctx: IPCContext): void {
const remember = typeof req.remember === 'boolean' ? req.remember : false;
const autoExecute = typeof req.autoExecute === 'boolean' ? req.autoExecute : false;
confirmationHook.resolveConfirmation(req.toolCallId, req.approved, remember, autoExecute);
log.info(`[CONFIRM] Tool ${req.toolCallId} ${req.approved ? 'approved' : 'denied'}${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`);
log.info(
`[CONFIRM] Tool ${req.toolCallId} ${req.approved ? 'approved' : 'denied'}${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`,
);
});
// ===== v0.3.2: 批量工具确认响应(并行工具调用一次性审批) =====
@@ -86,7 +93,9 @@ export function registerToolHandlers(ctx: IPCContext): void {
};
// 严格校验 toolCallIds 数组
if (!Array.isArray(req.toolCallIds) || req.toolCallIds.length === 0) {
log.warn('[IPC] tool:confirmationResponseBatch rejected: toolCallIds must be non-empty array');
log.warn(
'[IPC] tool:confirmationResponseBatch rejected: toolCallIds must be non-empty array',
);
return;
}
// 每个元素必须是字符串
@@ -108,7 +117,9 @@ export function registerToolHandlers(ctx: IPCContext): void {
remember,
autoExecute,
);
log.info(`[CONFIRM] Batch ${req.approved ? 'approved' : 'denied'}: ${resolved.length}/${req.toolCallIds.length} resolved${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`);
log.info(
`[CONFIRM] Batch ${req.approved ? 'approved' : 'denied'}: ${resolved.length}/${req.toolCallIds.length} resolved${remember ? ' (remembered)' : ''}${autoExecute ? ' (autoExecute)' : ''}`,
);
});
// ===== v0.3.2: 拉取当前所有 pending 确认 =====
@@ -116,6 +127,22 @@ export function registerToolHandlers(ctx: IPCContext): void {
return { success: true, data: confirmationHook.getPendingConfirmations() };
});
// ===== v0.4.1: 会话内拒绝记忆管理(拒绝记忆带 TTL,支持手动恢复询问) =====
ipcMain.handle('tool:getRememberedDenials', async () => {
return { success: true, data: confirmationHook.getRememberedDenials() };
});
ipcMain.handle('tool:resetRememberedDenial', async (_event, toolName: unknown) => {
if (typeof toolName !== 'string' || !toolName) {
return { success: false, error: 'Invalid toolName' };
}
const reset = confirmationHook.resetRememberedDenial(toolName);
if (reset) {
log.info(`[CONFIRM] Reset remembered denial for tool "${toolName}" — will ask again`);
}
return { success: reset };
});
// ===== v0.2.0: 持久化自动执行设置 =====
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => {
// M-44 修复: 校验 toolName 合法性和 enabled 类型,防止配置 key 污染
+65 -20
View File
@@ -15,7 +15,8 @@ const metonaAPI = {
// ===== Agent 交互 =====
agent: {
/** 发送用户消息 */
sendMessage: (message: unknown, sessionId: string) => ipcRenderer.invoke('agent:sendMessage', message, sessionId),
sendMessage: (message: unknown, sessionId: string) =>
ipcRenderer.invoke('agent:sendMessage', message, sessionId),
/** 监听流式事件 */
onStreamEvent: (callback: (event: unknown) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data);
@@ -42,17 +43,21 @@ const metonaAPI = {
sessions: {
list: () => ipcRenderer.invoke('sessions:list'),
create: (title?: string) => ipcRenderer.invoke('sessions:create', title),
rename: (sessionId: string, title: string) => ipcRenderer.invoke('sessions:rename', sessionId, title),
rename: (sessionId: string, title: string) =>
ipcRenderer.invoke('sessions:rename', sessionId, title),
delete: (sessionId: string) => ipcRenderer.invoke('sessions:delete', sessionId),
getMessages: (sessionId: string) => ipcRenderer.invoke('sessions:getMessages', sessionId),
pin: (sessionId: string, pinned: boolean) => ipcRenderer.invoke('sessions:pin', sessionId, pinned),
archive: (sessionId: string, archived: boolean) => ipcRenderer.invoke('sessions:archive', sessionId, archived),
pin: (sessionId: string, pinned: boolean) =>
ipcRenderer.invoke('sessions:pin', sessionId, pinned),
archive: (sessionId: string, archived: boolean) =>
ipcRenderer.invoke('sessions:archive', sessionId, archived),
deleteMessage: (messageId: string) => ipcRenderer.invoke('sessions:deleteMessage', messageId),
clearMessages: (sessionId: string) => ipcRenderer.invoke('sessions:clearMessages', sessionId),
/** P2-11: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */
truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) =>
ipcRenderer.invoke('sessions:truncateAfter', sessionId, messageId, inclusive),
saveTrace: (sessionId: string, data: unknown) => ipcRenderer.invoke('sessions:saveTrace', sessionId, data),
saveTrace: (sessionId: string, data: unknown) =>
ipcRenderer.invoke('sessions:saveTrace', sessionId, data),
getTrace: (sessionId: string) => ipcRenderer.invoke('sessions:getTrace', sessionId),
},
@@ -61,12 +66,14 @@ const metonaAPI = {
listServers: () => ipcRenderer.invoke('mcp:listServers'),
addServer: (config: unknown) => ipcRenderer.invoke('mcp:addServer', config),
removeServer: (name: string) => ipcRenderer.invoke('mcp:removeServer', name),
toggleServer: (name: string, enabled: boolean) => ipcRenderer.invoke('mcp:toggleServer', name, enabled),
toggleServer: (name: string, enabled: boolean) =>
ipcRenderer.invoke('mcp:toggleServer', name, enabled),
},
// ===== 记忆系统 =====
memory: {
search: (query: string, options?: unknown) => ipcRenderer.invoke('db:searchMemories', query, options),
search: (query: string, options?: unknown) =>
ipcRenderer.invoke('db:searchMemories', query, options),
listAll: (options?: { type?: string; limit?: number }) =>
ipcRenderer.invoke('memory:listAll', options),
delete: (type: string, id: string) => ipcRenderer.invoke('memory:delete', type, id),
@@ -75,10 +82,24 @@ const metonaAPI = {
// ===== v0.2.0: 任务管理 =====
tasks: {
list: (sessionId?: string) => ipcRenderer.invoke('tasks:list', sessionId),
create: (data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string }) =>
ipcRenderer.invoke('tasks:create', data),
update: (id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string }, sessionId: string) =>
ipcRenderer.invoke('tasks:update', id, updates, sessionId),
create: (data: {
sessionId: string;
title: string;
description?: string;
priority?: string;
parentId?: string;
}) => ipcRenderer.invoke('tasks:create', data),
update: (
id: string,
updates: {
title?: string;
description?: string;
status?: string;
priority?: string;
assignedTo?: string;
},
sessionId: string,
) => ipcRenderer.invoke('tasks:update', id, updates, sessionId),
delete: (id: string, sessionId: string) => ipcRenderer.invoke('tasks:delete', id, sessionId),
// P2v0.3.13: 订阅任务变更事件(Agent 通过 task_manager 写入后触发)
onTaskChanged: (callback: (sessionId: string) => void) => {
@@ -102,11 +123,19 @@ const metonaAPI = {
ipcRenderer.on('tool:confirmationRequest', listener);
return () => ipcRenderer.removeListener('tool:confirmationRequest', listener);
},
sendConfirmationResponse: (response: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) =>
ipcRenderer.send('tool:confirmationResponse', response),
sendConfirmationResponse: (response: {
toolCallId: string;
approved: boolean;
remember: boolean;
autoExecute?: boolean;
}) => ipcRenderer.send('tool:confirmationResponse', response),
// v0.3.2: 批量确认响应(并行工具一次性审批)
sendConfirmationResponseBatch: (response: { toolCallIds: string[]; approved: boolean; remember: boolean; autoExecute?: boolean }) =>
ipcRenderer.send('tool:confirmationResponseBatch', response),
sendConfirmationResponseBatch: (response: {
toolCallIds: string[];
approved: boolean;
remember: boolean;
autoExecute?: boolean;
}) => ipcRenderer.send('tool:confirmationResponseBatch', response),
// v0.3.2: 拉取当前所有 pending 确认(前端弹框打开时调用,防止 state 覆盖丢失)
getPendingConfirmations: () =>
ipcRenderer.invoke('tool:getPendingConfirmations') as Promise<{
@@ -120,6 +149,17 @@ const metonaAPI = {
expiresAt?: number;
}>;
}>,
// v0.4.1: 会话内拒绝记忆管理(拒绝记忆 10 分钟 TTL,支持手动恢复询问)
getRememberedDenials: () =>
ipcRenderer.invoke('tool:getRememberedDenials') as Promise<{
success: boolean;
data: Array<{ toolName: string; expiresInSeconds: number }>;
}>,
resetRememberedDenial: (toolName: string) =>
ipcRenderer.invoke('tool:resetRememberedDenial', toolName) as Promise<{
success: boolean;
error?: string;
}>,
// v0.2.0: 持久化自动执行设置
setAutoExecute: (toolName: string, enabled: boolean) =>
ipcRenderer.invoke('tool:setAutoExecute', toolName, enabled),
@@ -169,15 +209,18 @@ const metonaAPI = {
// ===== 工具管理 =====
tools: {
list: () => ipcRenderer.invoke('tools:list'),
toggle: (toolName: string, enabled: boolean) => ipcRenderer.invoke('tools:toggle', toolName, enabled),
toggle: (toolName: string, enabled: boolean) =>
ipcRenderer.invoke('tools:toggle', toolName, enabled),
/** v0.3.18 修复: 监听工具就绪事件(MCP 初始化完成后触发) */
onReady: (callback: (data: { toolCount: number }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { toolCount: number }) => callback(data);
const listener = (_event: Electron.IpcRendererEvent, data: { toolCount: number }) =>
callback(data);
ipcRenderer.on('tools:ready', listener);
return () => ipcRenderer.removeListener('tools:ready', listener);
},
/** v0.3.18 修复: 查询工具当前是否已就绪(解决事件竞态,前端注册监听器后立即查询一次) */
isReady: () => ipcRenderer.invoke('tools:isReady') as Promise<{ ready: boolean; toolCount: number }>,
isReady: () =>
ipcRenderer.invoke('tools:isReady') as Promise<{ ready: boolean; toolCount: number }>,
},
// ===== 数据管理 =====
@@ -197,8 +240,10 @@ const metonaAPI = {
// ===== Toast 通知桥接 =====
toast: {
onShow: (callback: (data: { type: string; message: string; options?: unknown }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { type: string; message: string; options?: unknown }) =>
callback(data);
const listener = (
_event: Electron.IpcRendererEvent,
data: { type: string; message: string; options?: unknown },
) => callback(data);
ipcRenderer.on('toast:show', listener);
return () => ipcRenderer.removeListener('toast:show', listener);
},
+53 -1
View File
@@ -365,12 +365,64 @@ export class DatabaseService {
tryAddColumn('semantic_memories', 'tf_cache', 'TEXT');
tryAddColumn('working_memories', 'tf_cache', 'TEXT');
// v0.4.1 迁移 6: 重建 mcp_servers 表,transport CHECK 约束放宽以支持 'streamable-http'
// 旧约束 CHECK(transport IN ('stdio','sse')) 会拒绝新传输方式写入
try {
const schemaRow = db
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'mcp_servers'")
.get() as { sql: string } | undefined;
// 检测现有 CHECK 约束是否已包含 streamable-http(新表跳过重建)
if (schemaRow && schemaRow.sql && !schemaRow.sql.includes('streamable-http')) {
log.info(
'[DB] Migration: rebuilding mcp_servers table to support streamable-http transport',
);
const rebuildMcpServers = db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS mcp_servers_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse', 'streamable-http')),
command TEXT,
args TEXT,
url TEXT,
headers TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
last_connected INTEGER,
error_message TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
INSERT INTO mcp_servers_new (id, name, transport, command, args, url, headers, enabled, last_connected, error_message, created_at, updated_at)
SELECT id, name, transport, command, args, url, headers, enabled, last_connected, error_message, created_at, updated_at
FROM mcp_servers;
DROP TABLE mcp_servers;
ALTER TABLE mcp_servers_new RENAME TO mcp_servers;
`);
});
rebuildMcpServers();
log.info(
'[DB] Migration: mcp_servers table rebuilt successfully (transport now supports streamable-http)',
);
}
} catch (error) {
const msg = toErrorMessage(error);
log.warn(`[DB] Migration 6 (mcp_servers transport CHECK) skipped: ${msg}`);
// 非致命 — 迁移失败时仅无法添加 streamable-http 服务器,stdio/sse 不受影响
}
// C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL
// @see project_memory.md — Assistant messages with tool_calls must set content to null
// SQLite 不支持 ALTER COLUMN,需要重建表
try {
// 检测 content 列是否有 NOT NULL 约束
const columns = db.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string; notnull: number }>;
const columns = db.prepare('PRAGMA table_info(messages)').all() as Array<{
name: string;
notnull: number;
}>;
const contentCol = columns.find((c) => c.name === 'content');
if (contentCol && contentCol.notnull === 1) {
log.info('[DB] Migration: rebuilding messages table to allow NULL content');
+97 -33
View File
@@ -15,6 +15,8 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
// v0.4.1: streamable HTTP 传输(MCP 当前主流远程传输方式)
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
import { nanoid } from 'nanoid';
import type Database from 'better-sqlite3';
@@ -41,9 +43,15 @@ function safeParseArgs(raw: string): string[] {
* 仅允许常见的 MCP Server 运行时,防止任意命令执行。
*/
const ALLOWED_MCP_COMMANDS = new Set([
'npx', 'node', 'npm',
'python', 'python3', 'uv', 'uvx',
'bun', 'deno',
'npx',
'node',
'npm',
'python',
'python3',
'uv',
'uvx',
'bun',
'deno',
]);
/**
@@ -58,12 +66,16 @@ const ALLOWED_MCP_COMMANDS = new Set([
*/
function validateMcpCommand(command: string, args: string[]): void {
// 提取命令 basename(处理 /usr/bin/node、C:\node\node.exe 等路径)
const baseCmd = command.split(/[\\/]/).pop()?.replace(/\.exe$/i, '') ?? command;
const baseCmd =
command
.split(/[\\/]/)
.pop()
?.replace(/\.exe$/i, '') ?? command;
if (!ALLOWED_MCP_COMMANDS.has(baseCmd)) {
throw new Error(
`MCP command "${baseCmd}" is not in the allowed list: ${[...ALLOWED_MCP_COMMANDS].join(', ')}. ` +
`For security reasons, only standard MCP runtimes are permitted.`,
`For security reasons, only standard MCP runtimes are permitted.`,
);
}
@@ -92,13 +104,22 @@ function validateMcpCommand(command: string, args: string[]): void {
function buildSafeEnv(): Record<string, string> {
// 敏感变量后缀黑名单 — 匹配这些后缀的变量不会被传递给子进程
const SENSITIVE_SUFFIXES = [
'_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD',
'_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY',
'_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',
'DEEPSEEK_API_KEY',
'AGNES_API_KEY',
'MIMO_API_KEY',
'GITEA_PASSWORD',
'DATABASE_PASSWORD',
]);
const env: Record<string, string> = {};
@@ -120,7 +141,8 @@ export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'err
export interface MCPServerConfig {
id: string;
name: string;
transport: 'stdio' | 'sse';
/** v0.4.1: 新增 'streamable-http'MCP 当前主流远程传输);'sse' 保留向后兼容 */
transport: 'stdio' | 'sse' | 'streamable-http';
command?: string;
args?: string[];
url?: string;
@@ -172,7 +194,10 @@ class MCPToolAdapter implements IMetonaTool {
* 将 MCP JSON Schema 转换为 MetonaToolParams
*/
private convertSchema(schema: Record<string, unknown>): MetonaToolDef['parameters'] {
const properties: Record<string, { type: 'string' | 'number' | 'boolean' | 'object' | 'array'; description: string }> = {};
const properties: Record<
string,
{ type: 'string' | 'number' | 'boolean' | 'object' | 'array'; description: string }
> = {};
const schemaProps = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
for (const [key, prop] of Object.entries(schemaProps)) {
@@ -212,11 +237,19 @@ export class MCPManager {
*/
async initialize(): Promise<void> {
const db = this.getDB();
const rows = db.prepare(`
const rows = db
.prepare(
`
SELECT * FROM mcp_servers WHERE enabled = 1
`).all() as Array<{
id: string; name: string; transport: string;
command: string | null; args: string | null; url: string | null;
`,
)
.all() as Array<{
id: string;
name: string;
transport: string;
command: string | null;
args: string | null;
url: string | null;
}>;
const connectWithTimeout = (config: MCPServerConfig): Promise<unknown> =>
@@ -230,7 +263,8 @@ export class MCPManager {
const config: MCPServerConfig = {
id: row.id,
name: row.name,
transport: row.transport as 'stdio' | 'sse',
// v0.4.1: 支持三种传输方式(stdio / sse / streamable-http
transport: row.transport as MCPServerConfig['transport'],
command: row.command ?? undefined,
args: row.args ? safeParseArgs(row.args) : undefined,
url: row.url ?? undefined,
@@ -279,12 +313,15 @@ export class MCPManager {
env: buildSafeEnv(),
});
} else if (config.transport === 'sse' && config.url) {
// SSE 模式(远程 HTTP
// SSE 模式(远程 HTTP,旧式传输,保留向后兼容
transport = new SSEClientTransport(new URL(config.url));
} else if (config.transport === 'streamable-http' && config.url) {
// v0.4.1: streamable HTTP 模式(MCP 当前主流远程传输)
transport = new StreamableHTTPClientTransport(new URL(config.url));
} else {
throw new Error(
`Unsupported transport "${config.transport}". ` +
`'stdio' requires 'command', 'sse' requires 'url'.`,
`'stdio' requires 'command', 'sse'/'streamable-http' requires 'url'.`,
);
}
@@ -315,9 +352,11 @@ export class MCPManager {
// 更新数据库
const db = this.getDB();
db.prepare(`
db.prepare(
`
UPDATE mcp_servers SET last_connected = ?, error_message = NULL WHERE name = ?
`).run(Date.now(), name);
`,
).run(Date.now(), name);
log.info(`MCP server "${name}" connected: ${tools.length} tool(s)`);
} catch (error) {
@@ -329,9 +368,11 @@ export class MCPManager {
// 更新数据库
const db = this.getDB();
db.prepare(`
db.prepare(
`
UPDATE mcp_servers SET error_message = ? WHERE name = ?
`).run((error as Error).message, name);
`,
).run((error as Error).message, name);
log.error(`MCP server "${name}" connection failed:`, error);
throw error;
@@ -369,19 +410,28 @@ export class MCPManager {
*/
async toggleServer(name: string, enabled: boolean): Promise<void> {
const db = this.getDB();
db.prepare(`
db.prepare(
`
UPDATE mcp_servers SET enabled = ?, updated_at = ? WHERE name = ?
`).run(enabled ? 1 : 0, Date.now(), name);
`,
).run(enabled ? 1 : 0, Date.now(), name);
if (enabled) {
const row = db.prepare('SELECT * FROM mcp_servers WHERE name = ?').get(name) as {
id: string; name: string; transport: string;
command: string | null; args: string | null; url: string | null;
} | undefined;
const row = db.prepare('SELECT * FROM mcp_servers WHERE name = ?').get(name) as
| {
id: string;
name: string;
transport: string;
command: string | null;
args: string | null;
url: string | null;
}
| undefined;
if (row) {
await this.connectServer({
id: row.id, name: row.name,
transport: row.transport as 'stdio' | 'sse',
id: row.id,
name: row.name,
transport: row.transport as MCPServerConfig['transport'],
command: row.command ?? undefined,
args: row.args ? safeParseArgs(row.args) : undefined,
url: row.url ?? undefined,
@@ -395,16 +445,30 @@ export class MCPManager {
/**
* 添加新的 MCP Server
*
* v0.4.1: 校验 transport 与对应字段匹配(stdio→commandsse/streamable-http→url
*/
async addServer(config: Omit<MCPServerConfig, 'id'>): Promise<void> {
const db = this.getDB();
const id = `mcp_${nanoid(8)}`;
db.prepare(`
// 校验传输方式与必填字段
if (config.transport === 'stdio' && !config.command) {
throw new Error('stdio transport requires "command"');
}
if ((config.transport === 'sse' || config.transport === 'streamable-http') && !config.url) {
throw new Error(`${config.transport} transport requires "url"`);
}
db.prepare(
`
INSERT INTO mcp_servers (id, name, transport, command, args, url, enabled)
VALUES (?, ?, ?, ?, ?, ?, 1)
`).run(
id, config.name, config.transport,
`,
).run(
id,
config.name,
config.transport,
config.command ?? null,
config.args ? JSON.stringify(config.args) : null,
config.url ?? null,