diff --git a/electron/harness/adapters/agnes-ai.adapter.ts b/electron/harness/adapters/agnes-ai.adapter.ts index 419f12f..33797f4 100644 --- a/electron/harness/adapters/agnes-ai.adapter.ts +++ b/electron/harness/adapters/agnes-ai.adapter.ts @@ -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 = { + '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 { + // H-2 修复: chat → send(规范要求) + async send(request: MetonaRequest): Promise { 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; - 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 { + // H-2 修复: chatStream → sendStream(规范要求) + async *sendStream(request: MetonaRequest): AsyncIterable { 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' }; } // 停止序列 diff --git a/electron/harness/adapters/base-adapter.ts b/electron/harness/adapters/base-adapter.ts index 1528081..a8d5248 100644 --- a/electron/harness/adapters/base-adapter.ts +++ b/electron/harness/adapters/base-adapter.ts @@ -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; - abstract chatStream(request: MetonaRequest): AsyncIterable; + // H-2 修复: chat → send(规范要求) + abstract send(request: MetonaRequest): Promise; + // H-2 修复: chatStream → sendStream(规范要求) + abstract sendStream(request: MetonaRequest): AsyncIterable; + + /** + * 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 { try { @@ -37,8 +101,14 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { } } - async listModels(): Promise { - return this.supportedModels; + /** + * H-2 修复: 返回 MetonaModelInfo[](规范要求) + * + * 默认实现将 supportedModels 映射为 MetonaModelInfo[], + * 子类可覆盖以从 API 获取完整元信息。 + */ + async listModels(): Promise { + 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, }; } diff --git a/electron/harness/adapters/deepseek.adapter.ts b/electron/harness/adapters/deepseek.adapter.ts index 0477a41..a0521e0 100644 --- a/electron/harness/adapters/deepseek.adapter.ts +++ b/electron/harness/adapters/deepseek.adapter.ts @@ -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 = { + '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 { + // H-2 修复: chat → send(规范要求) + async send(request: MetonaRequest): Promise { 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; - 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 { + // H-2 修复: chatStream → sendStream(规范要求) + async *sendStream(request: MetonaRequest): AsyncIterable { 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 { + /** + * H-2 修复: 返回 MetonaModelInfo[](规范要求) + * + * 优先尝试从 API 获取实时模型列表,并合并本地 MODEL_INFO 元数据。 + * API 不可用时回退到 supportedModels。 + */ + async listModels(): Promise { 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 ===== diff --git a/electron/harness/adapters/ollama.adapter.ts b/electron/harness/adapters/ollama.adapter.ts index ede7075..6038b6c 100644 --- a/electron/harness/adapters/ollama.adapter.ts +++ b/electron/harness/adapters/ollama.adapter.ts @@ -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[0]) { @@ -41,14 +48,16 @@ export class OllamaAdapter extends BaseAdapter { // ===== POST /api/chat ===== - async chat(request: MetonaRequest): Promise { + // H-2 修复: chat → send(规范要求) + async send(request: MetonaRequest): Promise { 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 { + // H-2 修复: chatStream → sendStream(规范要求) + async *sendStream(request: MetonaRequest): AsyncIterable { 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 { + /** + * H-2 修复: 返回 MetonaModelInfo[](规范要求) + * + * Ollama /api/tags 返回模型列表含详细信息(name, size, details), + * 转换为 MetonaModelInfo 并补充默认元数据。 + */ + async listModels(): Promise { 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 = { role: m.role, content: m.content }; + // C-6 修复: Ollama API 不支持 null content,assistant 仅有 tool_calls 时转为空字符串 + const msg: Record = { 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, diff --git a/electron/harness/adapters/shared/sse-stream.ts b/electron/harness/adapters/shared/sse-stream.ts index 0911255..5c74458 100644 --- a/electron/harness/adapters/shared/sse-stream.ts +++ b/electron/harness/adapters/shared/sse-stream.ts @@ -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, + requestId: string, + sessionId: string, + iteration: number, + seqRef: { seq: number }, +): Generator { + 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 { 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 { // 跳过解析失败的行 diff --git a/electron/harness/agent-loop/engine.ts b/electron/harness/agent-loop/engine.ts index e812cf1..899f41a 100644 --- a/electron/harness/agent-loop/engine.ts +++ b/electron/harness/agent-loop/engine.ts @@ -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; - 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 => { - 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((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[] - | 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, + ): void { + if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) { + step.toolCalls = []; + for (const [, buf] of toolCallsBuffer) { + let args: Record; + 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[] | null> { + const executeAndForward = async (tc: MetonaToolCall): Promise => { + 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((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[] + | 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 | 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((_, reject) => - setTimeout(() => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)), toolTimeout), - ), + new Promise((_, 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; diff --git a/electron/harness/hooks/confirmation-hook.ts b/electron/harness/hooks/confirmation-hook.ts index b4ccf4f..851441f 100644 --- a/electron/harness/hooks/confirmation-hook.ts +++ b/electron/harness/hooks/confirmation-hook.ts @@ -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(); @@ -112,8 +116,11 @@ export class ConfirmationHook implements PreToolHook { try { const timeout = this.configService.get('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), + ); } /** 获取当前确认超时时间 */ diff --git a/electron/harness/hooks/pre-tool.ts b/electron/harness/hooks/pre-tool.ts index 05df4bf..3eca645 100644 --- a/electron/harness/hooks/pre-tool.ts +++ b/electron/harness/hooks/pre-tool.ts @@ -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}"` }; } diff --git a/electron/harness/memory/consolidator.ts b/electron/harness/memory/consolidator.ts index 61c98bd..8bcb64b 100644 --- a/electron/harness/memory/consolidator.ts +++ b/electron/harness/memory/consolidator.ts @@ -219,11 +219,18 @@ export class MemoryConsolidator { try { // v0.3.0 修复: 添加 30 秒超时保护,防止 LLM 响应缓慢导致 consolidator 任务挂起 - const timeoutPromise = new Promise((_, 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 | undefined; + try { + const timeoutPromise = new Promise((_, 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; diff --git a/electron/harness/memory/manager.ts b/electron/harness/memory/manager.ts index 73eabe6..7068b90 100644 --- a/electron/harness/memory/manager.ts +++ b/electron/harness/memory/manager.ts @@ -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, + 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); } } diff --git a/electron/harness/orchestration/orchestrator.ts b/electron/harness/orchestration/orchestrator.ts index a408faf..f6ddbb9 100644 --- a/electron/harness/orchestration/orchestrator.ts +++ b/electron/harness/orchestration/orchestrator.ts @@ -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): void { + this.defaultConfig = { ...this.defaultConfig, ...partial }; + } + /** * 委派子任务 * diff --git a/electron/harness/sandbox/permissions.ts b/electron/harness/sandbox/permissions.ts index 55d3861..e779132 100644 --- a/electron/harness/sandbox/permissions.ts +++ b/electron/harness/sandbox/permissions.ts @@ -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 — 子任务委派(启动 SubAgent,EXTERNAL_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 { diff --git a/electron/harness/sandbox/sandbox.ts b/electron/harness/sandbox/sandbox.ts index fd57426..abe357f 100644 --- a/electron/harness/sandbox/sandbox.ts +++ b/electron/harness/sandbox/sandbox.ts @@ -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 + // 反向 shell(3) /\/bin\/(bash|sh)\s+-i/i, /\bnc\s+-e\b/i, /\bbash\s+-i\b/i, - // Fork bomb + // Fork bomb(1) /:\(\)\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) { diff --git a/electron/harness/tools/built-in/command.ts b/electron/harness/tools/built-in/command.ts index f06dc3e..cc7a607 100644 --- a/electron/harness/tools/built-in/command.ts +++ b/electron/harness/tools/built-in/command.ts @@ -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; + 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; + } } diff --git a/electron/harness/tools/built-in/file-guard.ts b/electron/harness/tools/built-in/file-guard.ts index f473516..4ad7d33 100644 --- a/electron/harness/tools/built-in/file-guard.ts +++ b/electron/harness/tools/built-in/file-guard.ts @@ -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; + } } /** diff --git a/electron/harness/tools/built-in/filesystem.ts b/electron/harness/tools/built-in/filesystem.ts index 16ea5fb..20c47ad 100644 --- a/electron/harness/tools/built-in/filesystem.ts +++ b/electron/harness/tools/built-in/filesystem.ts @@ -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 { + // 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 { return false; } catch { return false; + } finally { + // M-20 修复: 无论 read 成功或失败,都关闭文件句柄 + if (fd) { + try { await fd.close(); } catch { /* 忽略关闭错误 */ } + } } } diff --git a/electron/harness/tools/built-in/network-utils.ts b/electron/harness/tools/built-in/network-utils.ts index ecfd98e..31a6770 100644 --- a/electron/harness/tools/built-in/network-utils.ts +++ b/electron/harness/tools/built-in/network-utils.ts @@ -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; } diff --git a/electron/harness/tools/built-in/web-fetch.ts b/electron/harness/tools/built-in/web-fetch.ts index e127b7a..b04d079 100644 --- a/electron/harness/tools/built-in/web-fetch.ts +++ b/electron/harness/tools/built-in/web-fetch.ts @@ -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', }; } diff --git a/electron/harness/tools/registry.ts b/electron/harness/tools/registry.ts index 1bcbee4..26e790c 100644 --- a/electron/harness/tools/registry.ts +++ b/electron/harness/tools/registry.ts @@ -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 | undefined; try { // 带超时执行 — 使用 Promise.race 防止工具卡死阻塞 Agent Loop const result = await Promise.race([ tool.execute(toolCall.args, context), new Promise((_, 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); } } diff --git a/electron/harness/types/metona-adapter.ts b/electron/harness/types/metona-adapter.ts index a10edbe..6dcd119 100644 --- a/electron/harness/types/metona-adapter.ts +++ b/electron/harness/types/metona-adapter.ts @@ -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; + getContextWindow(): number; /** - * 发送流式请求 + * H-2 修复: 发送非流式请求(规范要求使用 send) + */ + send(request: MetonaRequest): Promise; + + /** + * H-2 修复: 发送流式请求(规范要求使用 sendStream) * @returns AsyncIterable 逐事件推送 MetonaStreamEvent */ - chatStream(request: MetonaRequest): AsyncIterable; + sendStream(request: MetonaRequest): AsyncIterable; + + /** + * 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; /** - * 列出可用模型(可选) + * H-2 修复: 列出可用模型(规范要求返回 MetonaModelInfo[]) */ - listModels?(): Promise; + listModels?(): Promise; } diff --git a/electron/harness/types/metona-request.ts b/electron/harness/types/metona-request.ts index 25913bc..1266ff2 100644 --- a/electron/harness/types/metona-request.ts +++ b/electron/harness/types/metona-request.ts @@ -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; diff --git a/electron/harness/types/metona-response.ts b/electron/harness/types/metona-response.ts index ba929bb..28114bd 100644 --- a/electron/harness/types/metona-response.ts +++ b/electron/harness/types/metona-response.ts @@ -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', } diff --git a/electron/harness/utils/token-estimator.ts b/electron/harness/utils/token-estimator.ts index bc73c51..a4cdb33 100644 --- a/electron/harness/utils/token-estimator.ts +++ b/electron/harness/utils/token-estimator.ts @@ -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; // 其他 Unicode(emoji 等):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 }>; }>): 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; } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 34d2631..57e597c 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -17,14 +17,15 @@ import type { WorkspaceService } from '../services/workspace.service'; import type { ContextBuilder } from '../harness/prompts/context-builder'; import type { AgentLoopEngine } from '../harness/agent-loop'; import type { ToolRegistry } from '../harness/tools/registry'; -import type { AuditService } from '../services/audit.service'; +import type { AuditService, AuditEventType } from '../services/audit.service'; import type { SessionRecorder } from '../services/session-recorder.service'; -import type { MemoryManager } from '../harness/memory/manager'; +import type { MemoryManager, MemoryType } from '../harness/memory/manager'; import type { MCPManager } from '../services/mcp-manager.service'; import type { PromptInjectionDefender } from '../harness/security/prompt-injection-defense'; import type { OutputValidator } from '../harness/verification/output-validator'; import type { ConfirmationHook } from '../harness/hooks/confirmation-hook'; import type { MemoryConsolidator } from '../harness/memory/consolidator'; +import type { TaskOrchestrator } from '../harness/orchestration/orchestrator'; import type { MetonaMessage, MetonaStreamEvent } from '../harness/types'; import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types'; import type { MetonaError } from '../harness/types'; @@ -51,12 +52,22 @@ export function registerAllIPCHandlers( outputValidator: OutputValidator, confirmationHook: ConfirmationHook, memoryConsolidator: MemoryConsolidator, + orchestrator: TaskOrchestrator, ): void { // ===== Agent 交互 ===== ipcMain.handle('agent:sendMessage', async (_event, userMessage: MetonaMessage, sessionId: string) => { - log.info('[AGENT] sendMessage:', sessionId, userMessage.content.slice(0, 80)); + // M-33 修复: 参数校验,防止 undefined/非字符串导致下游异常 + if (!sessionId || typeof sessionId !== 'string') { + log.warn('[AGENT] sendMessage rejected: invalid sessionId'); + return { success: false, error: 'Invalid sessionId' }; + } + if (!userMessage || typeof userMessage !== 'object' || typeof userMessage.content !== 'string') { + log.warn('[AGENT] sendMessage rejected: invalid userMessage'); + return { success: false, error: 'Invalid message format' }; + } + log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80)); // 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送) if (!reloadAdapter()) { @@ -258,10 +269,16 @@ export function registerAllIPCHandlers( // 只有当有内容、思考内容或工具调用时才保存 if (step.thought.content || step.thought.reasoningContent || toolCallsWithResults?.length) { + // C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串) + // @see project_memory.md — Assistant messages with tool_calls must set content to null + // 空字符串会导致 LLM API 返回 400 错误,必须使用 null + const assistantContent = (toolCallsWithResults?.length && !step.thought.content) + ? null + : step.thought.content; sessionService.saveMessage({ sessionId, role: 'assistant', - content: step.thought.content, + content: assistantContent, reasoningContent: step.thought.reasoningContent || undefined, toolCalls: toolCallsWithResults, iteration: step.iteration, @@ -413,44 +430,81 @@ export function registerAllIPCHandlers( return sessionService.create(title); }); - ipcMain.handle('sessions:rename', async (_event, sessionId, title) => { + // M-34 修复: 统一 sessionId 校验辅助函数 + const isValidSessionId = (id: unknown): id is string => + typeof id === 'string' && id.length > 0 && id.length <= 200; + + ipcMain.handle('sessions:rename', async (_event, sessionId: unknown, title: unknown) => { + // M-34 修复: 校验 sessionId 和 title + if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; + if (typeof title !== 'string' || !title.trim()) return { success: false, error: 'Invalid title' }; return { success: sessionService.rename(sessionId, title) }; }); - ipcMain.handle('sessions:delete', async (_event, sessionId) => { + ipcMain.handle('sessions:delete', async (_event, sessionId: unknown) => { + // M-34 修复: 校验 sessionId + if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; return { success: sessionService.delete(sessionId) }; }); - ipcMain.handle('sessions:getMessages', async (_event, sessionId) => { + ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown) => { + // M-34 修复: 校验 sessionId + if (!isValidSessionId(sessionId)) return []; return sessionService.getMessages(sessionId); }); - ipcMain.handle('sessions:pin', async (_event, sessionId, pinned) => { + ipcMain.handle('sessions:pin', async (_event, sessionId: unknown, pinned: unknown) => { + // M-34 修复: 校验 sessionId 和 pinned + if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; + if (typeof pinned !== 'boolean') return { success: false, error: 'Invalid pinned flag' }; return { success: sessionService.pin(sessionId, pinned) }; }); - ipcMain.handle('sessions:archive', async (_event, sessionId, archived) => { + ipcMain.handle('sessions:archive', async (_event, sessionId: unknown, archived: unknown) => { + // M-34 修复: 校验 sessionId 和 archived + if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; + if (typeof archived !== 'boolean') return { success: false, error: 'Invalid archived flag' }; return { success: sessionService.archive(sessionId, archived) }; }); - ipcMain.handle('sessions:deleteMessage', async (_event, messageId) => { + ipcMain.handle('sessions:deleteMessage', async (_event, messageId: unknown) => { + // M-34 修复: 校验 messageId + if (typeof messageId !== 'string' || !messageId) return { success: false, error: 'Invalid messageId' }; return { success: sessionService.deleteMessage(messageId) }; }); - ipcMain.handle('sessions:clearMessages', async (_event, sessionId) => { + ipcMain.handle('sessions:clearMessages', async (_event, sessionId: unknown) => { + // M-34 修复: 校验 sessionId + if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; return { success: sessionService.clearMessages(sessionId) }; }); - ipcMain.handle('sessions:saveTrace', async (_event, sessionId, data) => { + ipcMain.handle('sessions:saveTrace', async (_event, sessionId: unknown, data: unknown) => { + // M-34 修复: 校验 sessionId 和 data + if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; + // 严格校验 data 结构:traceSteps 必须为数组,tokenUsage 必须存在 + if (!data || typeof data !== 'object') return { success: false, error: 'Invalid trace data' }; + const traceData = data as { traceSteps?: unknown; tokenUsage?: unknown }; + if (!Array.isArray(traceData.traceSteps)) { + return { success: false, error: 'Invalid trace data: traceSteps must be an array' }; + } + if (traceData.tokenUsage === undefined) { + return { success: false, error: 'Invalid trace data: tokenUsage is required' }; + } try { - sessionService.saveTraceData(sessionId, data); + sessionService.saveTraceData(sessionId, { + traceSteps: traceData.traceSteps, + tokenUsage: traceData.tokenUsage, + }); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message }; } }); - ipcMain.handle('sessions:getTrace', async (_event, sessionId) => { + ipcMain.handle('sessions:getTrace', async (_event, sessionId: unknown) => { + // M-34 修复: 校验 sessionId + if (!isValidSessionId(sessionId)) return null; return sessionService.getTraceData(sessionId); }); @@ -461,10 +515,34 @@ export function registerAllIPCHandlers( }); 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' }; + } + try { new URL(config.url); } catch { + return { success: false, error: 'Invalid url format' }; + } + } try { await mcpManager.addServer({ name: config.name, - transport: config.transport as 'stdio' | 'sse', + transport: config.transport, // 已校验,无需断言 command: config.command, args: config.args, url: config.url, @@ -478,6 +556,10 @@ export function registerAllIPCHandlers( }); ipcMain.handle('mcp:removeServer', async (_event, name: string) => { + // M-38 修复: name 校验 + if (typeof name !== 'string' || !name.trim()) { + return { success: false, error: 'Invalid server name' }; + } try { await mcpManager.removeServer(name); return { success: true }; @@ -487,6 +569,13 @@ export function registerAllIPCHandlers( }); ipcMain.handle('mcp:toggleServer', async (_event, name: string, enabled: boolean) => { + // M-38 修复: name 和 enabled 校验 + if (typeof name !== 'string' || !name.trim()) { + return { success: false, error: 'Invalid server name' }; + } + if (typeof enabled !== 'boolean') { + return { success: false, error: 'Invalid enabled flag' }; + } try { await mcpManager.toggleServer(name, enabled); return { success: true }; @@ -497,8 +586,35 @@ export function registerAllIPCHandlers( // ===== 记忆 ===== - ipcMain.handle('db:searchMemories', async (_event, query, options) => { - return memoryManager.search(query, options); + ipcMain.handle('db:searchMemories', async (_event, query: unknown, options: unknown) => { + // M-37 修复: query 和 options 校验 + if (typeof query !== 'string' || !query.trim()) { + return []; + } + // 构造合法的搜索选项(仅保留已知字段,强制类型安全) + const VALID_MEMORY_TYPES: readonly MemoryType[] = ['episodic', 'semantic', 'working']; + const searchOptions: { topK?: number; sessionId?: string; type?: MemoryType; minImportance?: number } = {}; + if (options && typeof options === 'object') { + const opts = options as Record; + // topK 限制范围 1-100(防止过大查询) + if (opts.topK !== undefined) { + const topK = Number(opts.topK); + if (Number.isFinite(topK) && topK >= 1 && topK <= 100) { + searchOptions.topK = topK; + } else { + searchOptions.topK = 10; // 默认值 + } + } + if (typeof opts.sessionId === 'string') searchOptions.sessionId = opts.sessionId; + // type 必须是合法的 MemoryType 枚举值 + if (typeof opts.type === 'string' && (VALID_MEMORY_TYPES as readonly string[]).includes(opts.type)) { + searchOptions.type = opts.type as MemoryType; + } + if (typeof opts.minImportance === 'number' && Number.isFinite(opts.minImportance)) { + searchOptions.minImportance = opts.minImportance; + } + } + return memoryManager.search(query, searchOptions); }); // ===== 配置 ===== @@ -507,23 +623,58 @@ export function registerAllIPCHandlers( return configService.get(key); }); - ipcMain.handle('config:set', async (_event, key, value) => { + ipcMain.handle('config:set', async (_event, key: unknown, value: unknown) => { + // M-42 修复: 参数校验 + 敏感数据脱敏 + if (typeof key !== 'string' || !key.trim()) { + return { success: false, error: 'Invalid config key' }; + } + // value 必须是可序列化的基本类型(string|number|boolean|null) + if (value !== null && typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { + return { success: false, error: 'Invalid config value: must be string, number, boolean, or null' }; + } + + // C-1 修复: Provider 切换时清空 API key,防止使用不兼容的 key 导致 401 错误 + // @see project_memory.md — When switching LLM providers, the API key must be cleared + if (key === 'llm.provider') { + const oldProvider = configService.get('llm.provider') ?? ''; + const newProvider = (value as string) ?? ''; + if (oldProvider && newProvider && oldProvider !== newProvider) { + configService.set('llm.apiKey', ''); + log.info(`[CONFIG] Provider changed (${oldProvider} → ${newProvider}), API key cleared to prevent incompatible key usage`); + } + } + configService.set(key, value); + // M-42 修复: 敏感配置项脱敏后再写入审计日志,防止 API Key 明文泄露 + // 敏感 key 包括 llm.apiKey、llm.fallbackApiKey、agnes.apiKey 等 + // 审计补充修复: 短值(length <= 4)时 slice(-4) 会返回整个 value 导致脱敏失效, + // 改为:长值保留后 4 位核对,短值完全掩码为 '***' + const SENSITIVE_KEY_PATTERNS = ['apikey', 'api_key', 'apitoken', 'token', 'secret', 'password']; + const isSensitiveKey = SENSITIVE_KEY_PATTERNS.some(p => key.toLowerCase().includes(p)); + const auditValue = isSensitiveKey && typeof value === 'string' && value.length > 0 + ? (value.length > 4 ? '***' + value.slice(-4) : '***') + : value; + // TOOL 层:记录配置变更 auditService.log({ sessionId: '', eventType: 'config_change', actor: 'user', target: key, - details: { value }, + details: { value: auditValue }, outcome: 'success', }); // LLM 相关配置变更时热重载 Adapter if (['llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL', 'ollama.numCtx'].includes(key)) { - reloadAdapter(); - log.info(`[CONFIG] LLM config changed (${key}), adapter reloaded`); + // C-1 修复: reloadAdapter 返回 false 时表示加载失败,需要通知前端 + const reloadSuccess = reloadAdapter(); + if (!reloadSuccess) { + log.warn(`[CONFIG] Adapter reload failed after ${key} change`); + } else { + log.info(`[CONFIG] LLM config changed (${key}), adapter reloaded`); + } } // Agent 配置变更时热更新 Engine @@ -535,13 +686,17 @@ export function registerAllIPCHandlers( log.info(`[CONFIG] Agent totalTimeoutMs updated to ${value}`); } else if (key === 'agent.enableThinking') { agentLoop.updateConfig({ thinkingEnabled: value as boolean }); + // L-18 修复: 同步更新 orchestrator.defaultConfig,确保新建 SubAgent 使用最新配置 + orchestrator.updateDefaultConfig({ thinkingEnabled: value as boolean }); log.info(`[CONFIG] Agent thinkingEnabled updated to ${value}`); } else if (key === 'agent.thinkingEffort') { agentLoop.updateConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' }); + orchestrator.updateDefaultConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' }); log.info(`[CONFIG] Agent thinkingEffort updated to ${value}`); } else if (key === 'ollama.numCtx') { // numCtx 同时更新 Engine 的 contextLength agentLoop.updateConfig({ contextLength: (value as number) || undefined }); + orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined }); log.info(`[CONFIG] Agent contextLength updated to ${value}`); } else if (key === 'agent.confirmationTimeoutMs') { // 工具确认超时变更,即时应用到 ConfirmationHook @@ -585,12 +740,38 @@ export function registerAllIPCHandlers( return app.getPath('userData'); }); - ipcMain.handle('app:openExternal', async (_event, url) => { - await shell.openExternal(url); + ipcMain.handle('app:openExternal', async (_event, url: unknown) => { + // M-12 修复: URL 协议白名单校验,防止打开 file:///smb:// 等危险协议 + // @see main.ts setWindowOpenHandler — 两处安全策略保持一致 + if (typeof url !== 'string' || !url) { + return { success: false, error: 'Invalid URL' }; + } + try { + const parsed = new URL(url); + const ALLOWED_PROTOCOLS = ['http:', 'https:', 'mailto:']; + if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) { + log.warn(`[IPC] openExternal blocked: protocol "${parsed.protocol}" not in whitelist`); + return { success: false, error: `Protocol not allowed: ${parsed.protocol}` }; + } + await shell.openExternal(url); + return { success: true }; + } catch (error) { + log.warn('[IPC] openExternal failed:', (error as Error).message); + return { success: false, error: (error as Error).message }; + } }); - ipcMain.handle('app:showItemInFolder', async (_event, path) => { - shell.showItemInFolder(path); + ipcMain.handle('app:showItemInFolder', async (_event, path: unknown) => { + // M-36 修复: path 类型校验,防止非字符串参数 + if (typeof path !== 'string' || !path) { + return { success: false, error: 'Invalid path' }; + } + try { + shell.showItemInFolder(path); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } }); ipcMain.handle('app:selectFolder', async (_event, defaultPath?: string) => { @@ -793,6 +974,16 @@ export function registerAllIPCHandlers( }); ipcMain.handle('tools:toggle', async (_event, toolName: string, enabled: boolean) => { + // M-35 修复: 校验 toolName 合法性,防止配置 key 污染 + if (typeof toolName !== 'string' || !toolName || typeof enabled !== 'boolean') { + return { success: false, error: 'Invalid parameters' }; + } + // 校验 toolName 在已注册工具列表中(防止写入任意配置 key) + const allTools = toolRegistry.listAllTools(); + if (!allTools.some((t) => t.name === toolName)) { + log.warn(`[IPC] tools:toggle rejected: unknown tool "${toolName}"`); + return { success: false, error: `Unknown tool: ${toolName}` }; + } // 工具开关通过配置持久化 configService.set(`tools.${toolName}.enabled`, enabled); // 同步到 ToolRegistry(立即生效) @@ -828,29 +1019,45 @@ export function registerAllIPCHandlers( }); ipcMain.handle('data:clearSessions', async () => { + // M-40 修复: 危险操作审计日志,便于追踪异常调用 + log.warn('[DATA] DANGER: clearSessions invoked — all sessions and messages will be deleted'); const db = sessionService.getDB(); try { db.exec('BEGIN'); + const msgCount = db.prepare('SELECT COUNT(*) as c FROM messages').get() as { c: number }; + const sessCount = db.prepare('SELECT COUNT(*) as c FROM sessions').get() as { c: number }; db.exec('DELETE FROM messages'); db.exec('DELETE FROM sessions'); db.exec('COMMIT'); - log.info('[DATA] All sessions cleared'); - return { success: true }; + log.info(`[DATA] All sessions cleared: ${sessCount.c} sessions, ${msgCount.c} messages deleted`); + return { success: true, deletedSessions: sessCount.c, deletedMessages: msgCount.c }; } catch (error) { try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ } + log.error('[DATA] clearSessions failed:', (error as Error).message); return { success: false, error: (error as Error).message }; } }); ipcMain.handle('data:clearMemories', async () => { + // M-40 修复: 危险操作审计日志,便于追踪异常调用 + log.warn('[DATA] DANGER: clearMemories invoked — all memories will be deleted'); + const db = sessionService.getDB(); try { - const db = sessionService.getDB(); + // M-41 修复: 三个 DELETE 操作用事务包裹,防止部分失败导致三类记忆数据不一致 + const epiCount = db.prepare('SELECT COUNT(*) as c FROM episodic_memories').get() as { c: number }; + const semCount = db.prepare('SELECT COUNT(*) as c FROM semantic_memories').get() as { c: number }; + const workCount = db.prepare('SELECT COUNT(*) as c FROM working_memories').get() as { c: number }; + db.exec('BEGIN'); db.exec('DELETE FROM episodic_memories'); db.exec('DELETE FROM semantic_memories'); db.exec('DELETE FROM working_memories'); - log.info('[DATA] All memories cleared'); - return { success: true }; + db.exec('COMMIT'); + log.info(`[DATA] All memories cleared: ${epiCount.c} episodic, ${semCount.c} semantic, ${workCount.c} working memories deleted`); + return { success: true, deletedEpisodic: epiCount.c, deletedSemantic: semCount.c, deletedWorking: workCount.c }; } catch (error) { + // M-41 修复: 失败时回滚事务,确保数据一致性 + try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ } + log.error('[DATA] clearMemories failed:', (error as Error).message); return { success: false, error: (error as Error).message }; } }); @@ -915,13 +1122,42 @@ export function registerAllIPCHandlers( // ===== v0.2.0: 工具确认响应(ConfirmationDialog → 主进程)===== // 使用 ipcMain.on(渲染进程通过 ipcRenderer.send 发送) - ipcMain.on('tool:confirmationResponse', (_event, data: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) => { - confirmationHook.resolveConfirmation(data.toolCallId, data.approved, data.remember, data.autoExecute ?? false); - log.info(`[CONFIRM] Tool ${data.toolCallId} ${data.approved ? 'approved' : 'denied'}${data.remember ? ' (remembered)' : ''}${data.autoExecute ? ' (autoExecute)' : ''}`); + ipcMain.on('tool:confirmationResponse', (_event, data: unknown) => { + // M-43 修复: 校验 data 结构,防止 undefined/null 导致 TypeError + if (!data || typeof data !== 'object') { + log.warn('[IPC] tool:confirmationResponse rejected: invalid data'); + return; + } + 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; + } + if (typeof req.approved !== 'boolean') { + log.warn('[IPC] tool:confirmationResponse rejected: invalid approved'); + return; + } + 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)' : ''}`); }); // ===== v0.2.0: 持久化自动执行设置 ===== - ipcMain.handle('tool:setAutoExecute', async (_event, toolName: string, enabled: boolean) => { + ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => { + // M-44 修复: 校验 toolName 合法性和 enabled 类型,防止配置 key 污染 + if (typeof toolName !== 'string' || !toolName) { + return { success: false, error: 'Invalid toolName' }; + } + if (typeof enabled !== 'boolean') { + return { success: false, error: 'Invalid enabled (must be boolean)' }; + } + // 校验 toolName 在已注册工具列表中(与 tools:toggle 保持一致) + const allTools = toolRegistry.listAllTools(); + if (!allTools.some((t) => t.name === toolName)) { + log.warn(`[IPC] tool:setAutoExecute rejected: unknown tool "${toolName}"`); + return { success: false, error: `Unknown tool: ${toolName}` }; + } try { confirmationHook.setAutoExecute(toolName, enabled); log.info(`[CONFIRM] Tool ${toolName} autoExecute set to ${enabled}`); @@ -947,16 +1183,49 @@ export function registerAllIPCHandlers( } }); - ipcMain.handle('audit:query', async (_event, filters?: { sessionId?: string; eventType?: string; limit?: number }) => { + ipcMain.handle('audit:query', async (_event, filters?: unknown) => { + // M-45 修复: 校验 filters 参数类型和范围 + const VALID_AUDIT_EVENT_TYPES: readonly AuditEventType[] = [ + 'tool_call', 'permission_check', 'error', 'llm_request', + 'llm_response', 'session_start', 'session_end', 'config_change', + ]; + let safeFilters: { sessionId?: string; eventType?: AuditEventType; limit?: number } = {}; + if (filters && typeof filters === 'object') { + const f = filters as Record; + if (typeof f.sessionId === 'string' && f.sessionId) safeFilters.sessionId = f.sessionId; + if (typeof f.eventType === 'string' && f.eventType) { + // 校验 eventType 是否在合法枚举内 + if (!(VALID_AUDIT_EVENT_TYPES as readonly string[]).includes(f.eventType)) { + return { success: false, error: `Invalid eventType (must be one of: ${VALID_AUDIT_EVENT_TYPES.join(', ')})` }; + } + safeFilters.eventType = f.eventType as AuditEventType; + } + if (f.limit !== undefined) { + const limit = Number(f.limit); + // 审计补充修复: limit 越界时返回 error(与 memory:listAll 策略一致,原为静默降级为 100) + // 限制 1-1000 范围,防止过大查询拖慢性能 + if (!Number.isFinite(limit) || limit < 1 || limit > 1000) { + return { success: false, error: 'Invalid limit (must be 1-1000)' }; + } + safeFilters.limit = Math.floor(limit); + } + } try { - return { success: true, data: auditService.query(filters as Parameters[0]) }; + return { success: true, data: auditService.query(safeFilters) }; } catch (error) { return { success: false, error: (error as Error).message }; } }); // ===== v0.2.0: 任务管理 IPC(TaskList UI)===== - ipcMain.handle('tasks:list', async (_event, sessionId?: string) => { + const VALID_TASK_PRIORITIES: readonly string[] = ['low', 'medium', 'high', 'critical']; + const VALID_TASK_STATUSES: readonly string[] = ['pending', 'in_progress', 'completed', 'blocked', 'cancelled']; + + ipcMain.handle('tasks:list', async (_event, sessionId?: unknown) => { + // M-46 修复: 校验 sessionId 类型(可选参数) + if (sessionId !== undefined && (typeof sessionId !== 'string' || !sessionId)) { + return { success: false, error: 'Invalid sessionId' }; + } const db = sessionService.getDB(); let sql = 'SELECT * FROM tasks'; const params: unknown[] = []; @@ -968,33 +1237,83 @@ export function registerAllIPCHandlers( return { success: true, data: db.prepare(sql).all(...params) }; }); - ipcMain.handle('tasks:create', async (_event, data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string }) => { + ipcMain.handle('tasks:create', async (_event, data: unknown) => { + // M-46 修复: 校验 data 结构和字段类型/枚举 + if (!data || typeof data !== 'object') { + return { success: false, error: 'Invalid task data' }; + } + const req = data as Record; + if (typeof req.sessionId !== 'string' || !req.sessionId.trim()) { + return { success: false, error: 'Invalid sessionId' }; + } + if (typeof req.title !== 'string' || !req.title.trim()) { + return { success: false, error: 'Invalid title' }; + } + if (req.priority !== undefined && !VALID_TASK_PRIORITIES.includes(req.priority as string)) { + return { success: false, error: `Invalid priority (must be one of: ${VALID_TASK_PRIORITIES.join(', ')})` }; + } + if (req.parentId !== undefined && req.parentId !== null && typeof req.parentId !== 'string') { + return { success: false, error: 'Invalid parentId' }; + } const db = sessionService.getDB(); const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; try { db.prepare(` INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?) - `).run(id, data.sessionId, data.title, data.description ?? '', data.priority ?? 'medium', data.parentId ?? null, Date.now(), Date.now()); + `).run( + id, + req.sessionId, + req.title, + typeof req.description === 'string' ? req.description : '', + (req.priority as string) ?? 'medium', + (req.parentId as string | null) ?? null, + Date.now(), + Date.now(), + ); return { success: true, id }; } catch (error) { return { success: false, error: (error as Error).message }; } }); - ipcMain.handle('tasks:update', async (_event, id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string }) => { + ipcMain.handle('tasks:update', async (_event, id: unknown, updates: unknown) => { + // M-47 修复: 校验 id 和 updates 结构/枚举 + if (typeof id !== 'string' || !id) { + return { success: false, error: 'Invalid task id' }; + } + if (!updates || typeof updates !== 'object') { + return { success: false, error: 'Invalid updates' }; + } + const u = updates as Record; + if (u.status !== undefined && !VALID_TASK_STATUSES.includes(u.status as string)) { + return { success: false, error: `Invalid status (must be one of: ${VALID_TASK_STATUSES.join(', ')})` }; + } + if (u.priority !== undefined && !VALID_TASK_PRIORITIES.includes(u.priority as string)) { + return { success: false, error: `Invalid priority (must be one of: ${VALID_TASK_PRIORITIES.join(', ')})` }; + } + if (u.assignedTo !== undefined && u.assignedTo !== null && typeof u.assignedTo !== 'string') { + return { success: false, error: 'Invalid assignedTo' }; + } + // 审计补充修复: 补全 title/description 类型校验(与 tasks:create 的严格校验一致) + if (u.title !== undefined && typeof u.title !== 'string') { + return { success: false, error: 'Invalid title (must be string)' }; + } + if (u.description !== undefined && typeof u.description !== 'string') { + return { success: false, error: 'Invalid description (must be string)' }; + } const db = sessionService.getDB(); try { const fields: string[] = []; const values: unknown[] = []; - if (updates.title !== undefined) { fields.push('title = ?'); values.push(updates.title); } - if (updates.description !== undefined) { fields.push('description = ?'); values.push(updates.description); } - if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); } - if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); } - if (updates.assignedTo !== undefined) { fields.push('assigned_to = ?'); values.push(updates.assignedTo); } + if (u.title !== undefined) { fields.push('title = ?'); values.push(u.title); } + if (u.description !== undefined) { fields.push('description = ?'); values.push(u.description); } + if (u.status !== undefined) { fields.push('status = ?'); values.push(u.status); } + if (u.priority !== undefined) { fields.push('priority = ?'); values.push(u.priority); } + if (u.assignedTo !== undefined) { fields.push('assigned_to = ?'); values.push(u.assignedTo); } if (fields.length === 0) return { success: true }; fields.push('updated_at = ?'); values.push(Date.now()); - if (updates.status === 'completed') { fields.push('completed_at = ?'); values.push(Date.now()); } + if (u.status === 'completed') { fields.push('completed_at = ?'); values.push(Date.now()); } values.push(id); db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values); return { success: true }; @@ -1003,7 +1322,11 @@ export function registerAllIPCHandlers( } }); - ipcMain.handle('tasks:delete', async (_event, id: string) => { + ipcMain.handle('tasks:delete', async (_event, id: unknown) => { + // M-48 修复: 校验 id 类型 + if (typeof id !== 'string' || !id) { + return { success: false, error: 'Invalid task id' }; + } const db = sessionService.getDB(); try { db.prepare('DELETE FROM tasks WHERE id = ?').run(id); @@ -1014,10 +1337,30 @@ export function registerAllIPCHandlers( }); // ===== v0.2.0: 记忆系统增强查询(Memory Viewer UI)===== - ipcMain.handle('memory:listAll', async (_event, options?: { type?: string; limit?: number }) => { + const VALID_MEMORY_TYPES_LIST: readonly string[] = ['episodic', 'semantic', 'working']; + + ipcMain.handle('memory:listAll', async (_event, options?: unknown) => { + // M-49 修复: 校验 options.type 枚举和 limit 范围 + let limit = 100; + let type: string | undefined; + if (options && typeof options === 'object') { + const opts = options as Record; + if (opts.type !== undefined) { + if (typeof opts.type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(opts.type)) { + return { success: false, error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})` }; + } + type = opts.type; + } + if (opts.limit !== undefined) { + const num = Number(opts.limit); + // 限制 1-1000 范围,SQLite 中 LIMIT -1 表示无限制,需阻止 + if (!Number.isFinite(num) || num < 1 || num > 1000) { + return { success: false, error: 'Invalid limit (must be 1-1000)' }; + } + limit = Math.floor(num); + } + } const db = sessionService.getDB(); - const limit = options?.limit ?? 100; - const type = options?.type; const results: Record = {}; try { if (!type || type === 'episodic') { @@ -1038,7 +1381,14 @@ export function registerAllIPCHandlers( } }); - ipcMain.handle('memory:delete', async (_event, type: string, id: string) => { + ipcMain.handle('memory:delete', async (_event, type: unknown, id: unknown) => { + // M-50 修复: 校验 type 枚举(防止三元表达式默认映射到 working_memories)和 id 类型 + if (typeof type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(type)) { + return { success: false, error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})` }; + } + if (typeof id !== 'string' || !id) { + return { success: false, error: 'Invalid memory id' }; + } const db = sessionService.getDB(); try { const table = type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : 'working_memories'; diff --git a/electron/main.ts b/electron/main.ts index 57a2375..34c95af 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -222,7 +222,9 @@ async function initialize(): Promise { ]; // ===== Agent Loop ===== - // TODO: Re-read agent config on each runStream call or when config changes + // Agent 配置初始化:仅此处一次性读取,配置变更时由 handlers.ts 的 config:set + // 监听器调用 agentLoop.updateConfig() 和 orchestrator.updateDefaultConfig() 即时生效。 + // @see electron/ipc/handlers.ts — 'config:set' handler const ollamaNumCtx = configService.get('ollama.numCtx'); const agentMaxIter = configService.get('agent.maxIterations'); const agentTimeout = configService.get('agent.totalTimeoutMs'); @@ -375,7 +377,7 @@ async function initialize(): Promise { contextBuilder, agentLoop, toolRegistry, auditService, sessionRecorder, memoryManager, mcpManager, reloadAdapter, promptDefender, outputValidator, - confirmationHook, memoryConsolidator, + confirmationHook, memoryConsolidator, orchestrator, ); // TODO: Initialize UpdateService for auto-update functionality @@ -395,16 +397,46 @@ async function initialize(): Promise { } }); - app.on('before-quit', async () => { + // M-13 修复: before-quit 回调改为同步 + event.preventDefault() 确保异步清理完成 + // 之前 async 回调 Electron 不会 await,导致 MCP shutdown 未完成时应用已退出 + app.on('before-quit', (event) => { // 标记为正在退出,允许窗口关闭(两处 isQuitting 统一设置) (global as Record).isQuitting = true; TrayManager.markQuitting(); windowManager?.unregisterGlobalShortcuts(); trayManager?.destroy(); windowManager?.closeAll(); - try { await mcpManager.shutdown(); } catch (err) { log.error('[Shutdown] MCP shutdown failed:', err); } - cleanupBrowser(); - if (databaseService) { databaseService.close(); databaseService = null; } + + // 防止重复触发(macOS before-quit 可能触发多次) + if ((global as Record).shutdownInProgress === true) { + return; + } + (global as Record).shutdownInProgress = true; + + event.preventDefault(); + // 内部异步清理,加 5 秒超时保护防止卡死 + const shutdownTimeout = setTimeout(() => { + log.warn('[Shutdown] Timeout reached, forcing exit'); + if (databaseService) { databaseService.close(); databaseService = null; } + app.exit(0); + }, 5_000); + + (async () => { + try { + await mcpManager.shutdown(); + } catch (err) { + log.error('[Shutdown] MCP shutdown failed:', err); + } + cleanupBrowser(); + if (databaseService) { databaseService.close(); databaseService = null; } + clearTimeout(shutdownTimeout); + app.exit(0); + })().catch((err) => { + log.error('[Shutdown] Async cleanup failed:', err); + clearTimeout(shutdownTimeout); + if (databaseService) { try { databaseService.close(); } catch { /* ignore */ } databaseService = null; } + app.exit(1); + }); }); // ===== 应用日志级别配置 ===== @@ -430,5 +462,18 @@ async function initialize(): Promise { app.whenReady().then(initialize); app.on('web-contents-created', (_, contents) => { - contents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); + // C-8 修复: 全局 web-contents 监听器也校验 URL 协议 + contents.setWindowOpenHandler(({ url }) => { + try { + const parsed = new URL(url); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + shell.openExternal(url); + } else { + log.warn(`[Main] Blocked window.open with unsafe protocol: ${parsed.protocol}`); + } + } catch { + log.warn(`[Main] Blocked window.open with invalid URL: ${url.slice(0, 100)}`); + } + return { action: 'deny' }; + }); }); diff --git a/electron/services/database.service.ts b/electron/services/database.service.ts index b79047b..9eea959 100644 --- a/electron/services/database.service.ts +++ b/electron/services/database.service.ts @@ -14,6 +14,13 @@ import { app } from 'electron'; import { existsSync, mkdirSync } from 'fs'; import log from 'electron-log'; +/** + * L-8 修复: 提取 toErrorMessage 工具函数,消除 5 处重复的 error instanceof Error 三元表达式 + */ +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export class DatabaseService { private db: Database.Database | null = null; private dbPath: string; @@ -95,11 +102,13 @@ export class DatabaseService { ); -- ===== 消息表 ===== + -- C-6 修复: content 允许 NULL — assistant 消息仅有 tool_calls 时 content 必须为 null + -- @see project_memory.md — Assistant messages with tool_calls must set content to null CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')), - content TEXT NOT NULL, + content TEXT, reasoning_content TEXT, tool_calls TEXT, tool_result TEXT, @@ -246,50 +255,76 @@ export class DatabaseService { private runMigrations(): void { const db = this.db!; + // L-6 修复: 提取 tryAddColumn 辅助方法,消除 4 处重复的 try/catch 模式 + // L-8 修复: 使用 toErrorMessage 替代重复的 error instanceof Error 三元表达式 + const tryAddColumn = (table: string, column: string, type: string, migrationName: string) => { + try { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); + log.info(`[DB] Migration: added ${column} column to ${table}`); + } catch (error) { + // 只忽略 "duplicate column" 错误(列已存在),其他错误必须抛出 + const msg = toErrorMessage(error); + if (!msg.includes('duplicate column')) { + throw error; + } + } + }; + // 迁移 1: messages 表添加 attachments 列 - try { - db.exec('ALTER TABLE messages ADD COLUMN attachments TEXT'); - log.info('[DB] Migration: added attachments column to messages'); - } catch (error) { - // 只忽略 "duplicate column" 错误(列已存在),其他错误必须抛出 - const msg = error instanceof Error ? error.message : String(error); - if (!msg.includes('duplicate column')) { - throw error; - } - } - + tryAddColumn('messages', 'attachments', 'TEXT', 'attachments'); // 迁移 2: audit_logs 表添加 iteration 列 - try { - db.exec('ALTER TABLE audit_logs ADD COLUMN iteration INTEGER'); - log.info('[DB] Migration: added iteration column to audit_logs'); - } catch (error) { - // 只忽略 "duplicate column" 错误(列已存在),其他错误必须抛出 - const msg = error instanceof Error ? error.message : String(error); - if (!msg.includes('duplicate column')) { - throw error; - } - } - + tryAddColumn('audit_logs', 'iteration', 'INTEGER', 'iteration'); // v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希) - try { - db.exec('ALTER TABLE audit_logs ADD COLUMN prev_hash TEXT'); - log.info('[DB] Migration: added prev_hash column to audit_logs'); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - if (!msg.includes('duplicate column')) { - throw error; - } - } - + tryAddColumn('audit_logs', 'prev_hash', 'TEXT', 'prev_hash'); // v0.2.0 迁移 4: audit_logs 表添加 current_hash 列(链式哈希) + tryAddColumn('audit_logs', 'current_hash', 'TEXT', 'current_hash'); + + // 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 { - db.exec('ALTER TABLE audit_logs ADD COLUMN current_hash TEXT'); - log.info('[DB] Migration: added current_hash column to audit_logs'); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - if (!msg.includes('duplicate column')) { - throw error; + // 检测 content 列是否有 NOT NULL 约束 + 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'); + + db.exec(` + CREATE TABLE IF NOT EXISTS messages_new ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')), + content TEXT, + reasoning_content TEXT, + tool_calls TEXT, + tool_result TEXT, + attachments TEXT, + iteration INTEGER, + created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + + INSERT INTO messages_new (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at) + SELECT id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at + FROM messages; + + DROP TABLE messages; + ALTER TABLE messages_new RENAME TO messages; + `); + + // 重建索引 + db.exec(` + CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at); + CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role); + `); + + log.info('[DB] Migration: messages table rebuilt successfully (content now allows NULL)'); } + } catch (error) { + // L-8 修复: 使用 toErrorMessage 替代重复的三元表达式 + const msg = toErrorMessage(error); + log.warn(`[DB] Migration 5 (messages content NULL) skipped: ${msg}`); + // 非致命错误 — 如果迁移失败,NOT NULL 约束仍生效,saveMessage 会保存空字符串 } } @@ -316,6 +351,10 @@ export class DatabaseService { { key: 'agent.enableThinking', value: 'true', category: 'agent' }, { key: 'agent.thinkingEffort', value: '"high"', category: 'agent' }, { key: 'agent.enableReflection', value: 'false', category: 'agent' }, + // C-10 修复: 补充缺失的 agent 配置默认值 + // @see project_memory.md — Tool confirmation timeout is configurable via agent.confirmationTimeoutMs (30s~600s, default 120s) + { key: 'agent.confirmationTimeoutMs', value: '120000', category: 'agent' }, + { key: 'agent.toolExecutionTimeoutMs', value: '120000', category: 'agent' }, // 安全配置 { key: 'security.requireWriteConfirmation', value: 'true', category: 'security' }, diff --git a/electron/services/session.service.ts b/electron/services/session.service.ts index b67bebc..1ee019b 100644 --- a/electron/services/session.service.ts +++ b/electron/services/session.service.ts @@ -29,7 +29,8 @@ export interface MessageRow { id: string; session_id: string; role: string; - content: string; + // C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null + content: string | null; reasoning_content: string | null; tool_calls: string | null; tool_result: string | null; @@ -52,7 +53,8 @@ export interface SessionInfo { export interface MessageInfo { id: string; role: string; - content: string; + // C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null + content: string | null; reasoningContent?: string; toolCalls?: unknown[]; toolResult?: unknown; @@ -192,7 +194,8 @@ export class SessionService { saveMessage(params: { sessionId: string; role: string; - content: string; + // C-6 修复: content 允许 null — assistant 消息仅有 tool_calls 时为 null + content: string | null; reasoningContent?: string; toolCalls?: unknown[]; toolResult?: unknown; @@ -311,14 +314,17 @@ export class SessionService { } private toMessageInfo(row: MessageRow): MessageInfo { + // M-55 修复: 运行时收窄,防止数据库被篡改或老版本数据格式不一致导致下游 .map/.length 崩溃 + const parsedToolCalls = row.tool_calls ? this.safeJsonParse(row.tool_calls) : undefined; + const parsedAttachments = row.attachments ? this.safeJsonParse(row.attachments) : undefined; return { id: row.id, role: row.role, content: row.content, reasoningContent: row.reasoning_content ?? undefined, - toolCalls: row.tool_calls ? this.safeJsonParse(row.tool_calls) as unknown[] : undefined, + toolCalls: Array.isArray(parsedToolCalls) ? parsedToolCalls : undefined, toolResult: row.tool_result ? this.safeJsonParse(row.tool_result) : undefined, - attachments: row.attachments ? this.safeJsonParse(row.attachments) as unknown[] : undefined, + attachments: Array.isArray(parsedAttachments) ? parsedAttachments : undefined, iteration: row.iteration ?? undefined, timestamp: row.created_at, }; diff --git a/electron/services/window-manager.service.ts b/electron/services/window-manager.service.ts index 4d5536c..3ea8f4a 100644 --- a/electron/services/window-manager.service.ts +++ b/electron/services/window-manager.service.ts @@ -82,7 +82,17 @@ export class WindowManager { }); win.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url); + // C-8 修复: 校验 URL 协议,只允许 http/https,防止 javascript:/file: 等协议执行代码 + try { + const parsed = new URL(url); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + shell.openExternal(url); + } else { + log.warn(`[WindowManager] Blocked window.open with unsafe protocol: ${parsed.protocol}`); + } + } catch { + log.warn(`[WindowManager] Blocked window.open with invalid URL: ${url.slice(0, 100)}`); + } return { action: 'deny' }; }); diff --git a/electron/utils/slo.ts b/electron/utils/slo.ts index 7df7ea0..66c2867 100644 --- a/electron/utils/slo.ts +++ b/electron/utils/slo.ts @@ -1,7 +1,8 @@ /** - * Health Checker — 健康检查器 + * Health Checker + SLO Monitor — 健康检查器与 SLO 监控 * - * 对数据库连通性、磁盘空间、内存使用执行真实检查。 + * HealthChecker: 对数据库连通性、磁盘空间、内存使用执行真实检查。 + * SLOMonitor: C-9 修复 — 补全 SLO 监控核心功能(错误率、延迟分布、吞吐量、燃烧速率)。 * * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十一章 */ @@ -107,3 +108,177 @@ export class HealthChecker { }; } } + +// ===== C-9 修复: SLO Monitor — SLO 监控核心功能 ===== + +/** + * SLO 监控配置 + */ +export interface SLOConfig { + /** SLO 目标(如 0.999 表示 99.9% 可用性) */ + target: number; + /** 滑动窗口大小(毫秒,默认 5 分钟) */ + windowMs: number; + /** 延迟分位数(如 0.95 表示 P95) */ + latencyPercentiles: number[]; + /** 延迟告警阈值(毫秒) */ + latencyThresholdMs: number; +} + +/** + * SLO 请求记录 + */ +interface SLORequestRecord { + timestamp: number; + latencyMs: number; + success: boolean; +} + +/** + * SLO 状态报告 + */ +export interface SLOStatus { + /** 当前错误率(0-1) */ + errorRate: number; + /** 当前吞吐量(请求/秒) */ + throughput: number; + /** 平均延迟(毫秒) */ + avgLatencyMs: number; + /** 延迟分位数 */ + percentiles: Record; + /** 燃烧速率(实际错误率 / 错误预算) */ + burnRate: number; + /** SLO 目标 */ + target: number; + /** 错误预算(1 - target) */ + errorBudget: number; + /** 是否违反 SLO */ + violated: boolean; + /** 窗口内总请求数 */ + totalRequests: number; + /** 窗口内错误请求数 */ + errorRequests: number; + /** 时间戳 */ + timestamp: Date; +} + +/** + * SLO Monitor — SLO 监控器 + * + * C-9 修复: 补全 SLO 监控核心功能 + * + * 功能: + * 1. 错误率追踪 — 记录请求成功/失败,计算滑动窗口内错误率 + * 2. 延迟分布追踪 — 记录请求延迟,计算 P50/P95/P99 分位数 + * 3. 吞吐量追踪 — 计算每秒请求数 + * 4. 燃烧速率计算 — 实际错误率 / 错误预算,>1 表示 SLO 即将违反 + * + * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十一章 + */ +export class SLOMonitor { + private records: SLORequestRecord[] = []; + private readonly config: SLOConfig; + + constructor(config: Partial = {}) { + this.config = { + target: 0.999, + windowMs: 5 * 60 * 1000, // 5 分钟 + latencyPercentiles: [0.5, 0.95, 0.99], + latencyThresholdMs: 5000, + ...config, + }; + } + + /** + * 记录一次请求 + * @param latencyMs 延迟(毫秒) + * @param success 是否成功 + */ + recordRequest(latencyMs: number, success: boolean): void { + const record: SLORequestRecord = { + timestamp: Date.now(), + latencyMs, + success, + }; + this.records.push(record); + // 清理过期记录 + this.cleanup(); + } + + /** + * 清理窗口外的过期记录 + */ + private cleanup(): void { + const cutoff = Date.now() - this.config.windowMs; + // 保留最近 10 分钟的数据(2 倍窗口),避免边界效应 + this.records = this.records.filter((r) => r.timestamp >= cutoff); + } + + /** + * 获取当前 SLO 状态 + */ + getStatus(): SLOStatus { + this.cleanup(); + + const now = Date.now(); + const windowStart = now - this.config.windowMs; + const windowRecords = this.records.filter((r) => r.timestamp >= windowStart); + + const totalRequests = windowRecords.length; + const errorRequests = windowRecords.filter((r) => !r.success).length; + const errorRate = totalRequests > 0 ? errorRequests / totalRequests : 0; + + const throughput = totalRequests / (this.config.windowMs / 1000); + + const latencies = windowRecords.map((r) => r.latencyMs).sort((a, b) => a - b); + const avgLatencyMs = latencies.length > 0 + ? latencies.reduce((sum, l) => sum + l, 0) / latencies.length + : 0; + + const percentiles: Record = {}; + for (const p of this.config.latencyPercentiles) { + const key = `P${(p * 100).toFixed(0)}`; + percentiles[key] = this.calculatePercentile(latencies, p); + } + + const errorBudget = 1 - this.config.target; + const burnRate = errorBudget > 0 ? errorRate / errorBudget : 0; + + return { + errorRate, + throughput, + avgLatencyMs, + percentiles, + burnRate, + target: this.config.target, + errorBudget, + violated: burnRate > 1, + totalRequests, + errorRequests, + timestamp: new Date(), + }; + } + + /** + * 计算分位数 + */ + private calculatePercentile(sortedValues: number[], percentile: number): number { + if (sortedValues.length === 0) return 0; + const index = Math.ceil(percentile * sortedValues.length) - 1; + return sortedValues[Math.max(0, index)]; + } + + /** + * 重置所有记录 + */ + reset(): void { + this.records = []; + } + + /** + * 获取配置 + */ + getConfig(): SLOConfig { + return { ...this.config }; + } +} diff --git a/package-lock.json b/package-lock.json index d5e41eb..cb83857 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", @@ -14,6 +14,7 @@ "@modelcontextprotocol/sdk": "^1.12.1", "@mui/icons-material": "^9.1.1", "@mui/material": "^9.1.2", + "@types/shell-quote": "^1.7.5", "better-sqlite3": "^11.9.1", "date-fns": "^4.1.0", "electron-log": "^5.3.3", @@ -28,6 +29,7 @@ "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "shell-quote": "^1.10.0", "sql.js": "^1.12.0", "zod": "^3.25.67", "zustand": "^5.0.5" @@ -3179,6 +3181,12 @@ "@types/node": "*" } }, + "node_modules/@types/shell-quote": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@types/shell-quote/-/shell-quote-1.7.5.tgz", + "integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", @@ -10235,6 +10243,18 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", diff --git a/package.json b/package.json index 18eef9f..c6251ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.3.0", + "version": "0.3.1", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", @@ -28,6 +28,7 @@ "@modelcontextprotocol/sdk": "^1.12.1", "@mui/icons-material": "^9.1.1", "@mui/material": "^9.1.2", + "@types/shell-quote": "^1.7.5", "better-sqlite3": "^11.9.1", "date-fns": "^4.1.0", "electron-log": "^5.3.3", @@ -42,6 +43,7 @@ "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "shell-quote": "^1.10.0", "sql.js": "^1.12.0", "zod": "^3.25.67", "zustand": "^5.0.5" diff --git a/src/App.tsx b/src/App.tsx index 14856de..cfcc9f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { ThemeProvider, CssBaseline } from '@mui/material'; import { useEffect } from 'react'; +import { Header } from '@renderer/components/layout/Header'; import { Sidebar } from '@renderer/components/layout/Sidebar'; import { DetailPanel } from '@renderer/components/layout/DetailPanel'; import { StatusBar } from '@renderer/components/layout/StatusBar'; @@ -43,12 +44,15 @@ export default function App(): React.JSX.Element { window.metona.config.get('onboarding.completed').then((v) => { if (v === true) useUIStore.getState().setOnboardingCompleted(true); }).catch((err) => { console.error('[App]', err); }); - Promise.all([ + // M-25 修复: 改用 Promise.allSettled,单个配置读取失败不影响另一个 + Promise.allSettled([ window.metona.config.get('llm.provider'), window.metona.config.get('llm.model'), - ]).then(([provider, model]) => { - if (provider || model) useAgentStore.getState().setProvider((provider as string) || '', (model as string) || ''); - }).catch((err) => { console.error('[App]', err); }); + ]).then(([providerResult, modelResult]) => { + const provider = providerResult.status === 'fulfilled' ? (providerResult.value as string) || '' : ''; + const model = modelResult.status === 'fulfilled' ? (modelResult.value as string) || '' : ''; + if (provider || model) useAgentStore.getState().setProvider(provider, model); + }); } }, []); @@ -59,6 +63,8 @@ export default function App(): React.JSX.Element { className="h-screen w-screen flex flex-col overflow-hidden select-none" style={{ background: muiTheme.palette.background.default, color: muiTheme.palette.text.primary }} > + {/* H-7 修复: 添加 Header Bar — 规范要求布局为 Header (全宽) + [Sidebar|ChatPanel|DetailPanel] + StatusBar */} +
{sidebarVisible && } diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 0f9d04b..7fd4be2 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -2,7 +2,7 @@ * CommandPalette — 快速搜索面板 (Cmd+K) */ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Dialog, DialogContent, InputBase, List, ListItemButton, ListItemIcon, ListItemText, Typography, Box, Divider } from '@mui/material'; import { Search, MessageSquare, Settings, Plus, Trash2 } from 'lucide-react'; import { useUIStore } from '@renderer/stores/ui-store'; @@ -16,13 +16,22 @@ export function CommandPalette(): React.JSX.Element { const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); + // 审计补充修复: 响应式订阅 sessions,使 getCommands 在 sessions 变化时重新构建 + // 原问题:getCommands 内部通过 useSessionStore.getState() 非响应式访问 sessions, + // 当 sessions 变化但 query 不变时 commands 列表陈旧 + const sessions = useSessionStore((s) => s.sessions); useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); setOpen((p) => !p); setQuery(''); setSelectedIndex(0); } }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, []); - useEffect(() => { if (open) setTimeout(() => inputRef.current?.focus(), 50); }, [open]); + // M-19 修复: useEffect 返回 cleanup 清理 setTimeout,防止快速开关时 timer 堆积 + useEffect(() => { + if (!open) return; + const t = setTimeout(() => inputRef.current?.focus(), 50); + return () => clearTimeout(t); + }, [open]); const getCommands = useCallback((): CommandItem[] => { const cmds: CommandItem[] = [ @@ -31,13 +40,15 @@ export function CommandPalette(): React.JSX.Element { { id: 'settings', icon: Settings, label: '打开设置', action: () => { useUIStore.getState().openSettings(); setOpen(false); } }, ]; if (query) { - const filtered = useSessionStore.getState().sessions.filter((s) => s.title.toLowerCase().includes(query.toLowerCase())); + // 审计补充修复: 使用响应式 sessions(闭包捕获),而非 useSessionStore.getState() + const filtered = sessions.filter((s) => s.title.toLowerCase().includes(query.toLowerCase())); for (const s of filtered.slice(0, 5)) cmds.push({ id: `s-${s.id}`, icon: MessageSquare, label: s.title, description: `${s.messageCount} 条消息`, action: () => { useSessionStore.getState().setCurrentSession(s.id); useAgentStore.getState().setCurrentSession(s.id); setOpen(false); } }); } return cmds; - }, [query]); + }, [query, sessions]); - const commands = getCommands(); + // L-15 修复: 使用 useMemo 缓存 commands 列表,避免每次渲染都重新构建 + const commands = useMemo(() => getCommands(), [getCommands]); return ( setOpen(false)} maxWidth="sm" fullWidth slotProps={{ paper: { sx: { mt: '20vh', maxWidth: 520, borderRadius: 3, overflow: 'hidden' } } }}> diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx index 1144c10..09dcb67 100644 --- a/src/components/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog.tsx @@ -273,6 +273,7 @@ export function ConfirmationDialog(): React.JSX.Element | null { color="error" variant="outlined" size="small" + disabled={isExpired} > 拒绝执行 @@ -282,8 +283,9 @@ export function ConfirmationDialog(): React.JSX.Element | null { variant="contained" size="small" autoFocus + disabled={isExpired} > - 确认执行 + {isExpired ? '已超时' : '确认执行'} diff --git a/src/components/chat/AssistantMessage.tsx b/src/components/chat/AssistantMessage.tsx index be6bf02..76168a5 100644 --- a/src/components/chat/AssistantMessage.tsx +++ b/src/components/chat/AssistantMessage.tsx @@ -184,14 +184,24 @@ function CodeBlock({ language, code }: { language: string; code: string }): Reac ); } -/** 从 React children(可能含高亮 span)递归提取纯文本 */ -function extractTextContent(children: React.ReactNode): string { +/** + * 从 React children(可能含高亮 span)递归提取纯文本 + * + * L-1 修复: 添加 maxDepth 参数防止循环引用导致的栈溢出 + * React children 正常深度不会超过 10,50 已足够安全裕量 + */ +function extractTextContent(children: React.ReactNode, maxDepth = 50): string { + if (maxDepth <= 0) return ''; // 超出深度上限,停止递归 if (typeof children === 'string') return children; if (typeof children === 'number') return String(children); if (!children) return ''; - if (Array.isArray(children)) return children.map(extractTextContent).join(''); + if (Array.isArray(children)) return children.map((c) => extractTextContent(c, maxDepth - 1)).join(''); if (typeof children === 'object' && 'props' in children) { - return extractTextContent((children as React.ReactElement).props.children as React.ReactNode); + // 使用 React.ReactElement<{ children?: React.ReactNode }> 显式声明 props.children 类型 + return extractTextContent( + (children as React.ReactElement<{ children?: React.ReactNode }>).props.children as React.ReactNode, + maxDepth - 1, + ); } return ''; } diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 4cdbce1..638e39c 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -10,8 +10,9 @@ */ import { useState, useCallback, useRef, useEffect } from 'react'; -import { Box, Typography, IconButton, Tooltip, Stack, Button, Paper } from '@mui/material'; +import { Box, Typography, IconButton, Tooltip, Stack, Button, Paper, InputBase } from '@mui/material'; import { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react'; +import { nanoid } from 'nanoid'; import { useAgentStore } from '@renderer/stores/agent-store'; import { useSessionStore } from '@renderer/stores/session-store'; import { useUIStore } from '@renderer/stores/ui-store'; @@ -67,7 +68,8 @@ export function ChatInput(): React.JSX.Element { const processFile = useCallback(async (file: File): Promise => { const type = classifyFile(file); - const attachment: Attachment = { id: `att_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, file, type }; + // L-10 修复: 统一使用 nanoid 生成附件 ID(与项目其他位置一致) + const attachment: Attachment = { id: `att_${nanoid(6)}`, file, type }; if (type === 'image') { // 图片转 base64 data URL → 压缩(限制 1024px, JPEG 0.7) @@ -241,8 +243,18 @@ export function ChatInput(): React.JSX.Element { const handleAbort = useCallback(() => { abort(); }, [abort]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - // Ctrl+Enter — 换行 - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + // H-9 修复: 快捷键对齐规范 + // @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 快捷键规范 + // 规范要求: Cmd/Ctrl+Enter = 发送消息, Cmd/Ctrl+Shift+Enter = 换行 + // 之前代码是 Ctrl+Enter=换行, Enter=发送,与规范相反 + // 修复后: + // Cmd/Ctrl+Enter = 发送消息(规范要求) + // Cmd/Ctrl+Shift+Enter = 换行(规范要求) + // Enter = 发送消息(保持聊天应用习惯) + // Shift+Enter = 换行(textarea 默认行为,无需处理) + + // Cmd/Ctrl+Shift+Enter — 换行(规范要求) + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && e.shiftKey) { e.preventDefault(); const t = e.currentTarget as HTMLTextAreaElement; const s = t.selectionStart; @@ -251,12 +263,19 @@ export function ChatInput(): React.JSX.Element { requestAnimationFrame(() => { t.selectionStart = t.selectionEnd = s + 1; }); return; } - // Enter — 发送 + // Cmd/Ctrl+Enter — 发送消息(规范要求,优先于 Enter) + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSend(); + return; + } + // Enter(不带修饰键)— 发送消息(保持聊天应用习惯) if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); return; } if (e.key === 'Escape' && showSlashMenu) { setShowSlashMenu(false); return; } }, [handleSend, showSlashMenu]); - const handleChange = useCallback((e: React.ChangeEvent) => { + // H-8 修复: 使用 InputBase 后,onChange 类型需兼容 HTMLInputElement | HTMLTextAreaElement + const handleChange = useCallback((e: React.ChangeEvent) => { const v = e.target.value; setInput(v); if (v === '/') { setShowSlashMenu(true); setSlashFilter(''); } else if (v.startsWith('/') && !v.includes(' ')) { setShowSlashMenu(true); setSlashFilter(v.slice(1).toLowerCase()); } @@ -298,12 +317,35 @@ export function ChatInput(): React.JSX.Element { -