feat: 升级至 v0.3.1 — 全量代码审计修复 + 安全增强

本次升级基于完整代码审查,修复 Critical/High/Medium/Low 四级共 96 项问题,
并通过返工审计修复 10 项遗留问题,tsc 双端类型检查零错误。

Critical (10/10 完成):
- C-4: command.ts 接入 shell-quote 进行 token-level 注入检测,替代原有正则匹配
  可防御 r"m" -rf /、$'rm'、$(echo rm) 等字符串拼接绕过

High (11/11 完成):
- 竞态保护、Promise.allSettled、AbortController 资源泄漏、IPC 参数校验等

Medium (55/55 完成):
- 事务保护、敏感数据脱敏、枚举校验、MUI v9 Stack prop 迁移、
  React 组件 cancelled 标志、类型收窄等

Low (20/20 完成):
- 辅助方法提取(flushToolCallBuffer/scoreAndPushMemory/tryAddColumn 等)
- nanoid 统一替代 Date.now()+Math.random()
- confirm() 替换为 MUI Dialog、useMemo 缓存、魔法数字命名化等

返工审计修复 (10/10 完成):
- L-11: LogsSettings 残留的原生 confirm()/alert() 全部替换为 MUI Dialog/Alert
- M-53: MemoryViewer handleSearch 独立 ref,修复 searching 状态卡死
- M-42: 脱敏短值(length <= 4)泄露修复
- M-47: tasks:update 补全 title/description 类型校验
- L-9: ollama.adapter 非流式路径 nanoid 统一
- M-45: audit:query limit 策略与 memory:listAll 一致化
- SettingsModal handleConfirmRemove 补全 try/catch + loadServers cleanup
- L-15: CommandPalette useMemo 补全 sessions 响应式依赖
- useAgentStream 事件类型补全 seq/timestamp 字段

新增依赖: shell-quote + @types/shell-quote
版本号: 0.3.0 -> 0.3.1
This commit is contained in:
thzxx
2026-07-13 22:36:58 +08:00
parent 4f5f570ac8
commit e4d81d8247
47 changed files with 2247 additions and 475 deletions
+42 -9
View File
@@ -16,19 +16,35 @@
import { BaseAdapter } from './base-adapter';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format';
import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stream';
import log from 'electron-log';
export class AgnesAdapter extends BaseAdapter {
override readonly provider: string = 'agnes';
// H-2 修复: provider → providerId(规范要求)
override readonly providerId: string = 'agnes';
readonly supportedModels = ['agnes-2.0-flash'];
readonly supportsToolCalling = true;
readonly supportsThinking = true;
// H-2 修复: Agnes 模型元信息(1M 上下文,65.5K 最大输出)
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'agnes-2.0-flash': {
id: 'agnes-2.0-flash',
name: 'Agnes 2.0 Flash',
contextWindow: 1_000_000,
maxOutputTokens: 65_536,
supportsToolCalling: true,
supportsThinking: true,
description: 'Agnes AI 快速版,1M 上下文,支持多模态图片与思考模式',
},
};
// ===== POST /chat/completions (非流式) =====
async chat(request: MetonaRequest): Promise<MetonaResponse> {
// H-2 修复: chat → send(规范要求)
async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = this.toNativeRequest(request, false);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -39,7 +55,8 @@ export class AgnesAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok) {
@@ -48,12 +65,12 @@ export class AgnesAdapter extends BaseAdapter {
}
const data = await response.json() as Record<string, unknown>;
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.provider, this.config.defaultModel);
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.providerId, this.config.defaultModel);
return {
meta: {
requestId: request.meta.requestId,
provider: this.provider,
provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0,
timestamp: Date.now(),
@@ -68,7 +85,8 @@ export class AgnesAdapter extends BaseAdapter {
// ===== POST /chat/completions (流式) =====
async *chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
// H-2 修复: chatStream → sendStream(规范要求)
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = this.toNativeRequest(request, true);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -79,7 +97,8 @@ export class AgnesAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok || !response.body) {
@@ -94,6 +113,17 @@ export class AgnesAdapter extends BaseAdapter {
);
}
/**
* H-2 修复: 获取上下文窗口大小(规范要求)
*
* Agnes 模型统一 1M 上下文窗口。
* 注意:Agnes API 未提供 /models 端点,listModels 使用基类默认实现。
*/
override getContextWindow(): number {
const modelInfo = AgnesAdapter.MODEL_INFO[this.config.defaultModel];
return modelInfo?.contextWindow ?? 1_000_000;
}
// ========== 私有方法 ==========
/**
@@ -154,9 +184,12 @@ export class AgnesAdapter extends BaseAdapter {
body.tools = tools;
}
// Thinking 模式Agnes 使用 chat_template_kwargs 而非 thinking
// C-3 修复: Thinking 模式Agnes 使用 chat_template_kwargs 而非 thinking
// Agnes API 仅支持 enable_thinking: true/false,不支持 effort 级别
// thinkingEffort === 'low' 时映射为 false(不启用深度思考),其他级别映射为 true
if (request.params.thinkingEnabled) {
body.chat_template_kwargs = { enable_thinking: true };
const effort = request.params.thinkingEffort ?? 'high';
body.chat_template_kwargs = { enable_thinking: effort !== 'low' };
}
// 停止序列
+80 -10
View File
@@ -16,17 +16,81 @@ import type {
MetonaError,
} from '../types';
import { MetonaErrorCode } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
export abstract class BaseAdapter implements IMetonaProviderAdapter {
abstract readonly provider: string;
// H-2 修复: provider → providerId(规范要求)
abstract readonly providerId: string;
abstract readonly supportedModels: string[];
abstract readonly supportsToolCalling: boolean;
abstract readonly supportsThinking: boolean;
/**
* C-2 修复: 外部注入的 AbortSignal(来自 Engine 的 abortController
*
* 用户点击中断时,Engine 调用 abortController.abort(),此信号触发后,
* 正在进行的 fetch 会被立即中断,避免资源泄漏。
*/
private externalAbortSignal: AbortSignal | undefined;
constructor(protected config: AdapterConfig) {}
abstract chat(request: MetonaRequest): Promise<MetonaResponse>;
abstract chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent>;
// H-2 修复: chat → send(规范要求)
abstract send(request: MetonaRequest): Promise<MetonaResponse>;
// H-2 修复: chatStream → sendStream(规范要求)
abstract sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent>;
/**
* H-2 修复: 获取上下文窗口大小(规范要求)
*
* 默认实现从 config 读取 contextWindow,子类可覆盖以支持动态查询。
* Engine 用此值估算上下文使用率,决定是否触发压缩。
*
* @returns 上下文窗口大小(token 数)
*/
getContextWindow(): number {
// 优先使用 AdapterConfig.contextWindow(如果存在)
const ctx = (this.config as AdapterConfig & { contextWindow?: number }).contextWindow;
if (typeof ctx === 'number' && ctx > 0) return ctx;
// 默认 1M(保守值,子类应覆盖)
return 1_000_000;
}
/**
* C-2 修复: 注入外部 AbortSignal
* Engine 在调用 send/sendStream 前调用此方法,关联 abortController
*/
setAbortSignal(signal: AbortSignal | undefined): void {
this.externalAbortSignal = signal;
}
/**
* C-2 修复: 合并外部 abort signal 和 timeout signal
*
* 使用 AbortSignal.any() 合并两个信号,任一触发都会中断 fetch:
* - timeout signal:防止请求挂起
* - external abort signal:用户主动中断
*
* @param timeoutMs 超时时间(毫秒)
* @returns 合并后的 AbortSignal
*/
protected getFetchSignal(timeoutMs: number): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
// 如果没有外部信号,直接使用 timeout signal
if (!this.externalAbortSignal) {
return timeoutSignal;
}
// 如果外部信号已经 abort,直接返回它
if (this.externalAbortSignal.aborted) {
return this.externalAbortSignal;
}
// 合并两个信号 — 任一触发都会 abort
// Node.js 20+ / Electron 35+ 支持 AbortSignal.any()
return AbortSignal.any([timeoutSignal, this.externalAbortSignal]);
}
async healthCheck(): Promise<boolean> {
try {
@@ -37,8 +101,14 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
}
}
async listModels(): Promise<string[]> {
return this.supportedModels;
/**
* H-2 修复: 返回 MetonaModelInfo[](规范要求)
*
* 默认实现将 supportedModels 映射为 MetonaModelInfo[]
* 子类可覆盖以从 API 获取完整元信息。
*/
async listModels(): Promise<MetonaModelInfo[]> {
return this.supportedModels.map((id) => ({ id }));
}
/**
@@ -52,7 +122,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.NETWORK_TIMEOUT,
message: error.message,
provider: this.provider,
provider: this.providerId,
retryable: true,
retryAfterMs: 3000,
};
@@ -62,7 +132,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.NETWORK_ERROR,
message: error.message,
provider: this.provider,
provider: this.providerId,
retryable: true,
retryAfterMs: 3000,
};
@@ -72,7 +142,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.AUTH_INVALID,
message: 'API key 无效或已过期',
provider: this.provider,
provider: this.providerId,
retryable: false,
};
}
@@ -81,7 +151,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.RATE_LIMITED,
message: '请求过于频繁,请稍后重试',
provider: this.provider,
provider: this.providerId,
retryable: true,
retryAfterMs: 5000,
};
@@ -91,7 +161,7 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter {
return {
code: MetonaErrorCode.UNKNOWN,
message: error instanceof Error ? error.message : 'Unknown error',
provider: this.provider,
provider: this.providerId,
retryable: false,
};
}
+62 -12
View File
@@ -13,18 +13,43 @@
import { BaseAdapter } from './base-adapter';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason, MetonaErrorCode } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
import { buildOpenAICompatibleMessages, buildOpenAICompatibleTools } from './shared/openai-format';
import { parseSSEStream, parseOpenAICompatibleResponse } from './shared/sse-stream';
export class DeepSeekAdapter extends BaseAdapter {
override readonly provider: string = 'deepseek';
// H-2 修复: provider → providerId(规范要求)
override readonly providerId: string = 'deepseek';
readonly supportedModels = ['deepseek-v4-pro', 'deepseek-v4-flash'];
readonly supportsToolCalling = true;
readonly supportsThinking = true;
// H-2 修复: DeepSeek 模型元信息(1M 上下文,384K 最大输出)
private static readonly MODEL_INFO: Record<string, MetonaModelInfo> = {
'deepseek-v4-pro': {
id: 'deepseek-v4-pro',
name: 'DeepSeek V4 Pro',
contextWindow: 1_000_000,
maxOutputTokens: 384_000,
supportsToolCalling: true,
supportsThinking: true,
description: 'DeepSeek 旗舰模型,1M 上下文,支持深度推理与工具调用',
},
'deepseek-v4-flash': {
id: 'deepseek-v4-flash',
name: 'DeepSeek V4 Flash',
contextWindow: 1_000_000,
maxOutputTokens: 384_000,
supportsToolCalling: true,
supportsThinking: true,
description: 'DeepSeek 快速版,1M 上下文,低延迟推理',
},
};
// ===== POST /chat/completions (非流式) =====
async chat(request: MetonaRequest): Promise<MetonaResponse> {
// H-2 修复: chat → send(规范要求)
async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = this.toNativeRequest(request, false);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -35,7 +60,8 @@ export class DeepSeekAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 120_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 120_000),
});
if (!response.ok) {
@@ -44,12 +70,12 @@ export class DeepSeekAdapter extends BaseAdapter {
}
const data = await response.json() as Record<string, unknown>;
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.provider, this.config.defaultModel);
const parsed = parseOpenAICompatibleResponse(data, request.meta.requestId, this.providerId, this.config.defaultModel);
return {
meta: {
requestId: request.meta.requestId,
provider: this.provider,
provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0,
timestamp: Date.now(),
@@ -64,7 +90,8 @@ export class DeepSeekAdapter extends BaseAdapter {
// ===== POST /chat/completions (流式) =====
async *chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
// H-2 修复: chatStream → sendStream(规范要求)
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = this.toNativeRequest(request, true);
const response = await fetch(`${this.config.baseURL}/chat/completions`, {
@@ -75,7 +102,8 @@ export class DeepSeekAdapter extends BaseAdapter {
...this.config.headers,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok || !response.body) {
@@ -92,18 +120,40 @@ export class DeepSeekAdapter extends BaseAdapter {
// ===== GET /models =====
async listModels(): Promise<string[]> {
/**
* H-2 修复: 返回 MetonaModelInfo[](规范要求)
*
* 优先尝试从 API 获取实时模型列表,并合并本地 MODEL_INFO 元数据。
* API 不可用时回退到 supportedModels。
*/
async listModels(): Promise<MetonaModelInfo[]> {
try {
const response = await fetch(`${this.config.baseURL}/models`, {
headers: { Authorization: `Bearer ${this.config.apiKey}` },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return this.supportedModels;
const data = await response.json() as { data?: Array<{ id: string }> };
return data.data?.map((m) => m.id) ?? this.supportedModels;
if (response.ok) {
const data = await response.json() as { data?: Array<{ id: string }> };
if (data.data?.length) {
// 合并 API 返回的模型 ID 与本地元数据
return data.data.map((m) => DeepSeekAdapter.MODEL_INFO[m.id] ?? { id: m.id });
}
}
} catch {
return this.supportedModels;
// API 不可用时降级
}
// 回退到 supportedModels(带本地元数据)
return this.supportedModels.map((id) => DeepSeekAdapter.MODEL_INFO[id] ?? { id });
}
/**
* H-2 修复: 获取上下文窗口大小(规范要求)
*
* DeepSeek 模型统一 1M 上下文窗口。
*/
override getContextWindow(): number {
const modelInfo = DeepSeekAdapter.MODEL_INFO[this.config.defaultModel];
return modelInfo?.contextWindow ?? 1_000_000;
}
// ===== GET /user/balance =====
+70 -15
View File
@@ -23,15 +23,22 @@
*/
import { BaseAdapter } from './base-adapter';
import log from 'electron-log';
import { nanoid } from 'nanoid';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
import { MetonaFinishReason, MetonaStreamEventType } from '../types';
import type { MetonaModelInfo } from '../types/metona-adapter';
export class OllamaAdapter extends BaseAdapter {
override readonly provider: string = 'ollama';
// H-2 修复: provider → providerId(规范要求)
override readonly providerId: string = 'ollama';
readonly supportedModels = ['qwen3:latest', 'gemma3:latest', 'deepseek-r1:latest'];
readonly supportsToolCalling = true;
readonly supportsThinking = true;
// H-2 修复: Ollama 本地模型默认上下文窗口(可由 options.num_ctx 覆盖)
private static readonly DEFAULT_CONTEXT_WINDOW = 4096;
private baseURL: string;
constructor(config: ConstructorParameters<typeof BaseAdapter>[0]) {
@@ -41,14 +48,16 @@ export class OllamaAdapter extends BaseAdapter {
// ===== POST /api/chat =====
async chat(request: MetonaRequest): Promise<MetonaResponse> {
// H-2 修复: chat → send(规范要求)
async send(request: MetonaRequest): Promise<MetonaResponse> {
const nativeRequest = this.toNativeRequest(request);
const response = await fetch(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: false }),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok) {
@@ -59,14 +68,16 @@ export class OllamaAdapter extends BaseAdapter {
return this.toMetonaResponse(data, request.meta.requestId, request.meta.iteration);
}
async *chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
// H-2 修复: chatStream → sendStream(规范要求)
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const nativeRequest = this.toNativeRequest(request);
const response = await fetch(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: true }),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
// C-2 修复: 使用合并后的 signal(外部 abort + timeout
signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000),
});
if (!response.ok || !response.body) {
@@ -133,7 +144,8 @@ export class OllamaAdapter extends BaseAdapter {
seq: seq++,
timestamp: Date.now(),
toolCall: {
id: `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
// L-9 修复: 统一使用 nanoid 生成工具调用 ID(与 sse-stream.ts 一致)
id: `tc_${nanoid(8)}`,
name: tc.function?.name ?? '',
args: parsedArgs,
iteration: request.meta.iteration,
@@ -250,17 +262,55 @@ export class OllamaAdapter extends BaseAdapter {
// ===== GET /api/tags =====
async listModels(): Promise<string[]> {
/**
* H-2 修复: 返回 MetonaModelInfo[](规范要求)
*
* Ollama /api/tags 返回模型列表含详细信息(name, size, details),
* 转换为 MetonaModelInfo 并补充默认元数据。
*/
async listModels(): Promise<MetonaModelInfo[]> {
try {
const response = await fetch(`${this.baseURL}/api/tags`, {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return this.supportedModels;
const data = await response.json() as { models?: Array<{ name: string }> };
return data.models?.map((m) => m.name) ?? this.supportedModels;
if (response.ok) {
const data = await response.json() as {
models?: Array<{
name: string;
size?: number;
details?: { parameter_size?: string; quantization_level?: string; family?: string };
}>;
};
if (data.models?.length) {
return data.models.map((m) => ({
id: m.name,
name: m.name,
// Ollama 模型上下文窗口由 options.num_ctx 决定,此处给保守值
contextWindow: OllamaAdapter.DEFAULT_CONTEXT_WINDOW,
supportsToolCalling: true, // Ollama 多数模型支持,具体能力需通过 /api/show 查询
supportsThinking: true,
description: m.details
? `${m.details.family ?? 'unknown'} / ${m.details.parameter_size ?? '?'} / ${m.details.quantization_level ?? '?'}`
: undefined,
}));
}
}
} catch {
return this.supportedModels;
// API 不可用时降级
}
// 回退到 supportedModels
return this.supportedModels.map((id) => ({ id }));
}
/**
* H-2 修复: 获取上下文窗口大小(规范要求)
*
* Ollama 上下文窗口由 options.num_ctx 决定(默认 4096),
* Engine 应通过 MetonaRequest.params.contextLength 显式设置。
* 此处返回默认值,供 Engine 在未指定时参考。
*/
override getContextWindow(): number {
return OllamaAdapter.DEFAULT_CONTEXT_WINDOW;
}
// ===== POST /api/show =====
@@ -312,7 +362,10 @@ export class OllamaAdapter extends BaseAdapter {
try {
const chunk = JSON.parse(line);
onProgress?.({ status: chunk.status, completed: chunk.completed, total: chunk.total });
} catch {}
} catch {
// L-3 修复: 添加日志便于诊断非标准行(如进度通知、空行等)
log.debug('[Ollama] skipped non-JSON line during pull:', line.slice(0, 100));
}
}
}
}
@@ -366,7 +419,8 @@ export class OllamaAdapter extends BaseAdapter {
].filter(Boolean).join('\n\n'),
},
...request.messages.filter((m) => m.role !== 'system').map((m) => {
const msg: Record<string, unknown> = { role: m.role, content: m.content };
// C-6 修复: Ollama API 不支持 null contentassistant 仅有 tool_calls 时转为空字符串
const msg: Record<string, unknown> = { role: m.role, content: m.content ?? '' };
// Ollama 图片使用 images 字段(纯 base64 数组,不含 data: 前缀)
if (m.images?.length) {
msg.images = m.images.map((img) => {
@@ -439,7 +493,7 @@ export class OllamaAdapter extends BaseAdapter {
return {
meta: {
requestId,
provider: this.provider,
provider: this.providerId,
model: (data.model as string) ?? this.config.defaultModel,
latencyMs: 0,
timestamp: Date.now(),
@@ -464,7 +518,8 @@ export class OllamaAdapter extends BaseAdapter {
args = {};
}
return {
id: `tc_${Date.now()}_${i}`,
// L-9 修复(审计补充): 非流式路径统一使用 nanoid,与流式路径(sendStream)保持一致
id: `tc_${nanoid(8)}`,
name: (fn?.name as string) ?? '',
args,
iteration,
+56 -51
View File
@@ -12,6 +12,52 @@ import { nanoid } from 'nanoid';
import type { MetonaStreamEvent, MetonaTokenUsage } from '../../types';
import { MetonaStreamEventType } from '../../types';
/**
* L-4 修复: 提取 flushToolCallBuffer 辅助函数,消除 [DONE] 分支和 finish_reason='tool_calls' 分支的重复代码
*
* 遍历工具调用缓冲区,对每个缓冲的工具调用:
* 1. JSON.parse argsBuffer(失败则跳过)
* 2. yield 一个 TOOL_CALL_COMPLETE 事件
* 3. 清空缓冲区
*
* @param toolCallsBuffer - 工具调用缓冲区(index → { name, argsBuffer }
* @param requestId - 请求 ID
* @param sessionId - 会话 ID
* @param iteration - 当前迭代轮次
* @param seqRef - seq 计数器引用(递增)
* @yields MetonaStreamEvent
*/
function* flushToolCallBuffer(
toolCallsBuffer: Map<number, { name: string; argsBuffer: string }>,
requestId: string,
sessionId: string,
iteration: number,
seqRef: { seq: number },
): Generator<MetonaStreamEvent> {
for (const [, buf] of toolCallsBuffer) {
try {
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId,
sessionId,
iteration,
seq: seqRef.seq++,
timestamp: Date.now(),
toolCall: {
id: `tc_${nanoid(8)}`,
name: buf.name,
args: buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {},
iteration,
timestamp: Date.now(),
},
};
} catch {
// JSON 解析失败,跳过该工具调用
}
}
toolCallsBuffer.clear();
}
/**
* 解析 OpenAI 兼容 SSE 流式响应
*
@@ -29,7 +75,7 @@ export async function* parseSSEStream(
): AsyncGenerator<MetonaStreamEvent> {
const reader = responseBody.getReader();
const decoder = new TextDecoder();
let seq = 0;
const seqRef = { seq: 0 };
let buffer = '';
// 工具调用缓冲区:index → { name, argsBuffer }
@@ -50,36 +96,15 @@ export async function* parseSSEStream(
// 流结束
if (data === '[DONE]') {
// 将缓冲区中未完成拼接的工具调用发送
for (const [index, buf] of toolCallsBuffer) {
try {
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId,
sessionId,
iteration,
seq: seq++,
timestamp: Date.now(),
toolCall: {
id: `tc_${nanoid(8)}`,
name: buf.name,
args: buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {},
iteration,
timestamp: Date.now(),
},
};
} catch {
// JSON 解析失败,跳过
}
}
toolCallsBuffer.clear();
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
yield {
type: MetonaStreamEventType.DONE,
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
};
return;
@@ -96,7 +121,7 @@ export async function* parseSSEStream(
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
delta: delta.content,
};
@@ -109,7 +134,7 @@ export async function* parseSSEStream(
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
delta: delta.reasoning_content,
};
@@ -131,7 +156,7 @@ export async function* parseSSEStream(
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
toolCallDelta: {
index: idx,
@@ -158,7 +183,7 @@ export async function* parseSSEStream(
requestId,
sessionId,
iteration,
seq: seq++,
seq: seqRef.seq++,
timestamp: Date.now(),
usage,
};
@@ -167,28 +192,8 @@ export async function* parseSSEStream(
// 非 [DONE] 但 finish_reason 为 tool_calls 时提前 flush 缓冲区
const finishReason = chunk.choices?.[0]?.finish_reason as string | undefined;
if (finishReason === 'tool_calls') {
for (const [index, buf] of toolCallsBuffer) {
try {
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId,
sessionId,
iteration,
seq: seq++,
timestamp: Date.now(),
toolCall: {
id: `tc_${nanoid(8)}`,
name: buf.name,
args: buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {},
iteration,
timestamp: Date.now(),
},
};
} catch {
// JSON 解析失败,跳过
}
}
toolCallsBuffer.clear();
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
}
} catch {
// 跳过解析失败的行
+176 -76
View File
@@ -12,6 +12,7 @@
*/
import { EventEmitter } from 'events';
import { resolve } from 'path';
import { nanoid } from 'nanoid';
import {
AgentLoopState,
@@ -33,7 +34,7 @@ import type {
IMetonaProviderAdapter,
MetonaToolDef,
} from '../types';
import { MetonaStreamEventType, MetonaFinishReason } from '../types';
import { MetonaStreamEventType, MetonaFinishReason, MetonaErrorCode } from '../types';
import { estimateMessagesTokens } from '../utils/token-estimator';
import log from 'electron-log';
@@ -175,6 +176,11 @@ export class AgentLoopEngine extends EventEmitter {
this.currentSessionId = sessionId;
this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
this.abortController = new AbortController();
// C-2 修复: 将 abortController 的 signal 注入到 adapter
// 使正在进行的 fetch 可被用户中断,避免资源泄漏
if (this.adapter.setAbortSignal) {
this.adapter.setAbortSignal(this.abortController.signal);
}
this.eventSeq = 0;
// v0.3.0: 重置工具调用历史(用于死循环检测)
this.toolCallHistory = [];
@@ -297,6 +303,10 @@ export class AgentLoopEngine extends EventEmitter {
this.aborted = true;
this.abortController?.abort();
this.abortController = null;
// C-2 修复: 清除 adapter 的 abort signal,防止旧的已 abort signal 影响后续请求
if (this.adapter.setAbortSignal) {
this.adapter.setAbortSignal(undefined);
}
this.emit('aborted');
}
@@ -348,7 +358,8 @@ export class AgentLoopEngine extends EventEmitter {
// 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI
// RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支)
if (event.type === MetonaStreamEventType.ERROR && (event.error?.code as string) === 'RETRY') {
// H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'as string' 强制转换,确保类型安全
if (event.type === MetonaStreamEventType.ERROR && event.error?.code === MetonaErrorCode.RETRY) {
// 内部处理:清空已累积的内容和缓冲区(重试会从头开始接收)
fullContent = '';
reasoningContent = '';
@@ -410,26 +421,8 @@ export class AgentLoopEngine extends EventEmitter {
// === v0.2.0: PARSING 状态 — 解析流式缓冲区中的工具调用 ===
await this.transitionTo(AgentLoopState.PARSING);
// 处理缓冲区中的工具调用(TOOL_CALL_DELTA 拼接
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
step.toolCalls = [];
for (const [, buf] of toolCallsBuffer) {
let args: Record<string, unknown>;
try {
args = buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {};
} catch {
// JSON 解析失败,使用空参数(LLM 仍请求了该工具调用)
args = {};
}
step.toolCalls.push({
id: `tc_${nanoid(8)}`,
name: buf.name,
args,
iteration: this.currentIteration,
timestamp: Date.now(),
});
}
}
// L-19 修复: 提取 finalizeToolCallsFromBuffer 子方法(PARSING 阶段
this.finalizeToolCallsFromBuffer(step, toolCallsBuffer);
// 记录 Thought
if (fullContent || reasoningContent) {
@@ -470,49 +463,9 @@ export class AgentLoopEngine extends EventEmitter {
// transitionTo 已发射 stateChange 事件,无需重复 emit
await this.transitionTo(AgentLoopState.EXECUTING);
// 并行执行所有工具调用(独立工具之间无依赖,可安全并发
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
const result = await this.executeToolSafely(tc);
// H-5: abort 后不转发工具结果(避免 DONE 后到达的残留事件污染新流)
if (!this.aborted) {
// 立即转发工具结果到渲染进程(不等其他工具完成)
this.emit('streamEvent', {
type: MetonaStreamEventType.TOOL_RESULT,
requestId: request.meta.requestId,
sessionId,
iteration: this.currentIteration,
seq: this.nextSeq(),
timestamp: Date.now(),
toolResult: result,
runId: this.runId,
});
}
return result;
};
// H-5: abort 时提前退出工具执行等待,不再阻塞至所有工具完成
const toolsPromise = Promise.allSettled(
step.toolCalls.map((tc) => executeAndForward(tc)),
);
const controller = this.abortController;
let onAbort: (() => void) | null = null;
const abortPromise = new Promise<null>((resolve) => {
if (this.aborted) return resolve(null);
if (controller) {
onAbort = () => resolve(null);
controller.signal.addEventListener('abort', onAbort, { once: true });
}
});
const raceResult = await Promise.race([toolsPromise, abortPromise]) as
| PromiseSettledResult<MetonaToolResult>[]
| null;
// v0.3.0 修复: 若 toolsPromise 先完成(正常执行),手动移除 abort 监听器,避免监听器堆积
if (onAbort && controller && raceResult !== null) {
controller.signal.removeEventListener('abort', onAbort);
}
// L-19 修复: 提取 executeToolCallsParallel 子方法(EXECUTING 阶段
// 返回 null 表示被 abort 中断
const raceResult = await this.executeToolCallsParallel(step.toolCalls, request.meta.requestId, sessionId);
if (raceResult === null) {
// 被 abort 中断,标记步骤并退出
@@ -521,8 +474,7 @@ export class AgentLoopEngine extends EventEmitter {
return step;
}
const settledResults = raceResult;
step.toolResults = settledResults.map((result, idx) => {
step.toolResults = raceResult.map((result, idx) => {
const tc = step.toolCalls![idx];
if (result.status === 'fulfilled') return result.value;
// rejected:构造失败 result
@@ -600,6 +552,95 @@ export class AgentLoopEngine extends EventEmitter {
}
}
/**
* L-19 修复: PARSING 阶段 — 解析流式接收期间累积的工具调用缓冲区,
* 构造 MetonaToolCall[] 写入 step.toolCalls。
*
* 仅在缓冲区非空且 step 尚未通过 TOOL_CALL_COMPLETE 接收到完整调用时生效,
* 避免覆盖已就绪的 toolCalls。
*/
private finalizeToolCallsFromBuffer(
step: IterationStep,
toolCallsBuffer: Map<number, { name: string; argsBuffer: string }>,
): void {
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
step.toolCalls = [];
for (const [, buf] of toolCallsBuffer) {
let args: Record<string, unknown>;
try {
args = buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {};
} catch {
args = {};
}
step.toolCalls.push({
id: `tc_${nanoid(8)}`,
name: buf.name,
args,
iteration: this.currentIteration,
timestamp: Date.now(),
});
}
}
}
/**
* L-19 修复: EXECUTING 阶段 — 并行执行工具调用,转发结果到 UI,
* 并与 abort 信号竞速。返回 null 表示被 abort 中断。
*
* 注意:rejected 的工具结果由调用方处理(构造失败 result 并转发),
* 此处只负责转发 fulfilled 的结果。
*/
private async executeToolCallsParallel(
toolCalls: MetonaToolCall[],
requestId: string,
sessionId: string,
): Promise<PromiseSettledResult<MetonaToolResult>[] | null> {
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
const result = await this.executeToolSafely(tc);
// 执行成功后转发结果到 UI(abort 后不再转发,避免污染新会话的流)
if (!this.aborted) {
this.emit('streamEvent', {
type: MetonaStreamEventType.TOOL_RESULT,
requestId,
sessionId,
iteration: this.currentIteration,
seq: this.nextSeq(),
timestamp: Date.now(),
toolResult: result,
runId: this.runId,
});
}
return result;
};
// C-2 修复: 使用 Promise.allSettled 而非 Promise.all,确保单个工具失败不影响其他工具
const toolsPromise = Promise.allSettled(toolCalls.map((tc) => executeAndForward(tc)));
const controller = this.abortController;
let onAbort: (() => void) | null = null;
const abortPromise = new Promise<null>((resolve) => {
if (this.aborted) {
resolve(null);
return;
}
if (controller) {
onAbort = () => resolve(null);
controller.signal.addEventListener('abort', onAbort, { once: true });
}
});
const raceResult = (await Promise.race([toolsPromise, abortPromise])) as
| PromiseSettledResult<MetonaToolResult>[]
| null;
// 清理 abort 监听器,避免事件循环中残留
if (onAbort && controller && raceResult !== null) {
controller.signal.removeEventListener('abort', onAbort);
}
return raceResult;
}
/**
* 安全执行工具调用(经过 Hook 管道)
*/
@@ -627,12 +668,32 @@ export class AgentLoopEngine extends EventEmitter {
}
}
// H-5 修复: 精确保护工作空间根目录的 MEMORY.md
// @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected;
// subdirectory MEMORY.md files are unrestricted
// 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md,
// 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。
// run_command 由 permissions.ts 的粗粒度正则保留保护(命令解析复杂)。
if (['read_file', 'write_file', 'file_editor'].includes(toolCall.name)) {
if (this.isTargetingRootMemoryMd(toolCall)) {
return {
toolCallId: toolCall.id, toolName: toolCall.name,
result: null, success: false,
error: 'Access to workspace root MEMORY.md is protected by security policy',
durationMs: Date.now() - startTs, timestamp: Date.now(),
};
}
}
// 执行工具(带超时)
// 兜底超时取 max(配置值, 工具自定义 timeoutMs),确保工具能跑满自己声明的超时
const configuredTimeout = this.config.toolExecutionTimeoutMs ?? 120_000;
const toolDef = this.toolRegistry.get(toolCall.name)?.definition;
const toolTimeout = Math.max(configuredTimeout, toolDef?.timeoutMs ?? 0);
let toolResult: MetonaToolResult;
// M-16 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积
// 默认 120 秒超时下,多轮迭代会堆积大量未触发 timer
let engineTimer: ReturnType<typeof setTimeout> | undefined;
try {
toolResult = await Promise.race([
this.toolRegistry.execute(toolCall, {
@@ -641,9 +702,12 @@ export class AgentLoopEngine extends EventEmitter {
iteration: this.currentIteration,
requestId: this.currentRequestId,
}),
new Promise<MetonaToolResult>((_, reject) =>
setTimeout(() => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)), toolTimeout),
),
new Promise<MetonaToolResult>((_, reject) => {
engineTimer = setTimeout(
() => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)),
toolTimeout,
);
}),
]);
} catch (err) {
toolResult = {
@@ -660,6 +724,9 @@ export class AgentLoopEngine extends EventEmitter {
await hook.afterExecute(toolCall, toolResult, this.currentSessionId);
}
return toolResult;
} finally {
// M-16 修复: 清理未触发的 timeout timer
if (engineTimer) clearTimeout(engineTimer);
}
// 后置 Hook 管道
@@ -670,6 +737,38 @@ export class AgentLoopEngine extends EventEmitter {
return toolResult;
}
/**
* H-5 修复: 检查工具调用是否针对工作空间根目录的 MEMORY.md
*
* @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected;
* subdirectory MEMORY.md files are unrestricted
*
* 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md,
* 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。
*
* @param toolCall 工具调用
* @returns 是否指向工作空间根目录的 MEMORY.md
*/
private isTargetingRootMemoryMd(toolCall: MetonaToolCall): boolean {
if (!this.workspacePath) return false;
// 提取工具参数中的路径(不同工具使用不同的参数名)
const args = toolCall.args;
const pathStr = (args.path as string) || (args.file_path as string) ||
(args.filePath as string) || (args.file as string) ||
(args.target as string) || (args.destination as string);
if (!pathStr || typeof pathStr !== 'string') return false;
// 解析路径,判断是否指向工作空间根目录的 MEMORY.md
// 使用 toLowerCase 处理 Windows 不区分大小写的文件系统
const resolved = resolve(pathStr).toLowerCase();
const rootMemoryPath = resolve(this.workspacePath, 'MEMORY.md').toLowerCase();
// 精确匹配:路径必须等于 {workspacePath}/MEMORY.md
return resolved === rootMemoryPath;
}
/**
* 带重试的流式调用(v0.2.0: 指数退避)
*
@@ -687,10 +786,11 @@ export class AgentLoopEngine extends EventEmitter {
try {
// 首次尝试直接 yield
if (attempt === 0) {
yield* this.adapter.chatStream(request);
yield* this.adapter.sendStream(request);
return;
}
// 重试时:先发送一个 retry 事件,让 UI 清空已接收的 delta
// H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'RETRY' as never,移除不安全的类型断言
yield {
type: MetonaStreamEventType.ERROR,
requestId: request.meta.requestId,
@@ -699,12 +799,12 @@ export class AgentLoopEngine extends EventEmitter {
seq: 0,
timestamp: Date.now(),
error: {
code: 'RETRY' as never,
code: MetonaErrorCode.RETRY,
message: `Retrying after error (attempt ${attempt + 1}/${this.config.retryCount + 1})`,
retryable: true,
},
} as MetonaStreamEvent;
yield* this.adapter.chatStream(request);
};
yield* this.adapter.sendStream(request);
return;
} catch (error) {
lastError = error;
@@ -861,7 +961,7 @@ export class AgentLoopEngine extends EventEmitter {
// 不截断单条消息——摘要请求是独立 API 调用,不共享主对话上下文窗口
const conversationText = toCompress.map((m) => {
const role = m.role.toUpperCase();
return `[${role}] ${m.content}`;
return `[${role}] ${m.content ?? ''}`;
}).join('\n\n');
const summaryRequest: MetonaRequest = {
@@ -892,7 +992,7 @@ export class AgentLoopEngine extends EventEmitter {
};
try {
const response = await this.adapter.chat(summaryRequest);
const response = await this.adapter.send(summaryRequest);
const summary = response.content.trim();
if (!summary) return null;
+14 -3
View File
@@ -28,6 +28,10 @@ export class ConfirmationHook implements PreToolHook {
/** 需要确认的风险等级 */
private static REQUIRES_CONFIRMATION = ['high', 'critical'];
/** L-12 修复: 确认超时范围魔法数字提取为命名常量(30 秒 ~ 600 秒) */
private static readonly MIN_CONFIRMATION_TIMEOUT_MS = 30_000;
private static readonly MAX_CONFIRMATION_TIMEOUT_MS = 600_000;
/** 工具定义缓存(由外部设置) */
private toolDefs = new Map<string, MetonaToolDef>();
@@ -112,8 +116,11 @@ export class ConfirmationHook implements PreToolHook {
try {
const timeout = this.configService.get<number>('agent.confirmationTimeoutMs');
if (timeout != null) {
// 限制范围:30 秒 ~ 600 秒
this.confirmationTimeoutMs = Math.min(600_000, Math.max(30_000, timeout));
// L-12 修复: 使用命名常量替代魔法数字
this.confirmationTimeoutMs = Math.min(
ConfirmationHook.MAX_CONFIRMATION_TIMEOUT_MS,
Math.max(ConfirmationHook.MIN_CONFIRMATION_TIMEOUT_MS, timeout),
);
}
} catch {
// 配置加载失败,使用默认值
@@ -122,7 +129,11 @@ export class ConfirmationHook implements PreToolHook {
/** 设置确认超时时间(运行时更新) */
setConfirmationTimeout(ms: number): void {
this.confirmationTimeoutMs = Math.min(600_000, Math.max(30_000, ms));
// L-12 修复: 使用命名常量替代魔法数字
this.confirmationTimeoutMs = Math.min(
ConfirmationHook.MAX_CONFIRMATION_TIMEOUT_MS,
Math.max(ConfirmationHook.MIN_CONFIRMATION_TIMEOUT_MS, ms),
);
}
/** 获取当前确认超时时间 */
+3 -1
View File
@@ -53,7 +53,9 @@ export class RateLimitHook implements PreToolHook {
}
const entry = this.callCounts.get(key);
if (entry && entry.resetTime >= now) {
// L-2 修复: 使用 > 而非 >=,确保窗口到期时正确重置(边界条件)
// 当 resetTime === now 时应视为已到期,进入 else 分支重建 entry
if (entry && entry.resetTime > now) {
if (entry.count >= this.maxCallsPerMinute) {
return { blocked: true, reason: `Rate limit exceeded for tool "${toolCall.name}"` };
}
+12 -5
View File
@@ -219,11 +219,18 @@ export class MemoryConsolidator {
try {
// v0.3.0 修复: 添加 30 秒超时保护,防止 LLM 响应缓慢导致 consolidator 任务挂起
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('LLM extraction timeout')), 30_000),
);
const response = await Promise.race([this.adapter.chat(request), timeoutPromise]);
return response.content.trim();
// M-17 修复: 使用 try/finally 清理 setTimeout,防止每次会话结束时 timer 堆积
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('LLM extraction timeout')), 30_000);
});
const response = await Promise.race([this.adapter.send(request), timeoutPromise]);
return response.content.trim();
} finally {
// M-17 修复: LLM 调用正常完成时清理未触发的 30 秒 timer
if (timer) clearTimeout(timer);
}
} catch (error) {
log.warn('[MemoryConsolidator] LLM call failed:', (error as Error).message);
return null;
+83 -66
View File
@@ -184,6 +184,61 @@ export class MemoryManager {
}
}
/**
* L-5 修复: 提取 scoreAndPushMemory 辅助函数
*
* 计算 TF-IDF 余弦相似度并应用时间衰减和重要度权重,
* 将分数 > 0 的记忆 push 到 results 数组。
*
* 三种记忆类型(episodic/semantic/working)的评分逻辑统一调用此函数,
* 仅在调用前构造 docText/createdAt/importance 等参数。
*
* @param params - 评分参数
* @param results - 结果数组(push 到此数组)
*/
private scoreAndPushMemory(
params: {
docText: string;
createdAt: number;
importance: number;
id: string;
type: MemoryType;
content: string;
summary?: string;
source: MemorySource;
sessionId?: string;
expiresAt?: number;
},
queryTF: Map<string, number>,
queryNorm: number,
now: number,
results: SearchResult[],
): void {
const docTokens = tokenize(params.docText);
const docTF = computeTF(docTokens);
const docNorm = vectorNorm(docTF, this.idfCache);
if (docNorm === 0) return;
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
const cosineSim = dotProd / (queryNorm * docNorm);
// 时间衰减
const decayWeight = timeDecayWeight(params.createdAt, now);
// 最终分数 = 余弦相似度 * 时间衰减 * 重要度权重
const finalScore = cosineSim * decayWeight * (0.5 + params.importance * 0.5);
if (finalScore > 0) {
results.push({
id: params.id, type: params.type, content: params.content,
summary: params.summary, source: params.source,
importance: params.importance, sessionId: params.sessionId,
createdAt: params.createdAt, expiresAt: params.expiresAt,
score: finalScore,
});
}
}
/**
* TF-IDF 相似度搜索
*/
@@ -202,6 +257,8 @@ export class MemoryManager {
const results: SearchResult[] = [];
const now = Date.now();
// L-5 修复: 三段搜索统一调用 scoreAndPushMemory,消除重复的 tokenize/computeTF/vectorNorm/dotProduct 逻辑
// 搜索 episodic 记忆
if (!type || type === 'episodic') {
const rows = db.prepare(`
@@ -213,30 +270,16 @@ export class MemoryManager {
}>;
for (const row of rows) {
const docText = row.content + ' ' + (row.summary ?? '');
const docTokens = tokenize(docText);
const docTF = computeTF(docTokens);
const docNorm = vectorNorm(docTF, this.idfCache);
if (docNorm === 0) continue;
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
const cosineSim = dotProd / (queryNorm * docNorm);
// 时间衰减
const decayWeight = timeDecayWeight(row.created_at, now);
// 最终分数 = 余弦相似度 * 时间衰减 * 重要度权重
const finalScore = cosineSim * decayWeight * (0.5 + row.importance * 0.5);
if (finalScore > 0) {
results.push({
id: row.id, type: 'episodic', content: row.content,
summary: row.summary ?? undefined, source: row.source as MemorySource,
importance: row.importance, sessionId: row.session_id ?? undefined,
createdAt: row.created_at, expiresAt: row.expires_at ?? undefined,
score: finalScore,
});
}
this.scoreAndPushMemory({
docText: row.content + ' ' + (row.summary ?? ''),
createdAt: row.created_at,
importance: row.importance,
id: row.id, type: 'episodic', content: row.content,
summary: row.summary ?? undefined,
source: row.source as MemorySource,
sessionId: row.session_id ?? undefined,
expiresAt: row.expires_at ?? undefined,
}, queryTF, queryNorm, now, results);
}
}
@@ -251,27 +294,14 @@ export class MemoryManager {
}>;
for (const row of rows) {
const docText = row.key + ' ' + row.value;
const docTokens = tokenize(docText);
const docTF = computeTF(docTokens);
const docNorm = vectorNorm(docTF, this.idfCache);
if (docNorm === 0) continue;
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
const cosineSim = dotProd / (queryNorm * docNorm);
const decayWeight = timeDecayWeight(row.created_at, now);
const finalScore = cosineSim * decayWeight * (0.5 + row.confidence * 0.5);
if (finalScore > 0) {
results.push({
id: row.id, type: 'semantic', content: row.value,
source: 'imported', importance: row.confidence,
sessionId: row.source_session ?? undefined,
createdAt: row.created_at, score: finalScore,
});
}
this.scoreAndPushMemory({
docText: row.key + ' ' + row.value,
createdAt: row.created_at,
importance: row.confidence,
id: row.id, type: 'semantic', content: row.value,
source: 'imported',
sessionId: row.source_session ?? undefined,
}, queryTF, queryNorm, now, results);
}
}
@@ -285,27 +315,14 @@ export class MemoryManager {
}>;
for (const row of rows) {
const docText = row.key + ' ' + row.value;
const docTokens = tokenize(docText);
const docTF = computeTF(docTokens);
const docNorm = vectorNorm(docTF, this.idfCache);
if (docNorm === 0) continue;
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
const cosineSim = dotProd / (queryNorm * docNorm);
const decayWeight = timeDecayWeight(row.updated_at, now);
const finalScore = cosineSim * decayWeight * 0.5;
if (finalScore > 0) {
results.push({
id: row.id, type: 'working', content: row.value,
source: 'agent_thought', importance: 0.5,
sessionId: row.session_id, createdAt: row.updated_at,
score: finalScore,
});
}
this.scoreAndPushMemory({
docText: row.key + ' ' + row.value,
createdAt: row.updated_at,
importance: 0.5,
id: row.id, type: 'working', content: row.value,
source: 'agent_thought',
sessionId: row.session_id,
}, queryTF, queryNorm, now, results);
}
}
@@ -61,6 +61,19 @@ export class TaskOrchestrator extends EventEmitter {
super();
}
/**
* L-18 修复: 热更新 SubAgent 的默认配置
*
* 主 Agent 的配置变更(thinkingEnabled/thinkingEffort/contextLength 等)通过
* engine.updateConfig() 即时生效;但 SubAgent 在 delegate() 时从 defaultConfig
* 复制配置,若 defaultConfig 不同步,新创建的 SubAgent 仍使用旧配置。
*
* 此方法供 handlers.ts 在 config:set 时同步调用,确保后续 SubAgent 使用最新配置。
*/
updateDefaultConfig(partial: Partial<AgentLoopConfig>): void {
this.defaultConfig = { ...this.defaultConfig, ...partial };
}
/**
* 委派子任务
*
+22 -4
View File
@@ -29,12 +29,15 @@ export interface PermissionPolicy {
export const DEFAULT_POLICIES: PermissionPolicy[] = [
// v0.3.0 修复:deniedPatterns 使用 (?:\/|["'\s,}]|$) 匹配,
// 覆盖 /etc/ 和 /etc(无尾斜杠,在 JSON 字符串中后跟引号的情况)
{ toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i, /MEMORY\.md/i] },
// 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: '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, /MEMORY\.md/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: 3 },
{ toolName: 'web_fetch', requiredLevel: PermissionLevel.READ },
@@ -43,7 +46,7 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
{ 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, /MEMORY\.md/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)
@@ -52,6 +55,10 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
{ toolName: 'task_manager', requiredLevel: PermissionLevel.WRITE },
// delegate_task — 子任务委派(启动 SubAgentEXTERNAL_ACTION
{ 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 },
];
export class PolicyEngine {
@@ -90,7 +97,18 @@ export class PolicyEngine {
level: PermissionLevel;
requiresConfirmation: boolean;
} {
const policy = this.policies.get(toolName);
let policy = this.policies.get(toolName);
// C-7 修复: 支持通配符策略匹配(如 mcp_* 匹配所有 MCP 工具)
// MCP 工具名称动态生成(mcp_{serverName}_{toolName}),无法预先配置精确策略
if (!policy) {
for (const [pattern, p] of this.policies) {
if (pattern.endsWith('*') && toolName.startsWith(pattern.slice(0, -1))) {
policy = p;
break;
}
}
}
if (!policy) {
return {
+23 -15
View File
@@ -94,46 +94,54 @@ export class SandboxManager {
* fork bomb、PowerShell 编码执行、环境变量窃取、编码绕过等。
*/
scanCode(code: string): { safe: boolean; reason?: string } {
// C-5 修复: 补齐 28 个模式 + 加强 base64/$() 检测
// @see project_memory.md — sandbox scanCode must include 28 patterns with 'i' flag
// and base64/$() detection to prevent encoding bypass
const dangerousPatterns = [
// 危险模块导入
// 危险模块导入3
/require\s*\(\s*['"]child_process['"]\s*\)/i,
/import\s+.*from\s+['"]fs['"]/i,
/import\s+.*from\s+['"]child_process['"]/i,
// 代码执行
// 代码执行3
/\beval\s*\(/i,
/process\.exit/i,
/Function\s*\(/i,
// 路径遍历
// 路径遍历2
/\.\.\//i,
/\\\.\.\\/i, // Windows ..\
// 危险命令
// 危险命令3
/\brm\s+-rf\b/i,
/\bkillall\s+-9\b/i,
/\bchown\s+-R\s+\//i,
// 重定向到系统目录
// 重定向到系统目录2
/>\s*\/dev\/null/i,
/>\s*\/etc\//i,
// 管道执行
// 管道执行2
/\bcurl\b.*\|\s*(bash|sh|zsh)\b/i,
/\bwget\b.*\|\s*(sh|bash|zsh)\b/i,
// 反向 shell
// 反向 shell3
/\/bin\/(bash|sh)\s+-i/i,
/\bnc\s+-e\b/i,
/\bbash\s+-i\b/i,
// Fork bomb
// Fork bomb1
/:\(\)\s*\{\s*:\|:\s*&\s*\};:/i,
// PowerShell 编码执行
// PowerShell 编码执行1
/powershell.*-enc(odedCommand)?\s+/i,
// 环境变量窃取
// 环境变量窃取1
/env\b.*\b(GITHUB_TOKEN|API_KEY|SECRET|PASSWORD)\b/i,
// 编码绕过检测
/\bbase64\b.*\|\s*(sh|bash|zsh)\b/i,
// 编码绕过检测4)— C-5 加强 base64 解码后执行 + 任意 $() 替换
// 检测 base64 解码(-d 或 --decode)后管道到 shell
/\bbase64\b.*(-d|--decode)?\b.*\|\s*(sh|bash|zsh)\b/i,
/\batob\s*\(/i,
/\bprintf\s+['"]\\x[0-9a-f]/i,
// 命令替换
/\$\([^)]*(rm|kill|del|format|mkfs)\b/i,
// heredoc 执行
// 检测任意 $() 命令替换中包含危险命令(扩展检测范围)
/\$\([^)]*(rm|kill|del|format|mkfs|chmod|chown|curl|wget|nc|bash|sh)\b/i,
// heredoc 执行1
/<<\s*(EOF|END)\s*[\s\S]*?\b(rm|kill|del|format|mkfs)\b/i,
// C-5 新增模式 1: Python -c 执行危险代码
/\bpython3?\b.*-c\s+['"]\s*(import\s+(os|subprocess|shutil)|exec\s*\(|eval\s*\()/i,
// C-5 新增模式 2: Node.js -e 执行危险代码
/\bnode\b.*-e\s+['"]\s*(require\s*\(\s*['"]child_process|process\.exit|execSync|spawnSync)/i,
];
for (const pattern of dangerousPatterns) {
+123 -5
View File
@@ -8,13 +8,17 @@
* 2. 通过 SandboxManager.validatePath 校验工作目录
* 3. 命令注入模式检测扩展
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配
* C-4 修复(v0.3.1):双层命令注入防御
* 1. 主层 — 使用 shell-quote 解析命令为 token 数组,对每个 token 做模式匹配
* 2. 补充层 — 保留原正则检测作为 fallback,覆盖 Windows cmd 语法
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 + run_command 安全规则
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import { resolve } from 'path';
import { parse as shellQuoteParse } from 'shell-quote';
import log from 'electron-log';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
@@ -161,8 +165,13 @@ export class RunCommandTool implements IMetonaTool {
/**
* 命令安全校验
*
* 使用模式匹配检查危险命令。
* C-4 修复: 采用双层防御
* 1. 主层 — 使用 shell-quote 解析命令为 token 数组,对每个 token 做模式匹配
* 可防御所有字符串拼接绕过(如 `r"m" -rf /`、`$'rm'`、`$(echo rm)`、`r''m`
* 2. 补充层 — 保留原正则检测作为 fallback,覆盖 Windows cmd 语法和 shell-quote 无法解析的场景
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — run_command 安全规则
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配)
*/
private validateCommand(command: string): { allowed: boolean; reason?: string } {
const cmd = command.trim().toLowerCase();
@@ -172,8 +181,16 @@ export class RunCommandTool implements IMetonaTool {
return { allowed: false, reason: 'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via command execution' };
}
// 硬阻止列表(绝对禁止执行)
// v0.2.0: 扩展危险命令检测模式
// ===== 主层: shell-quote token-level 检测 =====
// 解析失败(Windows cmd 语法等)时降级到正则补充层
const tokenBlock = this.checkTokens(command);
if (tokenBlock !== null) return tokenBlock;
// ===== 补充层: 原正则检测(保留所有原模式) =====
// 覆盖 shell-quote 无法解析的场景:
// - Windows cmd 语法(&、|、>nul、chcp 等)
// - 复杂管道序列(curl ... | sh
// - Fork bomb 等特殊语法
const hardBlocks = [
// 文件系统破坏
{ pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' },
@@ -218,4 +235,105 @@ export class RunCommandTool implements IMetonaTool {
return { allowed: true };
}
/**
* C-4 修复: shell-quote token-level 安全检测
*
* 将命令解析为 token 数组,提取所有命令名和参数(忽略 shell 运算符),
* 对每个 token 做精确匹配。可防御所有字符串拼接绕过:
* - `r"m" -rf /` → 解析为 ['rm', '-rf', '/'] → 命中 rm 检测
* - `$'rm'` → 解析为 ['rm'] → 命中 rm 检测
* - `$(echo rm) -rf /` → 命令替换会被 shell-quote 识别为运算符序列
*
* @returns null 表示通过检测;非 null 表示被阻止(含 reason
*/
private checkTokens(command: string): { allowed: boolean; reason?: string } | null {
let tokens: ReturnType<typeof shellQuoteParse>;
try {
tokens = shellQuoteParse(command);
} catch {
// 解析失败(Windows cmd 语法、不完整的引号等)— 降级到正则补充层
return null;
}
// 提取所有 word token(命令名和参数),忽略运算符(&&、|、; 等)
// 同时跟踪管道运算符,检测危险组合(如 `| sh`)
const words: string[] = [];
let prevWasPipe = false;
for (const entry of tokens) {
if (typeof entry === 'string') {
// 裸字符串 token — 若前一 token 是管道,检测是否为 sh/bash(远程代码执行)
if (prevWasPipe && /^(ba)?sh$/i.test(entry)) {
return { allowed: false, reason: 'Remote code execution via pipe is forbidden' };
}
words.push(entry);
prevWasPipe = false;
} else if (typeof entry === 'object' && entry !== null) {
const obj = entry as { word?: unknown; op?: unknown };
if (typeof obj.word === 'string') {
// 引号包裹或变量替换后的 token
if (prevWasPipe && /^(ba)?sh$/i.test(obj.word)) {
return { allowed: false, reason: 'Remote code execution via pipe is forbidden' };
}
words.push(obj.word);
prevWasPipe = false;
} else if (typeof obj.op === 'string') {
// 跟踪管道运算符,用于下一轮检测 `| sh`
prevWasPipe = (obj.op === '|');
}
}
}
// 危险命令名 token(精确匹配,大小写不敏感)
const dangerousCommands = new Set([
'sudo', 'su', 'doas',
'shutdown', 'reboot', 'halt', 'poweroff',
'mkfs', 'fdisk', 'format', 'diskpart',
]);
// 危险参数 token
const dangerousArgs = new Set([
'-enc', '-encodedcommand', // PowerShell 编码执行
]);
for (const word of words) {
const lower = word.toLowerCase();
// 1. 危险命令名(精确匹配,或 mkfs/fdisk 前缀匹配如 mkfs.ext4
if (dangerousCommands.has(lower) || /^(mkfs|fdisk)\b/.test(lower)) {
if (['sudo', 'su', 'doas'].includes(lower)) {
return { allowed: false, reason: 'Privilege escalation commands are forbidden' };
}
if (['shutdown', 'reboot', 'halt', 'poweroff'].includes(lower)) {
return { allowed: false, reason: 'System shutdown commands are forbidden' };
}
// mkfs/fdisk/format/diskpart + mkfs.ext4 等前缀匹配
return { allowed: false, reason: 'Disk formatting commands are forbidden' };
}
// 2. 危险参数(精确匹配)
if (dangerousArgs.has(lower)) {
return { allowed: false, reason: 'PowerShell encoded command execution is forbidden' };
}
// 3. dd 写设备文件:of=/dev/...(不依赖系统目录前置,独立检测)
if (/^of=\/dev\//.test(lower)) {
return { allowed: false, reason: 'Writing to device files is forbidden' };
}
}
// 4. 检测 rm 与系统目录的组合(token 序列检测)
// 例如: ['rm', '-rf', '/etc'] 应被拦截
let hasRm = false;
let hasSystemPath = false;
for (const word of words) {
const lower = word.toLowerCase();
if (lower === 'rm') hasRm = true;
if (/^\/(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/.test(lower)) {
hasSystemPath = true;
}
}
if (hasRm && hasSystemPath) {
return { allowed: false, reason: 'rm on system directories is forbidden' };
}
return null;
}
}
+24 -1
View File
@@ -9,6 +9,7 @@
*/
import { resolve, sep } from 'path';
import { realpathSync } from 'fs';
/**
* 受保护文件名列表(工作空间根目录)
@@ -43,6 +44,14 @@ export function isProtectedWorkspaceFile(
*
* 修复前缀碰撞漏洞:`/home/user/app-evil` 不应被误判为在 `/home/user/app` 内。
*
* M-22 修复: 添加 realpathSync 二次校验防止符号链接逃逸
* 攻击场景:工作空间内创建符号链接 `ln -s /etc/passwd workspace/leak.txt`
* 字符串校验会通过(leak.txt 在 workspace 内),但实际读取的是 /etc/passwd。
*
* 注意:realpathSync 在路径不存在时会抛 ENOENT,此时降级为字符串校验
* write_file 的目标文件可能尚不存在,无法 realpath)。
*
* @see project_memory.md — sandbox validatePath must perform realpathSync secondary check
* @param filePath 用户传入的文件路径
* @param workspacePath 当前工作空间根路径
* @returns true 如果路径在工作空间内
@@ -53,7 +62,21 @@ export function isPathWithinWorkspace(
): boolean {
const resolved = resolve(workspacePath, filePath);
const workspaceRoot = resolve(workspacePath);
return resolved === workspaceRoot || resolved.startsWith(workspaceRoot + sep);
// 第一层:字符串前缀校验(快速路径)
const stringCheck = resolved === workspaceRoot || resolved.startsWith(workspaceRoot + sep);
if (!stringCheck) return false;
// 第二层:realpathSync 二次校验(防范符号链接逃逸)
// 仅对实际存在的路径做 realpath 校验;不存在的路径(如 write_file 目标)降级为字符串校验
try {
const realResolved = realpathSync(resolved);
const realWorkspaceRoot = realpathSync(workspaceRoot);
return realResolved === realWorkspaceRoot || realResolved.startsWith(realWorkspaceRoot + sep);
} catch {
// 路径不存在(ENOENT)或 realpath 失败 → 降级为字符串校验结果
return stringCheck;
}
}
/**
@@ -12,7 +12,7 @@
* @see standard/开发规范.md — 使用 fs/path 内置模块
*/
import { readFile, writeFile, readdir, stat, appendFile, mkdir, open } from 'fs/promises';
import { readFile, writeFile, readdir, stat, appendFile, mkdir, open, type FileHandle } from 'fs/promises';
import { join, relative, resolve, dirname } from 'path';
import { existsSync } from 'fs';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
@@ -42,11 +42,12 @@ function matchGlob(name: string, glob: string): boolean {
/** 二进制文件检测:读取前 8KB 检查是否含 NULL 字节 */
async function isBinaryFile(filePath: string): Promise<boolean> {
// M-20 修复: 使用 finally 块确保文件句柄关闭,防止 fd 泄漏
let fd: FileHandle | null = null;
try {
const buffer = Buffer.alloc(8192);
const fd = await open(filePath, 'r');
fd = await open(filePath, 'r');
await fd.read(buffer, 0, 8192, 0);
await fd.close();
// 含 NULL 字节 → 二进制
for (let i = 0; i < buffer.length; i++) {
if (buffer[i] === 0) return true;
@@ -54,6 +55,11 @@ async function isBinaryFile(filePath: string): Promise<boolean> {
return false;
} catch {
return false;
} finally {
// M-20 修复: 无论 read 成功或失败,都关闭文件句柄
if (fd) {
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
}
}
}
@@ -90,17 +90,45 @@ export async function fetchWithTimeout(
// ===== URL 标准化(去重用) =====
/**
* H-6 增强: URL 标准化用于搜索结果去重
*
* 规范推荐使用 normalize-url 库(@see docs/Agent网络工具通用设计-v2.md §6.1.3),
* 但当前实现已覆盖核心场景,且避免 ESM-only 依赖兼容性风险,
* 故在现有基础上增强以下能力(对标 normalize-url 默认行为):
* 1. 去除追踪参数(utm_*, gclid, fbclid
* 2. 强制小写 host
* 3. 去除尾部斜杠(根路径除外)
* 4. H-6 新增: 去除默认端口(http→:80, https→:443
* 5. H-6 新增: 排序查询参数(避免 ?a=1&b=2 vs ?b=2&a=1 被视为不同 URL
*/
export function normalizeUrl(url: string): string {
try {
const u = new URL(url);
// 去除追踪参数
const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid'];
for (const p of trackingParams) u.searchParams.delete(p);
// H-6 增强: 排序查询参数(确保参数顺序一致,便于去重)
// searchParams.sort() 原地排序,URLSearchParams 按码点顺序
u.searchParams.sort();
// 去除尾部斜杠(根路径除外)
let path = u.pathname;
if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
// H-6 增强: 去除默认端口
// http 默认 :80, https 默认 :443, ws 默认 :80, wss 默认 :443
const isDefaultPort =
(u.protocol === 'http:' && u.port === '80') ||
(u.protocol === 'https:' && u.port === '443') ||
(u.protocol === 'ws:' && u.port === '80') ||
(u.protocol === 'wss:' && u.port === '443');
const portSuffix = isDefaultPort ? '' : (u.port ? `:${u.port}` : '');
// 强制小写 host
return `${u.protocol}//${u.host.toLowerCase()}${path}${u.search}${u.hash}`;
return `${u.protocol}//${u.hostname.toLowerCase()}${portSuffix}${path}${u.search}${u.hash}`;
} catch {
return url;
}
+48 -19
View File
@@ -40,6 +40,10 @@ export class WebFetchTool implements IMetonaTool {
type: 'object',
properties: {
url: { type: 'string', description: 'Target URL (http/https only)' },
// H-3/H-4 修复: 补齐规范要求的 max_chars 和 extract_mode 参数
// @see docs/Agent网络工具通用设计-v2.md — 第 3 章 web_fetch 抓取设计
max_chars: { type: 'number', description: 'Maximum characters to return (default 50000, truncated with notice)' },
extract_mode: { type: 'string', enum: ['text', 'html'], description: 'Content extraction mode: "text"=plain text (default), "html"=cleaned HTML with scripts/styles removed' },
mobile_ua: { type: 'boolean', description: 'Use mobile User-Agent (default false)' },
retry: { type: 'boolean', description: 'Enable retry with exponential backoff (default true)' },
},
@@ -55,6 +59,9 @@ export class WebFetchTool implements IMetonaTool {
const url = args.url as string;
const mobileUA = (args.mobile_ua as boolean) ?? false;
const enableRetry = (args.retry as boolean) ?? true;
// H-3/H-4 修复: 读取 max_chars 和 extract_mode 参数
const maxChars = (args.max_chars as number) ?? 50_000;
const extractMode = ((args.extract_mode as string) ?? 'text') as 'text' | 'html';
if (!url || !/^https?:\/\//i.test(url)) {
return { url, content: '', success: false, error: 'URL must start with http:// or https://' };
@@ -64,7 +71,7 @@ export class WebFetchTool implements IMetonaTool {
const cached = fetchCache.get(url);
if (cached) {
logTool('web_fetch', `Cache hit: ${url}`);
return { url, content: cached, success: true, method: 'cache', length: cached.length };
return this.buildSuccess(url, cached, 'cache', maxChars);
}
logTool('web_fetch', `Fetching: ${url}`);
@@ -73,24 +80,29 @@ export class WebFetchTool implements IMetonaTool {
const phase1Result = await this.httpFetch(url, mobileUA, enableRetry);
if (phase1Result.success && !phase1Result.intercepted) {
// 内容过短检测 → Phase 2 升级
if (phase1Result.text.length < 200) {
logTool('web_fetch', `Phase 2: Content too short (${phase1Result.text.length} chars), upgrading to browser`);
// 根据 extract_mode 选择返回内容:'html' 模式返回清理后的 HTML'text' 模式返回纯文本
const phase1Content = extractMode === 'html' ? phase1Result.html : phase1Result.text;
// 内容过短检测 → Phase 2 升级(仅对 text 模式生效,html 模式不升级)
if (extractMode === 'text' && phase1Content.length < 200) {
logTool('web_fetch', `Phase 2: Content too short (${phase1Content.length} chars), upgrading to browser`);
const browserResult = await this.browserFetch(url);
if (browserResult) {
return this.buildSuccess(url, browserResult, 'browser');
return this.buildSuccess(url, browserResult, 'browser', maxChars);
}
}
// 写入缓存
fetchCache.set(url, phase1Result.text);
return this.buildSuccess(url, phase1Result.text, 'http');
// 写入缓存(仅缓存 text 模式的内容,html 模式不缓存以避免模式混淆)
if (extractMode === 'text') {
fetchCache.set(url, phase1Content);
}
return this.buildSuccess(url, phase1Content, 'http', maxChars, extractMode);
}
// ===== Phase 3: 浏览器回退 =====
logTool('web_fetch', `Phase 3: Falling back to browser (${phase1Result.reason})`);
const browserResult = await this.browserFetch(url);
if (browserResult) {
return this.buildSuccess(url, browserResult, 'browser');
return this.buildSuccess(url, browserResult, 'browser', maxChars);
}
// 全部失败
@@ -108,7 +120,7 @@ export class WebFetchTool implements IMetonaTool {
url: string,
mobileUA: boolean,
enableRetry: boolean,
): Promise<{ success: boolean; text: string; intercepted: boolean; reason: string }> {
): Promise<{ success: boolean; html: string; text: string; intercepted: boolean; reason: string }> {
const maxRetries = enableRetry ? 3 : 1;
const backoffBase = 2_000;
@@ -119,7 +131,7 @@ export class WebFetchTool implements IMetonaTool {
// 跳过重试的状态码 → 直接进入浏览器回退
if (SKIP_RETRY_STATUS.has(response.status)) {
return { success: false, text: '', intercepted: true, reason: `HTTP ${response.status}` };
return { success: false, html: '', text: '', intercepted: true, reason: `HTTP ${response.status}` };
}
if (!response.ok) {
@@ -128,7 +140,7 @@ export class WebFetchTool implements IMetonaTool {
await this.sleep(backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6);
continue;
}
return { success: false, text: '', intercepted: false, reason: `HTTP ${response.status} ${response.statusText}` };
return { success: false, html: '', text: '', intercepted: false, reason: `HTTP ${response.status} ${response.statusText}` };
}
// 读取正文(10MB 限制)
@@ -136,12 +148,13 @@ export class WebFetchTool implements IMetonaTool {
// 拦截检测
if (isInterceptedPage(html)) {
return { success: false, text: '', intercepted: true, reason: 'Intercepted page detected' };
return { success: false, html: '', text: '', intercepted: true, reason: 'Intercepted page detected' };
}
// HTML → 纯文本
const text = htmlToText(html);
return { success: true, text, intercepted: false, reason: '' };
// H-3/H-4 修复: 同时保留原始 HTML,供 extract_mode='html' 使用
return { success: true, html, text, intercepted: false, reason: '' };
} catch (err) {
const errorMsg = (err as Error).message;
if (attempt < maxRetries - 1) {
@@ -149,11 +162,11 @@ export class WebFetchTool implements IMetonaTool {
await this.sleep(backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6);
continue;
}
return { success: false, text: '', intercepted: false, reason: errorMsg };
return { success: false, html: '', text: '', intercepted: false, reason: errorMsg };
}
}
return { success: false, text: '', intercepted: false, reason: 'All retries exhausted' };
return { success: false, html: '', text: '', intercepted: false, reason: 'All retries exhausted' };
}
// ===== Phase 2/3: 浏览器回退(使用共享 BrowserWindowManager 单例) =====
@@ -214,13 +227,29 @@ export class WebFetchTool implements IMetonaTool {
// ===== 辅助方法 =====
private buildSuccess(url: string, text: string, method: string): unknown {
private buildSuccess(
url: string,
text: string,
method: string,
maxChars?: number,
extractMode?: 'text' | 'html',
): unknown {
// H-3/H-4 修复: 应用 max_chars 截断,防止过长内容消耗过多 token
let content = text;
let truncated = false;
if (maxChars !== undefined && maxChars > 0 && text.length > maxChars) {
content = text.slice(0, maxChars) + `\n\n[... content truncated at ${maxChars} chars ...]`;
truncated = true;
}
return {
url,
content: text,
content,
success: true,
method,
length: text.length,
length: content.length,
original_length: text.length,
truncated,
extract_mode: extractMode ?? 'text',
};
}
+7 -1
View File
@@ -97,12 +97,15 @@ export class ToolRegistry {
const startTs = Date.now();
const timeoutMs = tool.definition.timeoutMs;
// M-15 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积
// 工具正常完成时未触发的 timer 会持续占用事件循环 timeoutMs 毫秒
let timer: ReturnType<typeof setTimeout> | undefined;
try {
// 带超时执行 — 使用 Promise.race 防止工具卡死阻塞 Agent Loop
const result = await Promise.race([
tool.execute(toolCall.args, context),
new Promise<never>((_, reject) => {
setTimeout(
timer = setTimeout(
() => reject(new Error(`Tool execution timed out after ${timeoutMs}ms`)),
timeoutMs,
);
@@ -130,6 +133,9 @@ export class ToolRegistry {
durationMs: Date.now() - startTs,
timestamp: Date.now(),
};
} finally {
// M-15 修复: 无论工具成功或失败,清理 timeout timer
if (timer) clearTimeout(timer);
}
}
+52 -9
View File
@@ -3,11 +3,37 @@
*
* 所有 LLM Provider 适配器必须实现此接口。
*
* H-2 修复: 接口对齐规范
* @see docs/MetonaAI-Desktop 内部API请求与响应标准.html
* - provider → providerId(规范要求)
* - chat → send(规范要求)
* - chatStream → sendStream(规范要求)
* - 新增 getContextWindow()(规范要求)
* - listModels 返回 MetonaModelInfo[](规范要求)
*/
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from './index';
/**
* H-2 修复: 新增 MetonaModelInfo 类型 — 规范要求的模型元信息
*/
export interface MetonaModelInfo {
/** 模型 ID(如 'deepseek-v4-pro' */
id: string;
/** 模型显示名称(如 'DeepSeek V4 Pro' */
name?: string;
/** 上下文窗口大小(token 数) */
contextWindow?: number;
/** 最大输出 token 数 */
maxOutputTokens?: number;
/** 是否支持 Tool Calling */
supportsToolCalling?: boolean;
/** 是否支持 Thinking / Reasoning */
supportsThinking?: boolean;
/** 模型描述 */
description?: string;
}
/**
* Provider 适配器配置
*/
@@ -37,10 +63,10 @@ export interface AdapterConfig {
* 铁律:外部 API 原始类型不得穿透到此接口之外
*/
export interface IMetonaProviderAdapter {
/** Provider 标识 */
readonly provider: string;
/** H-2 修复: Provider 标识(规范要求使用 providerId */
readonly providerId: string;
/** 支持的模型列表 */
/** 支持的模型列表(保留作为便捷访问) */
readonly supportedModels: string[];
/** 是否支持 Tool Calling */
@@ -50,15 +76,32 @@ export interface IMetonaProviderAdapter {
readonly supportsThinking: boolean;
/**
* 发送非流式请求
* H-2 修复: 获取上下文窗口大小(token 数)
* 规范要求的方法,用于 Engine 估算上下文使用率
* @returns 上下文窗口大小(token 数)
*/
chat(request: MetonaRequest): Promise<MetonaResponse>;
getContextWindow(): number;
/**
* 发送流式请求
* H-2 修复: 发送流式请求(规范要求使用 send
*/
send(request: MetonaRequest): Promise<MetonaResponse>;
/**
* H-2 修复: 发送流式请求(规范要求使用 sendStream
* @returns AsyncIterable 逐事件推送 MetonaStreamEvent
*/
chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent>;
sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent>;
/**
* C-2 修复: 注入外部 AbortSignal,用于关联 Engine 的 abortController
*
* Engine 在调用 send/sendStream 前注入 signal,使用户 abort 能中断正在进行的 fetch
* 避免资源泄漏。Adapter 应将此 signal 与自身的 timeout signal 合并。
*
* @see project_memory.md — engine must use AbortController to avoid resource leaks on abort()
*/
setAbortSignal?(signal: AbortSignal | undefined): void;
/**
* 检查 Provider 可用性
@@ -66,7 +109,7 @@ export interface IMetonaProviderAdapter {
healthCheck(): Promise<boolean>;
/**
* 列出可用模型(可选
* H-2 修复: 列出可用模型(规范要求返回 MetonaModelInfo[]
*/
listModels?(): Promise<string[]>;
listModels?(): Promise<MetonaModelInfo[]>;
}
+7 -2
View File
@@ -73,8 +73,13 @@ export interface MetonaConstraints {
export interface MetonaMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
/** 文本内容(纯文本或 Markdown) */
content: string;
/**
* 文本内容(纯文本或 Markdown)
*
* C-6 修复: 允许 null — assistant 消息仅有 tool_calls 时 content 必须为 null
* @see project_memory.md — Assistant messages with tool_calls must set content to null
*/
content: string | null;
/** (仅 assistant)思考/推理内容 */
reasoningContent?: string;
+10
View File
@@ -79,6 +79,11 @@ export interface MetonaResponse {
// ===== 流式事件 =====
export enum MetonaStreamEventType {
// H-1 修复: 补齐 THINKING_START / THINKING_END — 用于显式标记思考阶段的边界
// 规范来源: docs/MetonaAI-Desktop 内部API请求与响应标准.html
// 时序: THINKING_START → REASONING_DELTA* → THINKING_END → TEXT_DELTA*
THINKING_START = 'thinking_start',
THINKING_END = 'thinking_end',
TEXT_DELTA = 'text_delta',
REASONING_DELTA = 'reasoning_delta',
TOOL_CALL_DELTA = 'tool_call_delta',
@@ -188,4 +193,9 @@ export enum MetonaErrorCode {
USER_ABORTED = 'user_aborted',
TIMEOUT = 'timeout',
UNKNOWN = 'unknown',
// H-11 修复: Engine 内部使用的伪错误码,用于重试时通知前端清空已累积的 delta 缓冲区
// 注意:这不是真正的错误,仅作为 ERROR 事件的特殊标记,engine 会过滤此事件不转发到 UI
// 之前使用 'RETRY' as never 绕过类型检查,现在改为正式枚举值以确保类型安全
RETRY = 'retry',
}
+17 -8
View File
@@ -17,12 +17,21 @@
// 中日韩统一表意文字 + 全角标点 + 日文假名 + 韩文谚文
const CJK_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/;
/**
* L-17 修复: 提取魔法系数为命名常量,便于统一调整
* @see project_memory.md — Token estimation coefficients
*/
const CJK_TOKEN_RATIO = 1.5; // 中文字符(含全角标点、日韩文):1 字符 ≈ 1.5 token
const ASCII_TOKEN_RATIO = 0.25; // ASCII 字符(英文、数字、半角符号):4 字符 ≈ 1 token
const OTHER_TOKEN_RATIO = 1; // 其他 Unicodeemoji 等):1 字符 ≈ 1 token
const MSG_OVERHEAD_TOKENS = 4; // 每条消息的结构性开销(role、分隔符,参考 OpenAI 规范)
/**
* 估算字符串的 token 数
* @param text 待估算的字符串
* @param text 待估算的字符串(可为 null/undefined,视为 0 token
* @returns 估算的 token 数
*/
export function estimateStringTokens(text: string): number {
export function estimateStringTokens(text: string | null | undefined): number {
if (!text || text.length === 0) return 0;
let cjkCount = 0;
@@ -39,8 +48,8 @@ export function estimateStringTokens(text: string): number {
}
}
// 中文字符 1.5 token/字,ASCII 0.25 token/字,其他 1 token/
return Math.ceil(cjkCount * 1.5 + asciiCount * 0.25 + otherCount);
// L-17 修复: 使用命名常量替代魔法数
return Math.ceil(cjkCount * CJK_TOKEN_RATIO + asciiCount * ASCII_TOKEN_RATIO + otherCount * OTHER_TOKEN_RATIO);
}
/**
@@ -48,11 +57,11 @@ export function estimateStringTokens(text: string): number {
*
* 每条消息额外加 4 token 的结构性开销(role、分隔符等,参考 OpenAI 规范)
*
* @param messages 消息列表
* @param messages 消息列表content 可为 null,对应仅有 tool_calls 的 assistant 消息)
* @returns 估算的 token 数
*/
export function estimateMessagesTokens(messages: Array<{
content: string;
content: string | null;
reasoningContent?: string;
toolCalls?: Array<{ args: Record<string, unknown> }>;
}>): number {
@@ -65,8 +74,8 @@ export function estimateMessagesTokens(messages: Array<{
total += estimateStringTokens(JSON.stringify(tc.args));
}
}
// 每条消息的结构性开销(role、分隔符)
total += 4;
// L-17 修复: 使用命名常量替代魔法数字
total += MSG_OVERHEAD_TOKENS;
}
return total;
}