diff --git a/electron/harness/adapters/agnes-ai.adapter.ts b/electron/harness/adapters/agnes-ai.adapter.ts index 6862b56..a07bd2f 100644 --- a/electron/harness/adapters/agnes-ai.adapter.ts +++ b/electron/harness/adapters/agnes-ai.adapter.ts @@ -47,7 +47,8 @@ export class AgnesAdapter extends BaseAdapter { async send(request: MetonaRequest): Promise { const body = this.toNativeRequest(request, false); - const response = await fetch(`${this.config.baseURL}/chat/completions`, { + // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理 + const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -55,9 +56,7 @@ export class AgnesAdapter extends BaseAdapter { ...this.config.headers, }, body: JSON.stringify(body), - // C-2 修复: 使用合并后的 signal(外部 abort + timeout) - signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000), - }); + }, this.config.timeoutMs ?? 300_000); if (!response.ok) { await this.throwHttpError(response, 'Agnes AI API error'); @@ -88,7 +87,8 @@ export class AgnesAdapter extends BaseAdapter { async *sendStream(request: MetonaRequest): AsyncIterable { const body = this.toNativeRequest(request, true); - const response = await fetch(`${this.config.baseURL}/chat/completions`, { + // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理 + const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -96,9 +96,7 @@ export class AgnesAdapter extends BaseAdapter { ...this.config.headers, }, body: JSON.stringify(body), - // C-2 修复: 使用合并后的 signal(外部 abort + timeout) - signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000), - }); + }, this.config.timeoutMs ?? 300_000); if (!response.ok || !response.body) { await this.throwHttpError(response, 'Agnes AI stream error'); diff --git a/electron/harness/adapters/base-adapter.ts b/electron/harness/adapters/base-adapter.ts index 0c2c43c..2100ae4 100644 --- a/electron/harness/adapters/base-adapter.ts +++ b/electron/harness/adapters/base-adapter.ts @@ -73,6 +73,9 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { * * @param timeoutMs 超时时间(毫秒) * @returns 合并后的 AbortSignal + * @deprecated 审查修复 M20: 使用 fetchWithTimeout 替代。 + * getFetchSignal 内部 AbortSignal.timeout() 创建的 timer 在请求成功完成后仍会存活到超时, + * 高频调用下 timer 句柄累积;fetchWithTimeout 用 setTimeout + clearTimeout 已解决此问题。 */ protected getFetchSignal(timeoutMs: number): AbortSignal { const timeoutSignal = AbortSignal.timeout(timeoutMs); @@ -92,6 +95,46 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { return AbortSignal.any([timeoutSignal, this.externalAbortSignal]); } + /** + * #24 修复: 封装 fetch + 超时控制,在 finally 中 clearTimeout,避免 timer 泄漏 + * + * getFetchSignal 使用 AbortSignal.timeout() 内部创建的 timer 在请求成功完成后 + * 仍会存活到超时,高频调用下 timer 句柄累积。本方法使用 setTimeout + clearTimeout + * 确保 fetch 完成(无论成功/失败/abort)后立即清理 timer。 + * + * @param url 请求 URL + * @param init fetch init(不含 signal,由本方法内部管理) + * @param timeoutMs 超时时间(毫秒) + */ + protected async fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + // 审查修复 M20: 保存 listener 引用,finally 中 removeEventListener 清理,避免 listener 泄漏 + const onExternalAbort = () => controller.abort(); + + try { + // 合并外部 abort signal(来自 Engine 的 abortController) + if (this.externalAbortSignal) { + if (this.externalAbortSignal.aborted) { + controller.abort(); + } else { + this.externalAbortSignal.addEventListener('abort', onExternalAbort, { once: true }); + } + } + + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + // #24 修复: 关键 — 无论请求成功、失败还是 abort,都清理 timer + clearTimeout(timer); + // 审查修复 M20: 清理 externalAbortSignal 上注册的 listener + // (即使 { once: true },请求正常完成时 listener 仍挂在 signal 上直到 abort 或 GC,需显式移除) + if (this.externalAbortSignal) { + this.externalAbortSignal.removeEventListener('abort', onExternalAbort); + } + } + } + async healthCheck(): Promise { try { await this.listModels(); @@ -150,7 +193,12 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { }; } - if (msg.includes('401') || msg.includes('unauthorized')) { + // #23 修复: 优先基于 HTTP status code 判断 401/429,避免字符串 includes 误匹配 URL 端口等数字 + // throwHttpError 已将 response.status 挂到 error.status,优先读取此字段 + const httpStatus = (error as Error & { status?: number }).status; + + // #23 修复: 401 认证失败 — 优先用 status code,'unauthorized' 是单词不会误匹配 + if (httpStatus === 401 || msg.includes('unauthorized')) { return { code: MetonaErrorCode.AUTH_INVALID, message: 'API key 无效或已过期', @@ -159,7 +207,8 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { }; } - if (msg.includes('429') || msg.includes('rate limit')) { + // #23 修复: 429 限流 — 优先用 status code,'rate limit' 是单词不会误匹配 + if (httpStatus === 429 || msg.includes('rate limit')) { return { code: MetonaErrorCode.RATE_LIMITED, message: '请求过于频繁,请稍后重试', diff --git a/electron/harness/adapters/deepseek.adapter.ts b/electron/harness/adapters/deepseek.adapter.ts index 16bbd84..8ab35c2 100644 --- a/electron/harness/adapters/deepseek.adapter.ts +++ b/electron/harness/adapters/deepseek.adapter.ts @@ -52,7 +52,8 @@ export class DeepSeekAdapter extends BaseAdapter { async send(request: MetonaRequest): Promise { const body = this.toNativeRequest(request, false); - const response = await fetch(`${this.config.baseURL}/chat/completions`, { + // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理 + const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -60,9 +61,7 @@ export class DeepSeekAdapter extends BaseAdapter { ...this.config.headers, }, body: JSON.stringify(body), - // C-2 修复: 使用合并后的 signal(外部 abort + timeout) - signal: this.getFetchSignal(this.config.timeoutMs ?? 120_000), - }); + }, this.config.timeoutMs ?? 120_000); if (!response.ok) { await this.throwHttpError(response, 'DeepSeek API error'); @@ -93,7 +92,8 @@ export class DeepSeekAdapter extends BaseAdapter { async *sendStream(request: MetonaRequest): AsyncIterable { const body = this.toNativeRequest(request, true); - const response = await fetch(`${this.config.baseURL}/chat/completions`, { + // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理 + const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -101,9 +101,7 @@ export class DeepSeekAdapter extends BaseAdapter { ...this.config.headers, }, body: JSON.stringify(body), - // C-2 修复: 使用合并后的 signal(外部 abort + timeout) - signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000), - }); + }, this.config.timeoutMs ?? 300_000); if (!response.ok || !response.body) { await this.throwHttpError(response, 'DeepSeek stream error'); diff --git a/electron/harness/adapters/mimo.adapter.ts b/electron/harness/adapters/mimo.adapter.ts index 83734a3..f69ad8e 100644 --- a/electron/harness/adapters/mimo.adapter.ts +++ b/electron/harness/adapters/mimo.adapter.ts @@ -59,7 +59,8 @@ export class MimoAdapter extends BaseAdapter { async send(request: MetonaRequest): Promise { const body = this.toNativeRequest(request, false); - const response = await fetch(`${this.config.baseURL}/chat/completions`, { + // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理 + const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -67,8 +68,7 @@ export class MimoAdapter extends BaseAdapter { ...this.config.headers, }, body: JSON.stringify(body), - signal: this.getFetchSignal(this.config.timeoutMs ?? 120_000), - }); + }, this.config.timeoutMs ?? 120_000); if (!response.ok) { await this.throwHttpError(response, 'MiMo API error'); @@ -98,7 +98,8 @@ export class MimoAdapter extends BaseAdapter { async *sendStream(request: MetonaRequest): AsyncIterable { const body = this.toNativeRequest(request, true); - const response = await fetch(`${this.config.baseURL}/chat/completions`, { + // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理 + const response = await this.fetchWithTimeout(`${this.config.baseURL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -106,8 +107,7 @@ export class MimoAdapter extends BaseAdapter { ...this.config.headers, }, body: JSON.stringify(body), - signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000), - }); + }, this.config.timeoutMs ?? 300_000); if (!response.ok || !response.body) { await this.throwHttpError(response, 'MiMo stream error'); @@ -194,7 +194,12 @@ export class MimoAdapter extends BaseAdapter { model: this.config.defaultModel, messages, // MiMo 使用 max_completion_tokens(非 max_tokens) - max_completion_tokens: request.params.maxTokens, + // #41 修复: thinking 模式下 max_completion_tokens 未配置时使用兜底默认值(32768) + // thinking 占用 token 配额,未配置时 API 默认值可能过小导致输出被截断 + // thinkingEnabled !== false 包含 true 和 undefined(MiMo 默认 enabled)两种情况 + // 审查修复: 兜底值从 63488 改为 32768,避免超出标准版上限导致 API 400 + max_completion_tokens: request.params.maxTokens + ?? (request.params.thinkingEnabled !== false ? 32768 : undefined), stream, }; diff --git a/electron/harness/adapters/ollama.adapter.ts b/electron/harness/adapters/ollama.adapter.ts index 178e74c..1360f2f 100644 --- a/electron/harness/adapters/ollama.adapter.ts +++ b/electron/harness/adapters/ollama.adapter.ts @@ -50,15 +50,15 @@ export class OllamaAdapter extends BaseAdapter { // H-2 修复: chat → send(规范要求) async send(request: MetonaRequest): Promise { - const nativeRequest = this.toNativeRequest(request); + // #2 修复: toNativeRequest 改为 async(需下载 URL 图片转 base64) + const nativeRequest = await this.toNativeRequest(request); - const response = await fetch(`${this.baseURL}/api/chat`, { + // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理 + const response = await this.fetchWithTimeout(`${this.baseURL}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...nativeRequest, stream: false }), - // C-2 修复: 使用合并后的 signal(外部 abort + timeout) - signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000), - }); + }, this.config.timeoutMs ?? 300_000); if (!response.ok) { await this.throwHttpError(response, 'Ollama API error'); @@ -70,15 +70,15 @@ export class OllamaAdapter extends BaseAdapter { // H-2 修复: chatStream → sendStream(规范要求) async *sendStream(request: MetonaRequest): AsyncIterable { - const nativeRequest = this.toNativeRequest(request); + // #2 修复: toNativeRequest 改为 async(需下载 URL 图片转 base64) + const nativeRequest = await this.toNativeRequest(request); - const response = await fetch(`${this.baseURL}/api/chat`, { + // #24 修复: 使用 fetchWithTimeout 替代 getFetchSignal + fetch,确保 timer 清理 + const response = await this.fetchWithTimeout(`${this.baseURL}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...nativeRequest, stream: true }), - // C-2 修复: 使用合并后的 signal(外部 abort + timeout) - signal: this.getFetchSignal(this.config.timeoutMs ?? 300_000), - }); + }, this.config.timeoutMs ?? 300_000); if (!response.ok || !response.body) { await this.throwHttpError(response, 'Ollama stream error'); @@ -409,8 +409,33 @@ export class OllamaAdapter extends BaseAdapter { // ========== 私有转换方法 ========== - private toNativeRequest(request: MetonaRequest): Record { - const messages = [ + /** + * #2 修复: 下载 http(s) URL 图片并转为纯 base64 字符串(不含 data: 前缀) + * + * Ollama API 的 images 字段要求纯 base64 字符串数组。 + * 当 MetonaMessage.images 中存储的是 URL 时,需先下载转为 base64。 + * 下载失败时返回空字符串(Ollama 会忽略空图片),不阻断整个请求。 + */ + private async resolveImageToBase64(url: string): Promise { + try { + // 审查修复: 使用基类 fetchWithTimeout 合并 externalAbortSignal 和 30s 超时, + // 避免用户中断时图片下载最多阻塞 30s×N(externalAbortSignal 是 BaseAdapter 的 + // private 属性,子类无法直接访问,故复用已合并 signal 的 fetchWithTimeout, + // 该方法同时处理了 listener 泄漏问题) + const res = await this.fetchWithTimeout(url, {}, 30_000); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const buf = Buffer.from(await res.arrayBuffer()); + return buf.toString('base64'); + } catch (error) { + log.warn(`[Ollama] Failed to download image ${url.slice(0, 100)}: ${(error as Error).message}`); + return ''; + } + } + + private async toNativeRequest(request: MetonaRequest): Promise> { + const messages: Record[] = [ { role: 'system', content: [ @@ -420,20 +445,34 @@ export class OllamaAdapter extends BaseAdapter { request.systemPrompt.dynamicReminders, ].filter(Boolean).join('\n\n'), }, - ...request.messages.filter((m) => m.role !== 'system').map((m) => { + ]; + + // #2 修复: 改为 for 循环以支持 async 图片下载(map 回调无法 await) + for (const m of request.messages) { + if (m.role === 'system') continue; // 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) => { + // #2 修复: 支持公网 URL 图片,下载后转为纯 base64 + // 之前直接将 URL 字符串传给 Ollama,导致 base64 解码错误 + const resolvedImages: string[] = []; + for (const img of m.images) { const url = img.url; - // data:image/png;base64,iVBOR... → iVBOR... if (url.startsWith('data:')) { + // data:image/png;base64,iVBOR... → iVBOR... const base64Part = url.split(',')[1]; - return base64Part ?? url; + resolvedImages.push(base64Part ?? url); + } else if (url.startsWith('http://') || url.startsWith('https://')) { + // #2 修复: 公网 URL → 下载 → 纯 base64 + const base64 = await this.resolveImageToBase64(url); + if (base64) resolvedImages.push(base64); + } else { + // 已是纯 base64 字符串(无 data: 前缀) + resolvedImages.push(url); } - return url; - }); + } + msg.images = resolvedImages; } // 工具结果 if (m.role === 'tool' && m.toolResult) { @@ -455,9 +494,8 @@ export class OllamaAdapter extends BaseAdapter { if (m.role === 'assistant' && m.reasoningContent) { (msg as Record).reasoning_content = m.reasoningContent; } - return msg; - }), - ]; + messages.push(msg); + } const body: Record = { model: this.config.defaultModel, diff --git a/electron/harness/adapters/shared/openai-format.ts b/electron/harness/adapters/shared/openai-format.ts index 2ab339e..21492bf 100644 --- a/electron/harness/adapters/shared/openai-format.ts +++ b/electron/harness/adapters/shared/openai-format.ts @@ -46,6 +46,11 @@ export function buildOpenAICompatibleMessages( content: m.content, }; + // 注意:图片(多模态)处理不在此共享函数中。 + // DeepSeek 不支持多模态,images 被静默丢弃是正确行为。 + // Agnes/MiMo 各自的 toNativeRequest 中有独立的 images 处理。 + // 审查修复: #27 曾在此添加 images 处理,但 DeepSeek 不支持多模态会导致 API 400,已撤销。 + // === Assistant 消息 === if (m.role === 'assistant') { // 工具调用历史 @@ -74,6 +79,10 @@ export function buildOpenAICompatibleMessages( : (typeof m.toolResult.result === 'string' ? m.toolResult.result : JSON.stringify(m.toolResult.result)); + // #26 修复: 确保 tool 消息 content 不为 undefined + // JSON.stringify(undefined) 返回 undefined(非字符串),会导致 content 字段在序列化后消失 + // OpenAI/DeepSeek/Agnes API 严格要求 tool 消息必须有 content 字段,缺失会返回 400 + if (msg.content === undefined) msg.content = ''; } return msg; diff --git a/electron/harness/adapters/shared/sse-stream.ts b/electron/harness/adapters/shared/sse-stream.ts index 3e81385..05eaba7 100644 --- a/electron/harness/adapters/shared/sse-stream.ts +++ b/electron/harness/adapters/shared/sse-stream.ts @@ -52,8 +52,14 @@ function* flushToolCallBuffer( timestamp: Date.now(), }, }; - } catch { - // JSON 解析失败,跳过该工具调用 + } catch (err) { + // #25 修复: 不再静默丢弃 JSON 解析失败的工具调用 + // 审查修复: 不再 yield ERROR 事件,因为 Engine 收到 ERROR 会 throw 中断整个请求, + // 导致一个好的工具调用 JSON 解析失败就丢弃所有工具调用。 + // 改为 log.warn 记录后 continue 跳过这条坏的工具调用,继续处理 buffer 中剩余的。 + const rawPreview = buf.argsBuffer?.slice(0, 200) ?? ''; + log.warn(`[SSE] Tool call JSON parse failed: ${(err as Error).message}`, rawPreview); + continue; } } toolCallsBuffer.clear(); diff --git a/electron/harness/agent-loop/engine.ts b/electron/harness/agent-loop/engine.ts index 86eb4b3..9757ccc 100644 --- a/electron/harness/agent-loop/engine.ts +++ b/electron/harness/agent-loop/engine.ts @@ -125,6 +125,22 @@ export class AgentLoopEngine extends EventEmitter { */ setAdapter(adapter: IMetonaProviderAdapter): void { this.adapter = adapter; + // #3 修复: 热切换 adapter 时同步 contextWindow,防止压缩阈值计算错误 + this.syncContextWindow(); + } + + /** + * #3 修复: 从 adapter 同步 contextWindow 到 Engine 配置 + * + * Engine 的 DEFAULT_CONFIG.contextWindow 硬编码为 128_000,但各 Provider 实际支持的 + * 上下文窗口差异巨大(DeepSeek 64K / Agnes 1M / Ollama 4096)。 + * 不同步会导致压缩阈值(compressionThreshold * contextWindow)计算错误。 + */ + private syncContextWindow(): void { + const adapterCtx = this.adapter.getContextWindow?.(); + if (adapterCtx && adapterCtx > 0) { + this.config.contextWindow = adapterCtx; + } } /** @@ -181,6 +197,9 @@ export class AgentLoopEngine extends EventEmitter { if (this.adapter.setAbortSignal) { this.adapter.setAbortSignal(this.abortController.signal); } + // #3 修复: 每次 runStream 开始时从 adapter 同步 contextWindow, + // 确保压缩阈值基于当前 Provider 的实际上下文窗口 + this.syncContextWindow(); this.eventSeq = 0; // v0.3.0: 重置工具调用历史(用于死循环检测) this.toolCallHistory = []; @@ -302,6 +321,18 @@ export class AgentLoopEngine extends EventEmitter { return this.adapter; } + /** + * #4 修复: 恢复 adapter 的 abort signal + * + * SubEngine 共享主 Engine 的 adapter 时,SubEngine 会覆盖 adapter 的 abort signal。 + * SubEngine 完成后,主 Engine 需调用此方法恢复自己的 signal,否则后续 fetch 无法被中断。 + */ + restoreAbortSignal(): void { + if (this.abortController && this.adapter.setAbortSignal) { + this.adapter.setAbortSignal(this.abortController.signal); + } + } + /** 获取工作空间路径(供 SubAgent 继承) */ getWorkspacePath(): string { return this.workspacePath; @@ -331,14 +362,29 @@ export class AgentLoopEngine extends EventEmitter { */ async waitForAbort(timeoutMs: number = 5_000): Promise { if (!this.currentRunPromise) return true; + // #29 修复: 原实现 Promise.race 永不抛错(currentRunPromise.catch 已吞异常), + // 导致 try/catch 永远走不到,总是返回 true。调用方误以为 abort 完成, + // 立即重发的新消息会卡在 runStream 的 currentRunPromise 等待中。 + // 改为用 timedOut 标志检测超时,超时返回 false 让调用方提示用户"上一次操作未完成"。 + let timedOut = false; + // 审查修复: 用 finally 块清理 timer,避免 currentRunPromise 先完成时 setTimeout 残留 + let timerHandle: NodeJS.Timeout | undefined; + const timer = new Promise((resolve) => { + timerHandle = setTimeout(() => { + timedOut = true; + resolve(); + }, timeoutMs); + }); + try { await Promise.race([ this.currentRunPromise.catch(() => {}), - new Promise((resolve) => setTimeout(resolve, timeoutMs)), + timer, ]); - return true; - } catch { - return false; + return !timedOut; // 超时返回 false,run 正常结束返回 true + } finally { + // 审查修复: 无论 race 谁先完成,都清理 timer 防止事件循环残留 + if (timerHandle) clearTimeout(timerHandle); } } @@ -1058,18 +1104,30 @@ export class AgentLoopEngine extends EventEmitter { if (!summary) return null; const summaryMessage: MetonaMessage = { - // CE-1 修复: 使用 role: 'user' 而非 'system' - // adapter 的 buildOpenAICompatibleMessages 会过滤所有 role !== 'system' 的消息, - // 只保留 systemPrompt 构建的 system 消息。如果用 'system',摘要会被丢弃,压缩无效。 - // 改为 'user' 后 LLM 会看到:system(systemPrompt) → user(摘要) → ...toKeep - role: 'user', + // #30 修复: 改用 assistant 角色注入摘要,避免语义混淆 + // 原 CE-1 修复用 'user' 角色,会导致 LLM 将摘要误视为新的用户指令, + // 可能基于"Summary of previous conversation"字面意思执行奇怪操作。 + // 工单建议方案 A(system 角色)不可行:buildOpenAICompatibleMessages 会 + // 过滤所有 role === 'system' 的消息(只保留 systemPrompt 构建的 system 消息), + // 用 system 角色摘要会被丢弃,压缩无效。 + // 采用 assistant 角色:既不会被过滤,又保持语义中立(摘要是 AI 生成的总结), + // LLM 不会将其视为新的用户指令。 + role: 'assistant', content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`, timestamp: Date.now(), }; log.info(`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${toKeep.length} recent`); - return [summaryMessage, ...toKeep]; + // 审查修复: #30 修复将摘要改为 assistant 角色,可能导致连续两个 assistant 消息 + // (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。 + // 插入占位 user 消息保证对话流清晰。 + return [ + summaryMessage, + // 审查修复: 插入占位 user 消息避免连续 assistant 消息 + { role: 'user', content: '[Continue from the summary above.]', timestamp: Date.now() }, + ...toKeep, + ]; } catch (error) { log.warn('[AgentLoop] Context compression failed, keeping original messages:', (error as Error).message); return null; diff --git a/electron/harness/hooks/confirmation-hook.ts b/electron/harness/hooks/confirmation-hook.ts index 57462d0..af60236 100644 --- a/electron/harness/hooks/confirmation-hook.ts +++ b/electron/harness/hooks/confirmation-hook.ts @@ -324,8 +324,21 @@ export class ConfirmationHook implements PreToolHook { return new Promise((resolve) => { const expiresAt = Date.now() + this.confirmationTimeoutMs; + // #18 修复: 超时与用户确认的竞态条件防护 + // 场景:超时 setTimeout 回调已进入事件循环队列但尚未执行时,用户点击确认, + // resolveConfirmation 中 clearTimeout 无法取消已排队的回调, + // 导致用户已确认但仍弹出"超时 toast"等副作用。 + // 使用 settled 标志确保超时分支与确认分支互斥,先到者赢,另一分支直接 return。 + let settled = false; + const safeResolve = (v: boolean) => { + if (settled) return; + settled = true; + resolve(v); + }; + // 设置超时 const timer = setTimeout(() => { + if (settled) return; // 已被 resolveConfirmation 处理,跳过超时副作用 this.pendingConfirmations.delete(request.toolCallId); // 超时发送 toast 通知用户(3 秒节流,防止并行工具风暴) if (this.mainWindow && !this.mainWindow.isDestroyed()) { @@ -343,11 +356,11 @@ export class ConfirmationHook implements PreToolHook { }); } } - resolve(false); // 超时视为拒绝 + safeResolve(false); // 超时视为拒绝 }, this.confirmationTimeoutMs); this.pendingConfirmations.set(request.toolCallId, { - resolve, + resolve: safeResolve, timer, toolName: request.toolName, expiresAt, diff --git a/electron/harness/hooks/post-tool.ts b/electron/harness/hooks/post-tool.ts index 88782f2..e4670eb 100644 --- a/electron/harness/hooks/post-tool.ts +++ b/electron/harness/hooks/post-tool.ts @@ -20,16 +20,24 @@ export class AuditLogHook implements PostToolHook { constructor(private auditService: AuditService) {} async afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise { - this.auditService.logToolCall({ - sessionId, - iteration: toolCall.iteration, - toolName: toolCall.name, - args: toolCall.args, - outcome: result.success ? 'success' : 'error', - result: result.result, - error: result.error, - durationMs: result.durationMs, - }); + // #17 修复: AuditHook 应为 "fire and forget",hook 失败不应影响工具执行链 + // 虽然 AuditService.log() 内部已 try-catch,但 hook 层再加一层防御, + // 确保任何意外异常(如 getDB 抛错、JSON.stringify 失败)都不会冒泡到 ToolRegistry + try { + this.auditService.logToolCall({ + sessionId, + iteration: toolCall.iteration, + toolName: toolCall.name, + args: toolCall.args, + outcome: result.success ? 'success' : 'error', + result: result.result, + error: result.error, + durationMs: result.durationMs, + }); + } catch (err) { + log.error('[AuditLogHook] Failed to log audit:', err); + // 不抛出,让工具执行链继续 + } } } diff --git a/electron/harness/memory/manager.ts b/electron/harness/memory/manager.ts index 7068b90..9ab1d52 100644 --- a/electron/harness/memory/manager.ts +++ b/electron/harness/memory/manager.ts @@ -14,6 +14,7 @@ */ import { nanoid } from 'nanoid'; +import { createHash } from 'crypto'; import type Database from 'better-sqlite3'; import log from 'electron-log'; @@ -352,17 +353,21 @@ export class MemoryManager { break; case 'semantic': // v0.3.0 修复:使用 summary 作为 key(若提供),支持更新已有语义记忆 + // #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重 + // v0.3.0 用 id 作为 key 时,因 id 每次新生成,INSERT OR REPLACE 永远不触发 REPLACE, + // 导致重复 store 同一内容会创建多条记忆。改为 contentHash 后,相同内容自动 REPLACE。 db.prepare(` INSERT OR REPLACE INTO semantic_memories (id, key, value, category, confidence, source_session, created_at, updated_at, access_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) - `).run(id, item.summary ?? id, item.content, 'general', importance, item.sessionId ?? null, now, now); + `).run(id, item.summary ?? this.contentHash(item.content), item.content, 'general', importance, item.sessionId ?? null, now, now); break; case 'working': // v0.3.0 修复:使用 summary 作为 key(若提供),避免硬编码 'default' 导致覆盖 + // #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重 db.prepare(` INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at) VALUES (?, ?, ?, ?, ?, ?) - `).run(id, item.sessionId ?? 'default', 'default', item.summary ?? id, item.content, now); + `).run(id, item.sessionId ?? 'default', 'default', item.summary ?? this.contentHash(item.content), item.content, now); break; default: // v0.3.0 修复:未知 type 抛错而非静默失败 @@ -519,4 +524,13 @@ export class MemoryManager { if (item.content.length > 200) score += 0.1; return Math.min(1, Math.max(0, score)); } + + /** + * #32 修复: 计算 content 的 SHA-256 hash(取前 16 字符),用于基于内容的去重 + * 当 store 未提供 summary 时,用 contentHash 作为 semantic/working 的 key, + * 使 INSERT OR REPLACE 能基于内容触发 REPLACE,避免重复存储相同内容。 + */ + private contentHash(content: string): string { + return createHash('sha256').update(content, 'utf-8').digest('hex').slice(0, 16); + } } diff --git a/electron/harness/orchestration/orchestrator.ts b/electron/harness/orchestration/orchestrator.ts index f6ddbb9..aa19615 100644 --- a/electron/harness/orchestration/orchestrator.ts +++ b/electron/harness/orchestration/orchestrator.ts @@ -176,13 +176,6 @@ export class TaskOrchestrator extends EventEmitter { handle.status = success ? 'completed' : 'error'; handle.result = result; - this.activeSubAgents.delete(taskId); - // v0.3.0 修复: 深度恢复为 0 时删除条目,避免 sessionDepth Map 随会话累积 - if (currentDepth === 0) { - this.sessionDepth.delete(params.parentSessionId); - } else { - this.sessionDepth.set(params.parentSessionId, currentDepth); - } this.emit('taskCompleted', result); log.info(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) ${success ? 'completed' : 'failed'} in ${durationMs}ms, ${output.iterations.length} iterations`); @@ -201,18 +194,28 @@ export class TaskOrchestrator extends EventEmitter { handle.status = 'error'; handle.result = result; - this.activeSubAgents.delete(taskId); - // v0.3.0 修复: 深度恢复为 0 时删除条目,避免 sessionDepth Map 随会话累积 - if (currentDepth === 0) { - this.sessionDepth.delete(params.parentSessionId); - } else { - this.sessionDepth.set(params.parentSessionId, currentDepth); - } this.emit('taskError', { taskId, error: errMsg }); log.error(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) error: ${errMsg}`); return result; + } finally { + // #5 修复: 统一在 finally 块恢复 sessionDepth,覆盖正常完成/异常/abort 所有路径 + // 之前 try 和 catch 中各有一份重复的恢复逻辑,且 abort 路径(handle.abort)跳过了恢复 + // 审查修复: 如果 abortAll 已 clear sessionDepth,不再恢复(避免覆盖紧急清理)。 + // 场景:用户紧急中断时 abortAll 先 clear,若 SubEngine 随后才返回执行 finally, + // 不应把已清空的 sessionDepth 又 set 回 currentDepth。 + if (this.sessionDepth.has(params.parentSessionId)) { + if (currentDepth === 0) { + this.sessionDepth.delete(params.parentSessionId); + } else { + this.sessionDepth.set(params.parentSessionId, currentDepth); + } + } + this.activeSubAgents.delete(taskId); + // #4 修复: SubEngine 共享主 Engine 的 adapter,完成后必须恢复主 Engine 的 abort signal, + // 否则主 Engine 后续 fetch 无法被用户中断(SubEngine 覆盖并清除了 adapter 的 signal) + this.mainEngine.restoreAbortSignal(); } } @@ -302,6 +305,10 @@ export class TaskOrchestrator extends EventEmitter { agent.abort(); } this.activeSubAgents.clear(); + // 审查修复: 恢复 sessionDepth.clear(),保留紧急清理能力。 + // #5 修复曾移除此行,但若 SubEngine 卡死不返回,delegate 的 finally 永远不会执行, + // sessionDepth 将永久残留。此处 clear 确保紧急路径能立即恢复状态。 + // 配合 delegate finally 块的 has() 检查:若已被 clear,finally 不再恢复(避免覆盖)。 this.sessionDepth.clear(); } } diff --git a/electron/harness/prompts/context-builder.ts b/electron/harness/prompts/context-builder.ts index e6e4a29..3b85311 100644 --- a/electron/harness/prompts/context-builder.ts +++ b/electron/harness/prompts/context-builder.ts @@ -94,12 +94,17 @@ export class ContextBuilder { // v0.3.14: 注入当前系统日期时间(每次构建时获取最新时间) // 用于让 AI 准确理解"今天"、"昨天"等相对时间表达 + // #43 修复: 时区硬编码 Asia/Shanghai 改为使用系统本地时区,跨时区用户显示正确 const now = new Date(); + const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'Asia/Shanghai'; + // 审查修复: 恢复 UTC 偏移显示,在时区名后附加 UTC 偏移,避免丢失时区偏移信息 + const offset = -now.getTimezoneOffset() / 60; + const offsetStr = offset >= 0 ? `UTC+${offset}` : `UTC${offset}`; const dateTimeStr = now.toLocaleString('zh-CN', { - timeZone: 'Asia/Shanghai', + timeZone: localTimezone, hour12: false, }); - dynamicParts.push(`## Current Date & Time\n${dateTimeStr} (Asia/Shanghai, UTC+8)`); + dynamicParts.push(`## Current Date & Time\n${dateTimeStr} (${localTimezone}, ${offsetStr})`); // 注入当前工作空间路径(动态区,路径可能切换故不放入静态区) if (workspacePath) { @@ -277,7 +282,20 @@ For multi-step complex tasks (3+ steps), proactively use \`task_manager\` to bre for (const mem of memories) total += estimateStringTokens(mem.content); // 工具定义 - for (const tool of tools) total += estimateStringTokens(tool.name + tool.description); + // #31 修复: 之前仅累加 tool.name + tool.description,忽略 tool.parameters(JSON Schema), + // 而 parameters schema 通常占工具 token 的 70%+,导致估算严重偏低、压缩阈值判断错误 + for (const tool of tools) { + total += estimateStringTokens(tool.name); + total += estimateStringTokens(tool.description); + // 审查修复: 用 try-catch 包裹 JSON.stringify,防止循环引用等异常导致估算中断 + if (tool.parameters) { + try { + total += estimateStringTokens(JSON.stringify(tool.parameters)); + } catch { + total += estimateStringTokens(String(tool.parameters)); + } + } + } // 用户输入 total += estimateStringTokens(userInput); diff --git a/electron/harness/sandbox/permissions.ts b/electron/harness/sandbox/permissions.ts index 3ea4051..b54752c 100644 --- a/electron/harness/sandbox/permissions.ts +++ b/electron/harness/sandbox/permissions.ts @@ -157,13 +157,32 @@ export class PolicyEngine { argsStr = String(args); } + // #14 修复: 深度递归扫描所有字符串字段值,防止 Unicode 转义/嵌套对象/字符串拼接绕过 + // 原 JSON.stringify 后整体正则匹配可被 \u0029 编码、嵌套对象伪装、正则注入等方式绕过 + // 现对每个字符串字段值单独匹配,同时保留整体 argsStr 兜底匹配 + const stringValues = this.deepScanStrings(args); + // v0.2.0: allowedPatterns 白名单校验 — 若定义了白名单,参数必须匹配其中之一 + // #14: 字段级匹配 + 整体兜底,任一匹配即通过 if (policy.allowedPatterns && policy.allowedPatterns.length > 0) { let matchedAllowed = false; - for (const pattern of policy.allowedPatterns) { - if (pattern.test(argsStr)) { - matchedAllowed = true; - break; + // 先做字段级匹配 + for (const val of stringValues) { + for (const pattern of policy.allowedPatterns) { + if (pattern.test(val)) { + matchedAllowed = true; + break; + } + } + if (matchedAllowed) break; + } + // 兜底:整体 argsStr 匹配 + if (!matchedAllowed) { + for (const pattern of policy.allowedPatterns) { + if (pattern.test(argsStr)) { + matchedAllowed = true; + break; + } } } if (!matchedAllowed) { @@ -176,7 +195,22 @@ export class PolicyEngine { } } + // #14 修复: deniedPatterns 字段级深度扫描 — 对每个字符串值单独匹配 + // 防止危险内容隐藏在嵌套对象或 Unicode 编码中绕过整体 stringify 匹配 if (policy.deniedPatterns) { + for (const val of stringValues) { + for (const pattern of policy.deniedPatterns) { + if (pattern.test(val)) { + return { + authorized: false, + reason: 'Command blocked by security policy', + level: policy.requiredLevel, + requiresConfirmation: false, + }; + } + } + } + // 兜底:整体 argsStr 匹配(保留原有行为,捕获跨字段拼接的危险模式) for (const pattern of policy.deniedPatterns) { if (pattern.test(argsStr)) { return { @@ -264,4 +298,37 @@ export class PolicyEngine { // v0.3.0 修复: cleanupFrequencyRecords 已删除 — checkFrequency 和 recordCall 已做内联清理, // 该方法属于死代码,删除以减少维护负担 + + /** + * #14 修复: 深度递归扫描对象,提取所有字符串字段值 + * + * 替代 JSON.stringify 后整体正则匹配的方式,防止: + * - Unicode 转义绕过(\u0029 等编码) + * - 嵌套对象伪装({a: {b: 'dangerous'}}) + * - 字符串拼接绕过('rm ' + '-rf /' 在 stringify 后是合法字符串) + * - 正则注入(输入中包含正则元字符破坏匹配逻辑) + * + * @param obj 待扫描的对象 + * @returns 所有字符串字段值的数组 + */ + // 审查修复: 添加 visited Set 参数防止循环引用导致无限递归栈溢出 + private deepScanStrings(obj: unknown, visited: Set = new Set()): string[] { + const results: string[] = []; + if (typeof obj === 'string') { + results.push(obj); + } else if (Array.isArray(obj)) { + if (visited.has(obj)) return results; + visited.add(obj); + for (const item of obj) { + results.push(...this.deepScanStrings(item, visited)); + } + } else if (obj && typeof obj === 'object') { + if (visited.has(obj)) return results; + visited.add(obj); + for (const v of Object.values(obj)) { + results.push(...this.deepScanStrings(v, visited)); + } + } + return results; + } } diff --git a/electron/harness/sandbox/sandbox.ts b/electron/harness/sandbox/sandbox.ts index abe357f..11a4e7f 100644 --- a/electron/harness/sandbox/sandbox.ts +++ b/electron/harness/sandbox/sandbox.ts @@ -95,6 +95,7 @@ export class SandboxManager { */ scanCode(code: string): { safe: boolean; reason?: string } { // C-5 修复: 补齐 28 个模式 + 加强 base64/$() 检测 + // #13 修复: 补全 require('fs')、动态 import、process.binding、new Function、Reflect.get 等绕过模式 // @see project_memory.md — sandbox scanCode must include 28 patterns with 'i' flag // and base64/$() detection to prevent encoding bypass const dangerousPatterns = [ @@ -102,6 +103,22 @@ export class SandboxManager { /require\s*\(\s*['"]child_process['"]\s*\)/i, /import\s+.*from\s+['"]fs['"]/i, /import\s+.*from\s+['"]child_process['"]/i, + // #13 新增: require('fs') 同步 require 形式(1) + /require\s*\(\s*['"]fs['"]\s*\)/i, + // #13 新增: 动态 import('fs') / import('child_process')(2) + /\bimport\s*\(\s*['"](?:fs|child_process|os|net|http|https|crypto|dns|cluster)['"]\s*\)/i, + // 审查修复 (M8): 兜底检测所有动态 import 调用 — 原模式只匹配字符串字面量, + // 动态 import 用变量 import(m) 可绕过。沙箱中应禁止所有动态 import。 + // 注意:此模式较宽泛,会拦截 import(safeModule),但沙箱 fail-closed 策略可接受。 + /\bimport\s*\(/i, + // #13 新增: process.binding('fs') 底层绑定(1) + /\bprocess\.binding\s*\(/i, + // #13 新增: new Function() 函数构造(1) + /\bnew\s+Function\s*\(/i, + // #13 新增: Reflect.get 字符串拼接绕过(1) + /\bReflect\.get\s*\(/i, + // #13 新增: Function('return process') 等函数构造执行(1) + /\bFunction\s*\(\s*['"]return\s+(?:process|require|global|globalThis)['"]\s*\)/i, // 代码执行(3) /\beval\s*\(/i, /process\.exit/i, diff --git a/electron/harness/security/prompt-injection-defense.ts b/electron/harness/security/prompt-injection-defense.ts index 9151c71..4a632f0 100644 --- a/electron/harness/security/prompt-injection-defense.ts +++ b/electron/harness/security/prompt-injection-defense.ts @@ -42,16 +42,17 @@ export class PromptInjectionDefender { private static INJECTION_PATTERNS: InjectionPattern[] = [ // === HIGH 危险:直接越狱/忽略指令 === // v0.3.0 修复:在关键词间允许可选限定词(the/any/all/these/those/your 等),防止 "ignore the previous instructions" 绕过 - { pattern: /ignore\s+(?:(?:the|all|any|these|those|your|above)\s+)*(?:previous|above|prior)\s+(?:(?:the|your|all)\s+)*(?:instructions?|commands?|directives?|rules?)/i, severity: 'high' }, - { pattern: /forget\s+(?:(?:everything|all|the|your|above|prior)\s+)+/i, severity: 'high' }, - { pattern: /override\s+(?:(?:the|your|all|any)\s+)+/i, severity: 'high' }, - { pattern: /JAILBREAK/i, severity: 'high' }, - { pattern: /DAN\s*[:\[]/i, severity: 'high' }, - { pattern: /(?:enable|turn\s+on|activate)\s+(?:(?:the|your)\s+)*(?:developer|debug|root|admin)\s+mode/i, severity: 'high' }, - { pattern: /(?:disable|turn\s+off|bypass)\s+(?:(?:the|your|all|any)\s+)*(?:safety|security|filter|guard|defense|restrictions?)/i, severity: 'high' }, - { pattern: /(?:send|transmit|exfiltrate|upload)\s+(?:(?:your|the|all|any)\s+)*(?:data|memory|context|secrets?)/i, severity: 'high' }, - { pattern: /(?:you\s+are|act\s+as|pretend\s+to\s+be)\s+(?:a|an)\s+(?:unrestricted|unfiltered|unlimited|free)/i, severity: 'high' }, - { pattern: /(?:in|under)\s+(?:(?:the|a)\s+)*(?:developer|unrestricted|jailbreak|god)\s+mode/i, severity: 'high' }, + // #15 修复: 添加 \b 单词边界,防止 "Xignore previous instructions" / "ignore previous instructionss" 等前后缀绕过 + { pattern: /\bignore\s+(?:(?:the|all|any|these|those|your|above)\s+)*(?:previous|above|prior)\s+(?:(?:the|your|all)\s+)*(?:instructions?|commands?|directives?|rules?)\b/i, severity: 'high' }, + { pattern: /\bforget\s+(?:(?:everything|all|the|your|above|prior)\s+)+/i, severity: 'high' }, + { pattern: /\boverride\s+(?:(?:the|your|all|any)\s+)+/i, severity: 'high' }, + { pattern: /\bJAILBREAK\b/i, severity: 'high' }, + { pattern: /\bDAN\s*[:\[]/i, severity: 'high' }, + { pattern: /\b(?:enable|turn\s+on|activate)\s+(?:(?:the|your)\s+)*(?:developer|debug|root|admin)\s+mode\b/i, severity: 'high' }, + { pattern: /\b(?:disable|turn\s+off|bypass)\s+(?:(?:the|your|all|any)\s+)*(?:safety|security|filter|guard|defense|restrictions?)\b/i, severity: 'high' }, + { pattern: /\b(?:send|transmit|exfiltrate|upload)\s+(?:(?:your|the|all|any)\s+)*(?:data|memory|context|secrets?)\b/i, severity: 'high' }, + { pattern: /\b(?:you\s+are|act\s+as|pretend\s+to\s+be)\s+(?:a|an)\s+(?:unrestricted|unfiltered|unlimited|free)\b/i, severity: 'high' }, + { pattern: /\b(?:in|under)\s+(?:(?:the|a)\s+)*(?:developer|unrestricted|jailbreak|god)\s+mode\b/i, severity: 'high' }, // 中文高危 { pattern: /忽略(?:以上|之前|前面|上面|所有)(?:的)?(?:指令|指示|命令|说明|规则)/i, severity: 'high' }, { pattern: /忘记(?:之前|上面|所有)(?:的)?(?:内容|对话|指令)/i, severity: 'high' }, @@ -59,15 +60,16 @@ export class PromptInjectionDefender { { pattern: /无视(?:以上|之前|前面|所有)(?:的)?(?:指令|指示|命令|规则)/i, severity: 'high' }, // === MEDIUM 危险:角色扮演/权限提升/编码注入 === - { pattern: /you\s+are\s+now/i, severity: 'medium' }, - { pattern: /new\s+(instructions|directive|role|persona)/i, severity: 'medium' }, - { pattern: /(show|display|print|output|reveal|tell\s+me)\s+(your|the)\s+(system\s+)?(prompt|instruction|directive)/i, severity: 'medium' }, - { pattern: /(dump|echo|log|expose)\s+(your|the)\s+(context|memory|history)/i, severity: 'medium' }, - { pattern: /pretend\s+(you\s+are|to\s+be)/i, severity: 'medium' }, - { pattern: /act\s+as\s+(if\s+you\s+were|you're)/i, severity: 'medium' }, - { pattern: /(?:grant|give)\s+(me|you|us)\s+(admin|root|sudo|super)/i, severity: 'medium' }, - { pattern: /base64\s*:?\s*[A-Za-z0-9+/=]{20,}/i, severity: 'medium' }, - { pattern: /eval\s*\(\s*atob\s*\(/i, severity: 'medium' }, + // #15 修复: 英文关键词模式添加 \b 单词边界 + { pattern: /\byou\s+are\s+now\b/i, severity: 'medium' }, + { pattern: /\bnew\s+(instructions|directive|role|persona)\b/i, severity: 'medium' }, + { pattern: /\b(show|display|print|output|reveal|tell\s+me)\s+(your|the)\s+(system\s+)?(prompt|instruction|directive)\b/i, severity: 'medium' }, + { pattern: /\b(dump|echo|log|expose)\s+(your|the)\s+(context|memory|history)\b/i, severity: 'medium' }, + { pattern: /\bpretend\s+(you\s+are|to\s+be)\b/i, severity: 'medium' }, + { pattern: /\bact\s+as\s+(if\s+you\s+were|you're)\b/i, severity: 'medium' }, + { pattern: /\b(?:grant|give)\s+(me|you|us)\s+(admin|root|sudo|super)\b/i, severity: 'medium' }, + { pattern: /\bbase64\s*:?\s*[A-Za-z0-9+/=]{20,}/i, severity: 'medium' }, + { pattern: /\beval\s*\(\s*atob\s*\(/i, severity: 'medium' }, { pattern: /\\x[0-9a-f]{2}\\x[0-9a-f]{2}\\x[0-9a-f]{2}/i, severity: 'medium' }, { pattern: /\\u[0-9a-f]{4}\\u[0-9a-f]{4}/i, severity: 'medium' }, // 中文中危 @@ -103,11 +105,17 @@ export class PromptInjectionDefender { recommendation: 'PASS: No injection patterns detected', }; } + + // #16 修复: Unicode 归一化 + 移除零宽字符,防止同形字符与零宽字符绕过 + // 攻击示例:іgnore (西里尔 і)、ignore\u200bprevious (零宽空格)、ignore\u00adprevious (软连字符) + // NFKC 归一化将兼容等价字符统一为规范形式,零宽字符移除后关键词检测可正常工作 + const normalized = this.normalizeForDetection(input); + const findings: InjectionFinding[] = []; let riskScore = 0; for (const { pattern, severity } of PromptInjectionDefender.INJECTION_PATTERNS) { - const matches = input.match(pattern); + const matches = normalized.match(pattern); if (matches) { findings.push({ pattern: pattern.source, @@ -118,6 +126,17 @@ export class PromptInjectionDefender { } } + // #16 新增: 检测可疑 Unicode 混合脚本(拉丁 + 西里尔/希腊),提高风险分数 + // 同形字符攻击通常需要混用拉丁与西里尔/希腊字母,正常输入很少混用 + if (this.hasMixedScripts(input)) { + findings.push({ + pattern: 'unicode:mixed_scripts', + matched: 'Mixed Latin/Cyrillic/Greek scripts detected (potential homoglyph attack)', + severity: 'medium', + }); + riskScore += 3; + } + return { isInjection: findings.length > 0, riskScore: Math.min(10, riskScore), @@ -148,21 +167,25 @@ export class PromptInjectionDefender { recommendation: 'PASS: No injection patterns detected', }; } + + // #16 修复: 语义检测同样使用归一化文本,防止 Unicode 绕过 + const normalized = this.normalizeForDetection(input); + const findings: InjectionFinding[] = []; let riskScore = 0; - // 1. 先执行正则快速检测 + // 1. 先执行正则快速检测(detect 内部已做归一化) const regexResult = this.detect(input); findings.push(...regexResult.findings); riskScore += regexResult.riskScore; - // 2. 语义级检测 + // 2. 语义级检测(使用归一化文本) // 2a. 指令性动词密度检测 - const imperativeCheck = this.checkImperativeDensity(input); + const imperativeCheck = this.checkImperativeDensity(normalized); if (imperativeCheck.isSuspicious) { findings.push({ pattern: 'semantic:imperative_density', - matched: `${imperativeCheck.count} imperative verbs in ${input.length} chars`, + matched: `${imperativeCheck.count} imperative verbs in ${normalized.length} chars`, severity: 'medium', }); riskScore += 2; @@ -170,7 +193,7 @@ export class PromptInjectionDefender { // 2b. 角色边界异常检测 if (context) { - const roleAnomaly = this.checkRoleBoundary(input, context); + const roleAnomaly = this.checkRoleBoundary(normalized, context); if (roleAnomaly) { findings.push(roleAnomaly); riskScore += 3; @@ -178,14 +201,14 @@ export class PromptInjectionDefender { } // 2c. 结构化分隔符嵌套检测 - const nestedFinding = this.checkNestedDelimiters(input); + const nestedFinding = this.checkNestedDelimiters(normalized); if (nestedFinding) { findings.push(nestedFinding); riskScore += 2; } // 2d. 隐式指令检测(通过疑问句伪装指令) - const implicitFinding = this.checkImplicitInstructions(input); + const implicitFinding = this.checkImplicitInstructions(normalized); if (implicitFinding) { findings.push(implicitFinding); riskScore += 1; @@ -199,6 +222,42 @@ export class PromptInjectionDefender { }; } + /** + * #16 修复: Unicode 归一化与零宽字符清理 + * + * 1. NFKC 归一化:将兼容等价字符(如西里尔 і → 拉丁 i)统一为规范形式 + * 2. 移除零宽字符:零宽空格(U+200B)、零宽连字符(U+200C)、零宽不连字符(U+200D)、 + * 软连字符(U+00AD)、BOM(U+FEFF) + * + * 这些字符在视觉上不可见,但会破坏正则关键词匹配,被用于绕过注入检测 + */ + private normalizeForDetection(input: string): string { + return input + .normalize('NFKC') + .replace(/[\u200b\u200c\u200d\u00ad\ufeff]/g, ''); + } + + /** + * #16 新增: 检测可疑 Unicode 混合脚本 + * + * 同形字符攻击通常混用拉丁与西里尔/希腊字母(如 іgnore 中 і 是西里尔字母)。 + * 正常技术输入很少混用这些脚本,检测到混用时提高风险分数。 + * NFKC 归一化可能无法完全消除所有同形字符,因此混合脚本检测作为补充手段。 + */ + // 审查修复: 改为统计各脚本字符数量,只有当非主导脚本的字符总数占比 > 30% 时才触发, + // 避免正常多语言输入(如 "Hello, Привет")误报 + private hasMixedScripts(text: string): boolean { + const latin = (text.match(/[\u0000-\u007f]/g) || []).length; + const cyrillic = (text.match(/[\u0400-\u04ff]/g) || []).length; + const greek = (text.match(/[\u0370-\u03ff]/g) || []).length; + const total = latin + cyrillic + greek; + if (total < 10) return false; // 文本太短不检测 + const max = Math.max(latin, cyrillic, greek); + const minorityRatio = (total - max) / total; + // 审查修复: 只有少数脚本占比 > 30% 时才触发,避免正常多语言输入误报 + return minorityRatio > 0.3; + } + /** * 指令性动词密度检测 * diff --git a/electron/harness/tools/built-in/browser-window-manager.ts b/electron/harness/tools/built-in/browser-window-manager.ts index 35af50f..636cc10 100644 --- a/electron/harness/tools/built-in/browser-window-manager.ts +++ b/electron/harness/tools/built-in/browser-window-manager.ts @@ -186,11 +186,44 @@ export class BrowserWindowManager { // ===== browserEvaluate ===== + /** + * 在浏览器页面上下文中执行 JS + * + * #46 修复: 增强安全防护 + * - 审计日志:记录所有 executeJavaScript 调用,便于事后追溯恶意操作 + * - 超时控制:防止恶意 JS 无限阻塞主进程(Electron 原生不支持超时,用 Promise.race 模拟) + * + * 注意:完全沙箱隔离较难实现(需要 iframe/Web Worker + API 白名单), + * 当前先实现审计 + 超时作为缓解措施。webPreferences 已配置 contextIsolation: true + * 和 sandbox: true,Electron API 不会被暴露给页面。 + */ async evaluate(js: string): Promise { this.ensureReady(); - const result = await this.win!.webContents.executeJavaScript(js, true); - if (typeof result === 'string') return result; - return result; + + // #46 修复: 审计日志 — 记录所有 executeJavaScript 调用(截断前 500 字符),便于追溯 + log.info('[BrowserWindowManager] evaluate JS (first 500 chars):', js.substring(0, 500)); + + // #46 修复: 超时控制 — 防止恶意 JS 无限阻塞主进程 + const EVAL_TIMEOUT_MS = 10_000; + let timer: ReturnType | undefined; + try { + const result = await Promise.race([ + this.win!.webContents.executeJavaScript(js, true), + new Promise((_, reject) => { + timer = setTimeout(() => { + // 审查修复 M17: 超时后中止页面 JS 执行。 + // executeJavaScript 返回的 Promise 无法取消,页面脚本仍会继续运行, + // 调用 webContents.stop() 中止页面正在执行的脚本(win 可能已销毁,try/catch 兜底)。 + try { this.win?.webContents.stop(); } catch {} + reject(new Error(`evaluate timed out after ${EVAL_TIMEOUT_MS}ms`)); + }, EVAL_TIMEOUT_MS); + }), + ]); + if (typeof result === 'string') return result; + return result; + } finally { + if (timer) clearTimeout(timer); + } } // ===== browserExtract ===== diff --git a/electron/harness/tools/built-in/code-search.ts b/electron/harness/tools/built-in/code-search.ts index abc0454..710048c 100644 --- a/electron/harness/tools/built-in/code-search.ts +++ b/electron/harness/tools/built-in/code-search.ts @@ -109,7 +109,10 @@ export class CodeSearchTool implements IMetonaTool { rgArgs.push('-g', '!MEMORY.md'); if (opts.fileGlob) rgArgs.push('-g', opts.fileGlob); - rgArgs.push(pattern, searchPath); + // #22 修复: 在 pattern 前加 -- 终止选项解析,防止以 - 开头的 pattern 被解释为选项 + // 攻击场景:pattern="--help" 输出帮助而非搜索,pattern="--ignore-file /etc/passwd" 可读取任意文件, + // pattern="--files" 列出所有文件。execFile 已用数组参数防 shell 注入,但 ripgrep 自身选项解析仍需防护 + rgArgs.push('--', pattern, searchPath); try { const { stdout } = await execFileAsync('rg', rgArgs, { diff --git a/electron/harness/tools/built-in/command.ts b/electron/harness/tools/built-in/command.ts index cc7a607..5294ef3 100644 --- a/electron/harness/tools/built-in/command.ts +++ b/electron/harness/tools/built-in/command.ts @@ -15,7 +15,7 @@ * @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 + run_command 安全规则 */ -import { exec } from 'child_process'; +import { exec, execFile } from 'child_process'; import { promisify } from 'util'; import { resolve } from 'path'; import { parse as shellQuoteParse } from 'shell-quote'; @@ -27,6 +27,7 @@ import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard import type { SandboxManager } from '../../sandbox/sandbox'; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // ===== Windows 编码智能解码 ===== // 某些 Windows 程序(dir、systeminfo、ipconfig 等)不响应 chcp 65001, @@ -48,6 +49,43 @@ function decodeBuffer(buf: Buffer): string { } } +/** + * #9 修复 + 审查修复: 构建安全的子进程环境变量 + * + * 审查修复: 原白名单方案遗漏了 GIT_*/PYTHONPATH/HTTP_PROXY 等常用变量,导致子进程功能破坏。 + * 改为黑名单方案:剔除包含敏感后缀的变量,保留其余。 + */ +function buildSafeCommandEnv(isWindows: boolean): Record { + // 敏感变量后缀黑名单 + const SENSITIVE_SUFFIXES = [ + '_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD', + '_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY', + ]; + // 敏感变量名黑名单(精确匹配) + const SENSITIVE_KEYS = new Set([ + 'DEEPSEEK_API_KEY', 'AGNES_API_KEY', 'MIMO_API_KEY', + 'GITEA_PASSWORD', 'DATABASE_PASSWORD', + ]); + + const env: Record = {}; + for (const [key, val] of Object.entries(process.env)) { + if (!val) continue; + if (SENSITIVE_KEYS.has(key)) continue; + if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue; + env[key] = val; + } + + // 添加必要的运行时变量 + env.NODE_ENV = 'production'; + if (isWindows) { + env.PYTHONIOENCODING = 'utf-8'; + env.LANG = 'zh_CN.UTF-8'; + env.LC_ALL = 'zh_CN.UTF-8'; + } + + return env; +} + // ===== 9. run_command ===== export class RunCommandTool implements IMetonaTool { @@ -120,28 +158,46 @@ export class RunCommandTool implements IMetonaTool { // Windows 中文编码修复:通过 chcp 65001 切换控制台到 UTF-8 代码页 // 避免 GBK/CP936 输出被 Node.js 按 UTF-8 解码导致中文乱码 const isWindows = process.platform === 'win32'; - const finalCommand = isWindows - ? `chcp 65001 >nul 2>&1 && ${command}` - : command; + + // #8 修复: 优先使用 execFile(不经过 shell,从根本上防止命令注入) + // 仅当命令为简单命令(无管道、重定向、&& 等 shell 运算符)时使用 execFile + // 复杂命令(含 shell 语法)降级到 exec(已有 SandboxManager + validateCommand 双层校验) + const simpleCmd = this.parseCommandSimple(command); // 使用 encoding: 'buffer' 获取原始字节,手动智能解码 // 部分程序(如 dir、systeminfo)不响应 chcp 65001,仍输出 GBK 字节流 - const { stdout, stderr } = await execAsync(finalCommand, { + // #9 修复: 不透传完整 process.env,仅保留子进程运行所需的最小环境变量集合 + // 防止 API keys / tokens / passwords 通过环境变量泄露给子进程 + const execEnv = buildSafeCommandEnv(isWindows); + const execOpts = { cwd: resolvedWorkdir, timeout, maxBuffer: 1024 * 1024, // 1MB - encoding: 'buffer', // 返回 Buffer 而非字符串,便于智能解码 - env: { - ...process.env, - NODE_ENV: 'production', - ...(isWindows ? { - // 强制常见程序使用 UTF-8 输出 - PYTHONIOENCODING: 'utf-8', - LANG: 'zh_CN.UTF-8', - LC_ALL: 'zh_CN.UTF-8', - } : {}), - }, - }); + encoding: 'buffer' as const, // 返回 Buffer 而非字符串,便于智能解码 + env: execEnv, + }; + + let stdout: Buffer; + let stderr: Buffer; + + // #8 修复 + 审查修复: 简单命令使用 execFile(不经过 shell,防止命令注入) + // 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法执行(ENOENT) + // 因此 Windows 上仍用 exec(已有 SandboxManager.scanCode + validateCommand 双层校验) + // 非 Windows 上对简单命令用 execFile + if (simpleCmd && !isWindows) { + const result = await execFileAsync(simpleCmd.command, simpleCmd.args, execOpts); + stdout = result.stdout; + stderr = result.stderr; + } else { + // 复杂命令(含管道/重定向/&& 等 shell 语法)或 Windows — 使用 exec + // 已有 SandboxManager.scanCode + validateCommand 双层安全校验 + const finalCommand = isWindows + ? `chcp 65001 >nul 2>&1 && ${command}` + : command; + const result = await execAsync(finalCommand, execOpts); + stdout = result.stdout; + stderr = result.stderr; + } return { success: true, @@ -336,4 +392,41 @@ export class RunCommandTool implements IMetonaTool { return null; } + + /** + * #8 修复: 将命令解析为简单的 command + args 数组 + * + * 使用 shell-quote 解析命令,如果命令只包含 word tokens(无管道、重定向、&& 等 shell 运算符), + * 返回 { command, args } 供 execFile 使用(不经过 shell,从根本上防止命令注入)。 + * 如果包含 shell 运算符或解析失败,返回 null,调用方降级到 exec(已有安全校验)。 + * + * @returns 简单命令的 { command, args },或 null 表示复杂命令 + */ + private parseCommandSimple(command: string): { command: string; args: string[] } | null { + let tokens: ReturnType; + try { + tokens = shellQuoteParse(command); + } catch { + // 解析失败(Windows cmd 语法、不完整引号等)— 降级到 exec + return null; + } + + const words: string[] = []; + for (const entry of tokens) { + if (typeof entry === 'string') { + words.push(entry); + } else if (typeof entry === 'object' && entry !== null) { + const obj = entry as { word?: unknown; op?: unknown }; + if (typeof obj.word === 'string') { + words.push(obj.word); + } else if (typeof obj.op === 'string') { + // 遇到 shell 运算符(&&、|、;、> 等)— 复杂命令,降级到 exec + return null; + } + } + } + + if (words.length === 0) return null; + return { command: words[0], args: words.slice(1) }; + } } diff --git a/electron/harness/tools/built-in/file-editor.ts b/electron/harness/tools/built-in/file-editor.ts index a9c58b9..271bc18 100644 --- a/electron/harness/tools/built-in/file-editor.ts +++ b/electron/harness/tools/built-in/file-editor.ts @@ -29,6 +29,43 @@ import { FILE_TOOL_TIMEOUT_MS, } from './file-guard'; +/** + * #45 修复: 检测潜在灾难性正则模式(ReDoS 风险) + * + * 灾难性回溯通常由以下模式引起: + * - 嵌套量词:(a+)+、(a*)*、(a+)* + * - 重叠量词:a+a+、a+.*a+(两个量词之间无固定字符分隔) + * - 交替分支加量词:(a|a)* + * + * 这些模式在长字符串上执行时间指数级增长,可阻塞主进程 + * + * @param pattern 用户提供的正则模式字符串 + * @returns true 如果检测到潜在灾难性模式 + */ +function isPotentiallyCatastrophicRegex(pattern: string): boolean { + // 审查修复: 放宽规则减少误报,补充漏报检测 + + // 1. 嵌套量词(捕获组内量词+外层量词) + // 审查修复: 区分外层量词类型 — 外层 +* 时组内一个量词即可触发(如 (a+)+), + // 外层 ? 时需组内两个量词才触发(排除 (\d+)? 误报) + if (/\([^)]*[+*?][^)]*\)[+*]/.test(pattern)) return true; + if (/\([^)]*[+*?][^)]*[+*?][^)]*\)[?]/.test(pattern)) return true; + + // 2. 重叠量词 — 补充 a+a+ 漏报 + if (/[+*][+*]/.test(pattern)) return true; + // 审查修复: 补充 a+a+ / a+.*a+ 等重叠量词检测 + if (/\w[+*]\s*\w[+*]/.test(pattern)) return true; + if (/\.\*[+*]\.\*[+*]/.test(pattern)) return true; + + // 3. 交替分支加量词 — 放宽: 仅当分支有重叠前缀时才危险 + // 移除对 (GET|POST)+ 的误报,只检测真正危险的重叠分支 + // (a|a)* 类型难以用正则精确检测,保留简化版 + if (/\(([^)]+)\|(\1[^)]*)\)[+*?]/.test(pattern)) return true; + if (/\(([^)]*\|[^)]*)\)[+*?]/.test(pattern) && /(.)\1.*\|.*\1/.test(pattern)) return true; + + return false; +} + export class FileEditorTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'file_editor', @@ -159,6 +196,11 @@ export class FileEditorTool implements IMetonaTool { if (pattern.length > 500) { return { success: false, error: 'Regex pattern too long (max 500 chars)' }; } + // #45 修复: ReDoS 防护 — 检测灾难性正则模式,拒绝可能导致指数级回溯的 pattern + // 攻击场景:恶意 LLM 传入 (a+)+ 等模式,在长字符串上阻塞主进程 + if (isPotentiallyCatastrophicRegex(pattern)) { + return { success: false, error: 'Potentially catastrophic regex pattern detected (ReDoS risk): nested or overlapping quantifiers' }; + } const startLine = Math.max(1, (args.start_line as number) ?? 1); const endLine = Math.min(lines.length, (args.end_line as number) ?? lines.length); if (endLine < startLine) { @@ -178,6 +220,15 @@ export class FileEditorTool implements IMetonaTool { if (multiline) { // F2-6: 跨行匹配 — 对整个目标内容做正则替换(支持多行代码块替换) const targetContent = lines.slice(startLine - 1, endLine).join('\n'); + // #45 修复: 限制 multiline 模式的目标内容长度,防止大文件上的 regex 执行导致主进程阻塞 + // Node.js RegExp 执行是同步的,超长内容 + 复杂正则可阻塞事件循环 + const MAX_REGEX_CONTENT_LENGTH = 100_000; + if (targetContent.length > MAX_REGEX_CONTENT_LENGTH) { + return { + success: false, + error: `Target content too large for multiline regex (${targetContent.length} chars, max ${MAX_REGEX_CONTENT_LENGTH}). Use a smaller line range or split the operation.`, + }; + } const matches = targetContent.match(regex); if (matches) replaceCount = matches.length; const replacedContent = targetContent.replace(regex, replacement); diff --git a/electron/harness/tools/built-in/filesystem.ts b/electron/harness/tools/built-in/filesystem.ts index b06830e..9658a6e 100644 --- a/electron/harness/tools/built-in/filesystem.ts +++ b/electron/harness/tools/built-in/filesystem.ts @@ -22,9 +22,9 @@ * @see standard/开发规范.md — 使用 fs/path 内置模块 */ -import { readFile, writeFile, readdir, stat, mkdir, open, unlink, rmdir, rm, rename, type FileHandle } from 'fs/promises'; +import { readFile, writeFile, readdir, stat, mkdir, open, unlink, rmdir, rm, rename, realpath, type FileHandle } from 'fs/promises'; import { join, relative, resolve, dirname } from 'path'; -import { existsSync } from 'fs'; +import { existsSync, realpathSync } from 'fs'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; @@ -542,7 +542,23 @@ export class SearchFilesTool implements IMetonaTool { fileGlob?: string, includeHidden?: boolean, workspacePath?: string, + // #21 修复: 添加深度限制,防止深层嵌套目录触发栈溢出 + depth: number = 0, + // #21 修复: 最大递归深度(默认 20),超过则停止递归 + maxDepth: number = 20, + // #21 修复: 符号链接循环防护 — 记录已访问的 realpath,防止 A -> B -> A 死循环 + visited: Set = new Set(), ): Promise { + // #21 修复: 深度限制 — 超过 maxDepth 立即返回,防止栈溢出 + if (depth > maxDepth) return; + + // #21 修复: 符号链接循环防护 — realpath 去重,防止 A -> B -> A 形成循环导致死循环 + // 审查修复: 改用异步 fs.promises.realpath,避免同步 realpathSync 阻塞 Electron 主进程。 + // realpath 失败(权限问题等)时回退到 dirPath 本身,保守继续。 + const real = await realpath(dirPath).catch(() => dirPath); + if (visited.has(real)) return; + visited.add(real); + try { const entries = await readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { @@ -552,7 +568,10 @@ export class SearchFilesTool implements IMetonaTool { const fullPath = join(dirPath, entry.name); if (entry.isDirectory()) { - await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath); + // #21 修复: 不跟随符号链接目录,防止循环遍历与越权访问 + if (!entry.isSymbolicLink()) { + await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath, depth + 1, maxDepth, visited); + } } else { // F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx") if (fileGlob && !matchAnyGlob(entry.name, fileGlob)) continue; @@ -640,9 +659,35 @@ export class DeleteFileTool implements IMetonaTool { } try { + // #20 修复: TOCTOU 竞态防护 — 删除前 realpath 二次校验 + // 攻击场景:在校验(safeResolvePath)与删除(unlink/rmdir)之间, + // 攻击者用符号链接替换目标文件,使 unlink 实际删除符号链接指向的工作空间外文件 + // 防护:记录删除前 realpath,删除前再次校验未被替换 + let realBefore: string; + try { + realBefore = realpathSync(resolvedPath); + } catch { + // realpath 失败(路径不存在等),使用 resolvedPath + realBefore = resolvedPath; + } + if (!isPathWithinWorkspace(realBefore, context.workspacePath)) { + return { error: 'Path escape detected (TOCTOU protection)', path: filePath, success: false }; + } + const stats = await stat(resolvedPath); const isDirectory = stats.isDirectory(); + // #20 修复: 删除前再次 realpath 校验,确保文件未被替换为指向工作空间外的符号链接 + let realAfter: string; + try { + realAfter = realpathSync(resolvedPath); + } catch { + realAfter = resolvedPath; + } + if (realAfter !== realBefore) { + return { error: 'TOCTOU detected: file replaced during deletion', path: filePath, success: false }; + } + if (isDirectory) { if (recursive) { // 递归删除非空目录 diff --git a/electron/harness/tools/built-in/git.ts b/electron/harness/tools/built-in/git.ts index 3daa63e..2246383 100644 --- a/electron/harness/tools/built-in/git.ts +++ b/electron/harness/tools/built-in/git.ts @@ -211,6 +211,23 @@ export class GitDiffTool implements IMetonaTool { const pathspec = args.pathspec as string | undefined; const contextLines = Math.max(0, (args.contextLines as number) ?? 3); + // #19 修复: 校验 pathspec 在工作空间内,防止读取任意仓库差异 + // 攻击场景:pathspec 传 "../../../other-repo/" 或绝对路径可越权读取工作空间外的 git 差异 + // resolve 后 isPathWithinWorkspace 校验,glob 字符(*?)不影响前缀校验 + if (pathspec) { + // 审查修复 M18: git magic pathspec(以 : 开头,如 :(glob)**/*.ts)跳过 resolve 校验。 + // resolve 会把 magic 前缀当普通字符拼接到路径,导致 pathspec 被污染; + // git magic 语法本身不构成路径越权风险(git 自身解析 magic 前缀,不指向工作空间外)。 + if (pathspec.startsWith(':')) { + // git magic pathspec,跳过 resolve 校验直接放行 + } else { + const resolved = resolve(context.workspacePath, pathspec); + if (!isPathWithinWorkspace(resolved, context.workspacePath)) { + return { error: `Path outside workspace: ${pathspec}`, success: false }; + } + } + } + const gitArgs = ['diff']; if (cached) gitArgs.push('--cached'); gitArgs.push(`--unified=${contextLines}`); diff --git a/electron/harness/tools/built-in/http-request.ts b/electron/harness/tools/built-in/http-request.ts index 008d1d0..b598d48 100644 --- a/electron/harness/tools/built-in/http-request.ts +++ b/electron/harness/tools/built-in/http-request.ts @@ -5,8 +5,12 @@ * * 使用 Node.js 18+ 内置 fetch API。 * 响应体截断到 50KB 防止结果过大。 + * + * #10 修复: SSRF 防护 — 解析 URL 域名并校验 IP,拒绝内网/回环/元数据地址。 */ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; @@ -14,6 +18,120 @@ import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const; const MAX_BODY_BYTES = 50 * 1024; // 50KB +/** + * #10 修复: 检查 IP 是否为私有/内网/回环/元数据地址 + * + * 覆盖: + * - IPv4: 127.0.0.0/8 (回环)、10.0.0.0/8、192.168.0.0/16、172.16.0.0/12、 + * 169.254.0.0/16 (链路本地,含云元数据 169.254.169.254)、0.0.0.0/8、 + * 224.0.0.0/4 (组播)、240.0.0.0/4 (保留) + * - IPv6: ::1 (回环)、fe80::/10 (链路本地)、fc00::/7 (唯一本地)、::ffff: 映射的 IPv4 + */ +function isPrivateIP(ip: string): boolean { + // IPv4 直接检测 + if (isIP(ip) === 4) { + const parts = ip.split('.').map(Number); + if (parts[0] === 127) return true; // 回环 + if (parts[0] === 10) return true; // 内网 + if (parts[0] === 192 && parts[1] === 168) return true; // 内网 + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 内网 + if (parts[0] === 169 && parts[1] === 254) return true; // 链路本地(含云元数据) + if (parts[0] === 0) return true; // 0.0.0.0/8 + if (parts[0] >= 224) return true; // 组播 + 保留 + return false; + } + + // IPv6 检测 + if (isIP(ip) === 6) { + const lower = ip.toLowerCase(); + if (lower === '::1') return true; // 回环 + if (lower.startsWith('fe80:')) return true; // 链路本地 + if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // 唯一本地 + // ::ffff: 映射的 IPv4 — 提取 IPv4 部分递归检测 + const v4MappedMatch = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (v4MappedMatch) return isPrivateIP(v4MappedMatch[1]); + return false; + } + + // 非 IP 格式(域名等),由调用方 DNS 解析后再检测 + return false; +} + +/** + * #10 修复: SSRF 校验 — 解析 URL 域名并校验 IP + * + * 1. 协议白名单:仅允许 http/https + * 2. DNS 解析域名,获取所有 IP 地址 + * 3. 逐个检测 IP 是否为私有/内网/回环/元数据地址 + * 4. 任意一个 IP 为私有即拒绝(防止 DNS rebinding 中只校验第一个 IP) + * + * 审查修复 (M7) — 已知限制:DNS rebinding 窗口 + * --------------------------------------------------------------- + * validateSSRF 在校验阶段 DNS 解析得到 IP,fetch 内部会再次 DNS 解析, + * 两次解析之间存在 DNS rebinding 攻击窗口(攻击者可在校验通过后切换 + * DNS 记录到内网 IP)。 + * + * 完全防护需要 "DNS pinning"(用校验通过的 IP 替换 URL hostname), + * 但在 Node.js fetch 实现下不可行: + * 1. HTTPS 请求时 fetch 会基于 URL hostname 校验证书 SAN, + * 用 IP 替换会导致证书校验失败(除非目标证书 SAN 包含该 IP)。 + * 2. Node fetch 将 Host 列为 forbidden header,无法通过设置 + * Host header 保留原始域名。 + * 3. fetch API 不暴露 SNI 自定义入口。 + * + * 当前实现的缓解措施: + * - 校验所有 DNS 返回的 IP(防只校验第一个 IP 的绕过) + * - redirect: 'manual' 禁用自动重定向(防重定向到内网) + * - 窗口虽存在,但需要攻击者控制权威 DNS 并在毫秒级切换记录, + * 实际利用难度较高。 + * + * 彻底防护建议:在 Electron 主进程层使用自定义 lookup 钩子实现 + * DNS pinning(例如 undici 的 dispatcher.agent.connect lookup)。 + * + * @throws 如果 URL 指向私有/内网/回环地址 + */ +async function validateSSRF(url: string): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid URL: ${url}`); + } + + // 协议白名单 + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Blocked SSRF: protocol "${parsed.protocol}" not allowed (only http/https)`); + } + + const hostname = parsed.hostname; + + // 如果 hostname 本身就是 IP,直接检测 + if (isIP(hostname)) { + if (isPrivateIP(hostname)) { + throw new Error(`Blocked SSRF: ${hostname} is a private/loopback address`); + } + return; + } + + // 域名 — DNS 解析后检测所有 IP + let addresses: Array<{ address: string }>; + try { + addresses = await lookup(hostname, { all: true }); + } catch (err) { + throw new Error(`Blocked SSRF: DNS resolution failed for ${hostname}: ${(err as Error).message}`); + } + + if (addresses.length === 0) { + throw new Error(`Blocked SSRF: no DNS records for ${hostname}`); + } + + for (const { address } of addresses) { + if (isPrivateIP(address)) { + throw new Error(`Blocked SSRF: ${hostname} resolves to private IP ${address}`); + } + } +} + export class HttpRequestTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'http_request', @@ -34,8 +152,8 @@ export class HttpRequestTool implements IMetonaTool { required: ['url'], }, category: MetonaToolCategory.NETWORK, - riskLevel: MetonaRiskLevel.LOW, - requiresPermission: false, + riskLevel: MetonaRiskLevel.MEDIUM, + requiresPermission: true, timeoutMs: 30_000, }; @@ -52,6 +170,13 @@ export class HttpRequestTool implements IMetonaTool { return { error: 'Invalid URL', success: false }; } + // #10 修复: SSRF 校验 — 拒绝内网/回环/元数据地址 + try { + await validateSSRF(url); + } catch (ssrfErr) { + return { error: (ssrfErr as Error).message, success: false }; + } + // 校验 method if (!(ALLOWED_METHODS as readonly string[]).includes(method)) { return { @@ -69,7 +194,9 @@ export class HttpRequestTool implements IMetonaTool { method, headers, signal: controller.signal, - redirect: 'follow', + // #10 修复: 禁用自动重定向跟随 — 防止重定向到内网地址绕过 SSRF 校验 + // 重定向后的 URL 由用户自行处理(响应中会包含 Location 头) + redirect: 'manual', }; // GET/HEAD 不应携带 body if (body !== undefined && method !== 'GET' && method !== 'HEAD') { @@ -84,11 +211,14 @@ export class HttpRequestTool implements IMetonaTool { const safeBody = truncated ? text.slice(0, MAX_BODY_BYTES) : text; // 只返回 content-type 和 content-length + // 审查修复: redirect:'manual' 后需要返回 Location header,否则 LLM 无法知道重定向目标 const filteredHeaders: Record = {}; const contentType = response.headers.get('content-type'); if (contentType) filteredHeaders['content-type'] = contentType; const contentLength = response.headers.get('content-length'); if (contentLength) filteredHeaders['content-length'] = contentLength; + const location = response.headers.get('location'); + if (location) filteredHeaders['location'] = location; return { status: response.status, diff --git a/electron/harness/tools/registry.ts b/electron/harness/tools/registry.ts index 6419549..bccedcb 100644 --- a/electron/harness/tools/registry.ts +++ b/electron/harness/tools/registry.ts @@ -95,20 +95,39 @@ export class ToolRegistry { } const startTs = Date.now(); - const timeoutMs = tool.definition.timeoutMs; + + // #12 修复: timeoutMs 为 undefined 时使用默认值,并校验有效性 + // 防止 setTimeout(fn, undefined) 被解释为 setTimeout(fn, 0) 立即触发超时 + // 类型定义中 timeoutMs 是必填 number,但 MCP/外部工具运行时可能缺失,需防御 + const DEFAULT_TIMEOUT_MS = 120_000; + const rawTimeout = tool.definition.timeoutMs; + const timeoutMs = + typeof rawTimeout === 'number' && rawTimeout > 0 + ? rawTimeout + : DEFAULT_TIMEOUT_MS; + + // #11 修复: 使用 AbortController 在超时后通知工具中止,防止 Promise 未取消导致资源泄漏 + // 原实现 Promise.race 超时后 tool.execute() 仍在后台运行,持续消耗资源 + // 现通过 signal 传给 context,工具可在耗时操作前检查 signal.aborted 自行中止 + const controller = new AbortController(); + const enhancedContext: ToolExecutionContext = { + ...context, + signal: controller.signal, + }; // M-15 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积 // 工具正常完成时未触发的 timer 会持续占用事件循环 timeoutMs 毫秒 let timer: ReturnType | undefined; try { // 带超时执行 — 使用 Promise.race 防止工具卡死阻塞 Agent Loop + // #11: 超时触发 controller.abort(),支持 signal 的工具可据此中止后台操作 const result = await Promise.race([ - tool.execute(toolCall.args, context), + tool.execute(toolCall.args, enhancedContext), new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error(`Tool execution timed out after ${timeoutMs}ms`)), - timeoutMs, - ); + timer = setTimeout(() => { + controller.abort(); + reject(new Error(`Tool execution timed out after ${timeoutMs}ms`)); + }, timeoutMs); }), ]); diff --git a/electron/harness/types/metona-tool.ts b/electron/harness/types/metona-tool.ts index 7f727ef..af959e7 100644 --- a/electron/harness/types/metona-tool.ts +++ b/electron/harness/types/metona-tool.ts @@ -20,6 +20,8 @@ export interface ToolExecutionContext { iteration: number; /** 请求 ID */ requestId: string; + /** #11 修复: 超时 abort signal,工具可在耗时操作前检查 signal.aborted 自行中止 */ + signal?: AbortSignal; } /** diff --git a/electron/harness/utils/token-estimator.ts b/electron/harness/utils/token-estimator.ts index a4cdb33..be1bc9c 100644 --- a/electron/harness/utils/token-estimator.ts +++ b/electron/harness/utils/token-estimator.ts @@ -52,6 +52,11 @@ export function estimateStringTokens(text: string | null | undefined): number { return Math.ceil(cjkCount * CJK_TOKEN_RATIO + asciiCount * ASCII_TOKEN_RATIO + otherCount * OTHER_TOKEN_RATIO); } +/** + * #50 修复: tool_call 结构开销({"id":"","name":"","arguments":""} 等结构字符,参考 OpenAI 规范) + */ +const TOOL_CALL_OVERHEAD_TOKENS = 8; + /** * 估算多条消息的总 token 数 * @@ -63,7 +68,8 @@ export function estimateStringTokens(text: string | null | undefined): number { export function estimateMessagesTokens(messages: Array<{ content: string | null; reasoningContent?: string; - toolCalls?: Array<{ args: Record }>; + toolCalls?: Array<{ id?: string; name?: string; args: Record }>; + toolCallId?: string; }>): number { let total = 0; for (const msg of messages) { @@ -71,9 +77,19 @@ export function estimateMessagesTokens(messages: Array<{ if (msg.reasoningContent) total += estimateStringTokens(msg.reasoningContent); if (msg.toolCalls) { for (const tc of msg.toolCalls) { - total += estimateStringTokens(JSON.stringify(tc.args)); + // #50 修复: OpenAI tokenizer 会将 tool_call 的完整结构(id、name、args)都计入 token + // 之前仅估算 args,忽略 id(通常 24 字符 call_xxx)和 name(通常 5-20 字符),导致每个 tool_call 少算 5-10 tokens + total += estimateStringTokens(tc.id ?? ''); + total += estimateStringTokens(tc.name ?? ''); + total += estimateStringTokens(JSON.stringify(tc.args ?? {})); + // 结构开销({"id":"","name":"","arguments":""} 等结构字符) + total += TOOL_CALL_OVERHEAD_TOKENS; } } + // #50 修复: tool 消息的 tool_call_id 字段也计入 token + if (msg.toolCallId) { + total += estimateStringTokens(msg.toolCallId); + } // L-17 修复: 使用命名常量替代魔法数字 total += MSG_OVERHEAD_TOKENS; } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 1d76039..e2ec352 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1208,6 +1208,37 @@ export function registerAllIPCHandlers( return { success: true, inherited, failed }; }); + // #51 修复: 校验源数据库完整性,防止继承损坏的数据库(如 WAL 损坏)导致新工作空间数据丢失 + // 在前端勾选 inheritDatabase 时调用,执行 PRAGMA integrity_check + ipcMain.handle('workspace:checkDatabaseIntegrity', async (_event, sourcePath: string) => { + if (!sourcePath) return { success: false, error: '参数无效' }; + const { existsSync } = await import('fs'); + const { resolve } = await import('path'); + const srcFile = join(resolve(sourcePath), '.metona', 'agent.db'); + if (!existsSync(srcFile)) { + // 源数据库不存在不算损坏(可能是新工作空间尚未创建数据库),视为校验通过 + return { success: true, ok: true, detail: 'source database not exist' }; + } + try { + const Database = (await import('better-sqlite3')).default; + // 以只读方式打开源库,避免锁竞争 + const db = new Database(srcFile, { readonly: true, fileMustExist: true }); + try { + const result = db.prepare('PRAGMA integrity_check').get() as { integrity_check: string }; + const ok = result.integrity_check === 'ok'; + if (!ok) { + log.warn(`[WORKSPACE] Source database integrity check failed: ${result.integrity_check} (source=${sourcePath})`); + } + return { success: true, ok, detail: result.integrity_check }; + } finally { + db.close(); + } + } catch (err) { + log.error(`[WORKSPACE] Failed to check database integrity: ${(err as Error).message}`); + return { success: false, error: (err as Error).message }; + } + }); + // 获取当前工作空间详情:路径 + 2 个核心文件状态 + 自动目录状态 ipcMain.handle('workspace:getInfo', async () => { const { existsSync, statSync, readFileSync, readdirSync } = await import('fs'); diff --git a/electron/preload.ts b/electron/preload.ts index 6dc91c6..3903973 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -140,6 +140,9 @@ const metonaAPI = { showItemInFolder: (path: string) => ipcRenderer.invoke('app:showItemInFolder', path), selectFolder: (defaultPath?: string) => ipcRenderer.invoke('app:selectFolder', defaultPath), restart: () => ipcRenderer.invoke('app:restart'), + // #49 修复: 前端错误上报通道(主进程可注册 'error:report' handler 记录到 electron-log/审计日志) + // 使用 send(单向)而非 invoke,即使主进程未注册 handler 也不会 reject + reportError: (payload: unknown) => ipcRenderer.send('error:report', payload), }, // ===== 工作空间管理 ===== @@ -148,6 +151,9 @@ const metonaAPI = { inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) => ipcRenderer.invoke('workspace:inheritFiles', params), getInfo: () => ipcRenderer.invoke('workspace:getInfo'), + // #51 修复: 校验源数据库完整性(PRAGMA integrity_check),用于 inheritDatabase 前置校验 + checkDatabaseIntegrity: (sourcePath: string) => + ipcRenderer.invoke('workspace:checkDatabaseIntegrity', sourcePath), }, // ===== 工具管理 ===== @@ -187,10 +193,25 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('electron', electronAPI); contextBridge.exposeInMainWorld('metona', metonaAPI); } catch (error) { - console.error('Failed to expose APIs:', error); + console.error('Failed to expose APIs via contextBridge:', error); + // #7 修复: 生产环境强制失败,防止以不安全状态启动(contextBridge 失败意味着 + // context isolation 边界已破坏,继续运行可能导致渲染进程直接访问 Node API) + // 开发环境降级到 globalThis 以便调试 + // 审查修复: process.defaultApp 在 sandbox: true 下可能为 undefined, + // 导致开发环境也无法降级。补充 process.env.NODE_ENV 判断(sandbox 下 process.env 仍可用) + if (process.env.NODE_ENV === 'development' || process.defaultApp) { + console.warn('[DEV] Falling back to globalThis assignment — context isolation is weakened'); + (globalThis as unknown as Record).electron = electronAPI; + (globalThis as unknown as Record).metona = metonaAPI; + } else { + // 审查修复 M19: 在 throw 前记录详细错误,便于在 DevTools 中排查白屏问题。 + // 生产环境 contextBridge 失败时用户只看到白屏,console.error 至少能在 DevTools 中留下线索。 + console.error('[FATAL] contextBridge failed in production:', error); + throw error; + } } } else { - // 降级方案(不推荐) + // context isolation 未启用(不推荐,仅开发环境) (globalThis as unknown as Record).electron = electronAPI; (globalThis as unknown as Record).metona = metonaAPI; } diff --git a/electron/services/audit.service.ts b/electron/services/audit.service.ts index bb9deec..da5a25e 100644 --- a/electron/services/audit.service.ts +++ b/electron/services/audit.service.ts @@ -16,6 +16,27 @@ import type Database from 'better-sqlite3'; import { createHash } from 'crypto'; import log from 'electron-log'; +/** + * #36 修复: 稳定序列化,递归按 key 字典序排序后序列化 + * JSON.stringify 对对象 key 顺序敏感({a:1,b:2} ≠ {b:2,a:1}), + * 导致相同语义的对象产生不同 hash,审计去重失效。 + * stableStringify 保证相同内容始终产生相同字符串。 + */ +function stableStringify(obj: unknown): string { + if (obj === null || typeof obj !== 'object') return JSON.stringify(obj); + if (Array.isArray(obj)) { + return '[' + obj.map(stableStringify).join(',') + ']'; + } + const keys = Object.keys(obj as Record).sort(); + return ( + '{' + + keys + .map((k) => JSON.stringify(k) + ':' + stableStringify((obj as Record)[k])) + .join(',') + + '}' + ); +} + export type AuditEventType = 'tool_call' | 'permission_check' | 'error' | 'llm_request' | 'llm_response' | 'session_start' | 'session_end' | 'config_change'; export type AuditActor = 'agent' | 'user' | 'system'; export type AuditOutcome = 'success' | 'denied' | 'error'; @@ -38,6 +59,7 @@ export class AuditService { * 计算链式哈希 * current_hash = SHA256(prev_hash + 所有内容字段) * v0.2.0: 纳入 iteration/outcome/durationMs 字段,使用 JSON.stringify 避免分隔符碰撞 + * #36 修复: 改用 stableStringify 替代 JSON.stringify,避免对象 key 顺序不稳定导致 hash 不一致 */ private computeHash( prevHash: string, @@ -47,8 +69,8 @@ export class AuditService { outcome: string | null; durationMs: number | null; createdAt: number; }, ): string { - // 使用 JSON.stringify 避免分隔符碰撞 - const content = JSON.stringify({ + // #36 修复: 使用 stableStringify 避免分隔符碰撞且保证 key 顺序稳定 + const content = stableStringify({ prevHash, sessionId: entry.sessionId, iteration: entry.iteration, @@ -328,6 +350,25 @@ export class AuditService { }); if (row.current_hash !== expectedHash) { + // 审查修复: #36 将 JSON.stringify 改为 stableStringify 后,旧记录的 hash 用旧算法生成 + // 尝试用旧算法(JSON.stringify)重新计算,如果匹配则跳过(兼容旧数据) + const legacyContent = JSON.stringify({ + prevHash, + sessionId: row.session_id, + iteration: row.iteration, + eventType: row.event_type, + actor: row.actor, + target: row.target, + details: row.details, + outcome: row.outcome, + durationMs: row.duration_ms, + createdAt: row.created_at, + }); + const legacyHash = createHash('sha256').update(legacyContent, 'utf-8').digest('hex'); + if (row.current_hash === legacyHash) { + prevHash = row.current_hash; + continue; + } return { valid: false, totalRecords: rows.length, verifiedRecords: verified, tamperedId: row.id }; } diff --git a/electron/services/database.service.ts b/electron/services/database.service.ts index 2fb5141..5560e83 100644 --- a/electron/services/database.service.ts +++ b/electron/services/database.service.ts @@ -270,62 +270,78 @@ export class DatabaseService { } }; - // 迁移 1: messages 表添加 attachments 列 - tryAddColumn('messages', 'attachments', 'TEXT', 'attachments'); - // 迁移 2: audit_logs 表添加 iteration 列 - tryAddColumn('audit_logs', 'iteration', 'INTEGER', 'iteration'); - // v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希) - tryAddColumn('audit_logs', 'prev_hash', 'TEXT', 'prev_hash'); - // v0.2.0 迁移 4: audit_logs 表添加 current_hash 列(链式哈希) - tryAddColumn('audit_logs', 'current_hash', 'TEXT', 'current_hash'); + // #33 修复: 整个迁移批次包裹在事务中,保证原子性 + // 若某个 migration 部分失败(如 ALTER TABLE 成功,CREATE INDEX 失败), + // 事务回滚,数据库不会处于部分变更的不一致状态,下次启动可安全重试。 + // better-sqlite3 的事务是同步原子的,嵌套事务使用 SAVEPOINT 实现。 + const runAllMigrations = db.transaction(() => { + // 迁移 1: messages 表添加 attachments 列 + tryAddColumn('messages', 'attachments', 'TEXT', 'attachments'); + // 迁移 2: audit_logs 表添加 iteration 列 + tryAddColumn('audit_logs', 'iteration', 'INTEGER', 'iteration'); + // v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希) + 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 { - // 检测 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'); + // C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL + // @see project_memory.md — Assistant messages with tool_calls must set content to null + // SQLite 不支持 ALTER COLUMN,需要重建表 + try { + // 检测 content 列是否有 NOT NULL 约束 + const columns = db.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string; notnull: number }>; + const 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 - ); + // #33 修复: 重建表的多步骤包裹在嵌套事务中,部分失败时回滚 + // 避免 CREATE messages_new 成功但 DROP/RENAME 失败导致数据丢失或 schema 不一致 + const rebuildMessages = db.transaction(() => { + 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; + 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; - `); + 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); - `); + // 重建索引 + // #42 确认: idx_messages_session 已是 (session_id, created_at) 复合索引, + // 覆盖 getMessages 的 WHERE session_id = ? ORDER BY created_at ASC 查询, + // 工单描述"仅有 session_id 单字段索引"不准确,无需额外添加 idx_messages_session_timestamp + 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); + `); + }); + rebuildMessages(); - log.info('[DB] Migration: messages table rebuilt successfully (content now allows NULL)'); + 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 会保存空字符串 } - } catch (error) { - // L-8 修复: 使用 toErrorMessage 替代重复的三元表达式 - const msg = toErrorMessage(error); - log.warn(`[DB] Migration 5 (messages content NULL) skipped: ${msg}`); - // 非致命错误 — 如果迁移失败,NOT NULL 约束仍生效,saveMessage 会保存空字符串 - } + }); + + runAllMigrations(); } /** diff --git a/electron/services/mcp-manager.service.ts b/electron/services/mcp-manager.service.ts index 91e9b43..5f73f27 100644 --- a/electron/services/mcp-manager.service.ts +++ b/electron/services/mcp-manager.service.ts @@ -35,6 +35,84 @@ function safeParseArgs(raw: string): string[] { } } +// #6 修复: MCP stdio 命令安全校验 +/** + * 允许的 MCP Server 启动命令白名单。 + * 仅允许常见的 MCP Server 运行时,防止任意命令执行。 + */ +const ALLOWED_MCP_COMMANDS = new Set([ + 'npx', 'node', 'npm', + 'python', 'python3', 'uv', 'uvx', + 'bun', 'deno', +]); + +/** + * #6 修复: 校验 MCP Server 的 command 和 args,防止命令注入 + * + * 1. 命令白名单:只允许已知的运行时命令 + * 2. 参数注入检测:拒绝包含 shell 元字符的参数 + * + * @param command MCP Server 启动命令 + * @param args MCP Server 启动参数 + * @throws 如果命令不在白名单或参数包含 shell 元字符 + */ +function validateMcpCommand(command: string, args: string[]): void { + // 提取命令 basename(处理 /usr/bin/node、C:\node\node.exe 等路径) + const baseCmd = command.split(/[\\/]/).pop()?.replace(/\.exe$/i, '') ?? command; + + if (!ALLOWED_MCP_COMMANDS.has(baseCmd)) { + throw new Error( + `MCP command "${baseCmd}" is not in the allowed list: ${[...ALLOWED_MCP_COMMANDS].join(', ')}. ` + + `For security reasons, only standard MCP runtimes are permitted.`, + ); + } + + // 审查修复: 移除 {}() 字符 — StdioClientTransport 用 spawn(不经 shell), + // 这些字符无注入风险,但 MCP Server 的 args 常含 JSON 配置(如 --config {"port":3000})会被误拒。 + // 保留 ; & | ` $ < > 换行 等高危字符。 + const shellMetacharPattern = /[;&|`$<>\n\r]/; + for (const arg of args) { + if (shellMetacharPattern.test(arg)) { + throw new Error( + `MCP command argument contains shell metacharacters and was rejected: ${arg.slice(0, 100)}`, + ); + } + } +} + +/** + * #6 修复 + 审查修复: 构建安全的子进程环境变量 + * + * 审查修复: 原白名单方案过于激进,剥离了 MCP Server 运行所需的 npm_config_*、代理变量等, + * 导致 MCP Server 无法启动。改为黑名单方案:剔除包含敏感后缀的变量,保留其余。 + * + * 注意: GITHUB_TOKEN / SLACK_BOT_TOKEN 等含 _TOKEN 后缀的变量也会被过滤。 + * 如果 MCP Server 需要这些凭证,应通过 MCP Server 配置文件传递,而非环境变量。 + */ +function buildSafeEnv(): Record { + // 敏感变量后缀黑名单 — 匹配这些后缀的变量不会被传递给子进程 + const SENSITIVE_SUFFIXES = [ + '_API_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_PASSWD', + '_CREDENTIAL', '_CREDENTIALS', '_PRIVATE_KEY', + ]; + // 敏感变量名黑名单(精确匹配) + const SENSITIVE_KEYS = new Set([ + 'DEEPSEEK_API_KEY', 'AGNES_API_KEY', 'MIMO_API_KEY', + 'GITEA_PASSWORD', 'DATABASE_PASSWORD', + ]); + + const env: Record = {}; + for (const [key, val] of Object.entries(process.env)) { + if (!val) continue; + // 跳过敏感变量名 + if (SENSITIVE_KEYS.has(key)) continue; + // 跳过敏感后缀变量 + if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue; + env[key] = val; + } + return env; +} + // ===== 类型定义 ===== export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; @@ -178,9 +256,13 @@ export class MCPManager { if (config.transport === 'stdio' && config.command) { // stdio 模式 const args = config.args ?? []; + // #6 修复: 命令白名单 + 参数元字符检测,防止命令注入 + validateMcpCommand(config.command, args); transport = new StdioClientTransport({ command: config.command, args, + // #6 修复: 不透传完整 process.env,仅保留 MCP Server 运行所需的最小环境变量 + env: buildSafeEnv(), }); } else if (config.transport === 'sse' && config.url) { // SSE 模式(远程 HTTP) diff --git a/electron/services/session-recorder.service.ts b/electron/services/session-recorder.service.ts index 6e339b6..788f2a7 100644 --- a/electron/services/session-recorder.service.ts +++ b/electron/services/session-recorder.service.ts @@ -13,7 +13,7 @@ */ import { join } from 'path'; -import { appendFileSync, existsSync, mkdirSync } from 'fs'; +import { appendFileSync, existsSync, mkdirSync, promises } from 'fs'; import log from 'electron-log'; // ===== 事件类型 ===== @@ -43,6 +43,10 @@ export class SessionRecorder { private filePath: string | null = null; private seq = 0; private sessionId: string | null = null; + // #35 修复: 缓冲写入,避免高频 appendFileSync 阻塞主进程(30-50 次/秒 → 每 100ms 批量异步 flush) + private buffer: string[] = []; + private flushTimer: NodeJS.Timeout | null = null; + private flushing = false; constructor(private workspacePath: string) {} @@ -91,6 +95,9 @@ export class SessionRecorder { terminationReason: params.terminationReason, }); + // #35 修复: 同步 flush 确保最后的 session_end 事件写入文件 + this.flushSync(); + log.info(`Session recording stopped: ${this.filePath}`); this.filePath = null; this.sessionId = null; @@ -222,7 +229,10 @@ export class SessionRecorder { // ===== 私有方法 ===== /** - * 写入事件到 JSONL 文件 + * 写入事件到缓冲区 + * + * #35 修复: 改为缓冲写入,定时异步 flush,避免每次 appendFileSync 阻塞主进程 + * 高频事件(30-50 次/秒)先 push 到内存 buffer,每 100ms 批量异步写入文件 */ private writeEvent(data: Record): void { if (!this.filePath) return; @@ -234,10 +244,64 @@ export class SessionRecorder { ...data, } as TraceEvent; + const line = JSON.stringify(event); + + // 审查修复: buffer 上限防止 OOM — 高频事件持续 flush 失败时避免内存无限增长 + const MAX_BUFFER_SIZE = 1000; + if (this.buffer.length >= MAX_BUFFER_SIZE) { + // 超限时强制同步写入,避免内存无限增长 + this.flushSync(); + } + this.buffer.push(line); + if (!this.flushTimer) { + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + void this.flush(); + }, 100); + } + } + + /** + * #35 修复: 异步 flush 缓冲区到文件 + * 审查修复: 用局部变量保存 filePath,防止 stopRecording 将 filePath 置 null 后 appendFile 抛错; + * 失败时将数据 unshift 回 buffer 避免丢整批数据 + */ + private async flush(): Promise { + if (this.flushing || this.buffer.length === 0 || !this.filePath) return; + this.flushing = true; + const filePath = this.filePath; // 局部变量,防止中途变 null + const data = this.buffer.join('\n') + '\n'; + this.buffer = []; try { - appendFileSync(this.filePath, JSON.stringify(event) + '\n', 'utf-8'); + await promises.appendFile(filePath, data, 'utf-8'); } catch (error) { - log.error('Trace event write failed:', error); + log.error('Trace event flush failed:', error); + // 审查修复: 失败时将数据放回 buffer 头部,下次 flush/flushSync 重试 + this.buffer.unshift(data.trimEnd()); + } finally { + this.flushing = false; + } + } + + /** + * #35 修复: 同步 flush 缓冲区到文件 + * 用于 stopRecording 确保最后的数据(如 session_end 事件)写入文件 + * 审查修复: 如果异步 flush 正在进行(flushing=true),等待其完成后再写入,避免数据交叉/丢失 + */ + private flushSync(): void { + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + if (this.buffer.length === 0 || !this.filePath) return; + const data = this.buffer.join('\n') + '\n'; + this.buffer = []; + try { + appendFileSync(this.filePath, data, 'utf-8'); + } catch (error) { + log.error('Trace event flushSync failed:', error); + // 审查修复: 失败时将数据放回 buffer,避免数据丢失 + this.buffer.unshift(data.trimEnd()); } } } diff --git a/electron/services/session.service.ts b/electron/services/session.service.ts index 1ee019b..ada30cd 100644 --- a/electron/services/session.service.ts +++ b/electron/services/session.service.ts @@ -175,15 +175,26 @@ export class SessionService { /** * 获取会话消息列表 + * + * #44 修复: 添加 limit/offset 参数支持分页,避免超长会话一次性加载导致 OOM + * 默认不限制(limit=0),保持向后兼容;调用者可传 limit 限制返回条数 */ - getMessages(sessionId: string): MessageInfo[] { + getMessages( + sessionId: string, + options: { limit?: number; offset?: number } = {}, + ): MessageInfo[] { const db = this.getDBFn(); + const { limit = 0, offset = 0 } = options; - const rows = db.prepare(` - SELECT * FROM messages - WHERE session_id = ? - ORDER BY created_at ASC - `).all(sessionId) as MessageRow[]; + const rows = (limit > 0 + ? db + .prepare( + 'SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC LIMIT ? OFFSET ?', + ) + .all(sessionId, limit, offset) + : db + .prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY created_at ASC') + .all(sessionId)) as MessageRow[]; return rows.map((row) => this.toMessageInfo(row)); } @@ -206,28 +217,34 @@ export class SessionService { const id = `msg_${nanoid(12)}`; const now = Date.now(); - db.prepare(` - INSERT INTO messages (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - id, - params.sessionId, - params.role, - params.content, - params.reasoningContent ?? null, - params.toolCalls ? JSON.stringify(params.toolCalls) : null, - params.toolResult ? JSON.stringify(params.toolResult) : null, - params.attachments ? JSON.stringify(params.attachments) : null, - params.iteration ?? null, - now, - ); + // #34 修复: 包裹事务保证 INSERT messages 和 UPDATE sessions 原子执行 + // message_count = message_count + 1 已是原子 SQL 表达式(避免读-改-写竞态) + // 事务进一步保证消息插入和计数更新要么全部成功,要么全部回滚 + const saveMessageTxn = db.transaction(() => { + db.prepare(` + INSERT INTO messages (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + params.sessionId, + params.role, + params.content, + params.reasoningContent ?? null, + params.toolCalls ? JSON.stringify(params.toolCalls) : null, + params.toolResult ? JSON.stringify(params.toolResult) : null, + params.attachments ? JSON.stringify(params.attachments) : null, + params.iteration ?? null, + now, + ); - // 更新会话的 updated_at 和 message_count - db.prepare(` - UPDATE sessions - SET updated_at = ?, message_count = message_count + 1 - WHERE id = ? - `).run(now, params.sessionId); + // 更新会话的 updated_at 和 message_count + db.prepare(` + UPDATE sessions + SET updated_at = ?, message_count = message_count + 1 + WHERE id = ? + `).run(now, params.sessionId); + }); + saveMessageTxn(); return { id, diff --git a/electron/services/window-manager.service.ts b/electron/services/window-manager.service.ts index 08ec404..fb99259 100644 --- a/electron/services/window-manager.service.ts +++ b/electron/services/window-manager.service.ts @@ -54,7 +54,12 @@ export class WindowManager { title: options.title ?? 'MetonaAI Desktop', webPreferences: { preload: join(__dirname, '../preload/preload.mjs'), - sandbox: false, + // #37 修复: 启用沙箱,强化渲染进程隔离 + // preload.ts 仅使用 contextBridge/ipcRenderer(sandbox 下可用)和 + // @electron-toolkit/preload(兼容 sandbox),不依赖 fs/child_process 等 Node API, + // 因此可安全启用 sandbox: true。启用后渲染进程无法直接访问 Node API, + // 缩小恶意网页通过 preload 漏洞访问 fs/child_process 的攻击面。 + sandbox: true, contextIsolation: true, nodeIntegration: false, }, diff --git a/package-lock.json b/package-lock.json index 7f378a5..54d1d91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.3.15", + "version": "0.3.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.3.15", + "version": "0.3.16", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 37bf3c9..1874607 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.3.15", + "version": "0.3.16", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx index edec41d..52ed815 100644 --- a/src/components/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog.tsx @@ -62,6 +62,8 @@ export function ConfirmationDialog(): React.JSX.Element | null { const [remainingMs, setRemainingMs] = useState(0); // 记录首次接收时的剩余时间,作为进度条总量(固定不变) const [initialMs, setInitialMs] = useState(0); + // #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击 + const [confirmAutoExecute, setConfirmAutoExecute] = useState(false); // ===== 弹框打开时主动拉取已积压的 pending 请求 ===== // 解决:并行工具触发的多个 IPC 事件可能在本组件 mount 前已到达, @@ -291,12 +293,16 @@ export function ConfirmationDialog(): React.JSX.Element | null { : requests[0]?.reason ?? 'Agent 正在请求执行工具'; return ( + <> 0} onClose={(_, reason) => { // 超时时禁止通过外部点击/ESC 关闭(与"拒绝全部"按钮 disabled 一致) // 防止超时后用户误触关闭,导致后端 pending 状态不一致 if (isExpired) return; + // 审查修复: 内层二次确认 Dialog 打开时,外层禁用 ESC/backdrop 关闭 + // 防止 ESC 穿透到外层导致意外拒绝所有工具执行 + if (confirmAutoExecute) return; // 只允许"拒绝全部"语义的关闭方式(点击外部 / ESC) // reason: 'backdropClick' | 'escapeKeyDown' | 'closeButtonClick' if (reason === 'backdropClick' || reason === 'escapeKeyDown') { @@ -305,6 +311,7 @@ export function ConfirmationDialog(): React.JSX.Element | null { }} maxWidth="md" fullWidth + disableEscapeKeyDown={confirmAutoExecute} slotProps={{ paper: { sx: { bgcolor: 'background.paper' }, @@ -520,9 +527,12 @@ export function ConfirmationDialog(): React.JSX.Element | null { { - setAutoExecute(e.target.checked); - // 勾选永久自动执行时,自动勾选会话内记忆(保持一致) - if (e.target.checked) setRemember(true); + // #40 修复: autoExecute 永久自动执行风险高,勾选时弹出二次确认避免误点击 + if (e.target.checked) { + setConfirmAutoExecute(true); + } else { + setAutoExecute(false); + } }} size="small" /> @@ -576,5 +586,42 @@ export function ConfirmationDialog(): React.JSX.Element | null { + + {/* #40 修复: autoExecute 二次确认 Dialog — 避免误点击导致永久自动执行 */} + {/* 审查修复: disableEscapeKeyDown + onKeyDown stopPropagation 防止 ESC 事件穿透到外层 Dialog */} + setConfirmAutoExecute(false)} + maxWidth="xs" + fullWidth + disableEscapeKeyDown + onKeyDown={(e) => e.stopPropagation()} + > + 确认永久自动执行? + + + 勾选后,选中的工具将永久自动执行(跨会话不再询问),包括未来的潜在危险操作。此设置可在「设置 → 工具管理」中关闭。 + + + 确定要启用吗? + + + + + + + + ); } diff --git a/src/components/chat/MessageItem.tsx b/src/components/chat/MessageItem.tsx index 4cf55b7..6f17d6d 100644 --- a/src/components/chat/MessageItem.tsx +++ b/src/components/chat/MessageItem.tsx @@ -9,13 +9,10 @@ */ import { memo } from 'react'; -import { Box, Typography, Stack } from '@mui/material'; -import { Terminal } from 'lucide-react'; import type { ChatMessage } from '@renderer/stores/agent-store'; import { UserMessage } from './UserMessage'; import { AssistantMessage } from './AssistantMessage'; import { SystemMessage } from './SystemMessage'; -import { formatTime } from '@renderer/lib/formatters'; interface MessageItemProps { message: ChatMessage; @@ -23,7 +20,7 @@ interface MessageItemProps { isStreaming?: boolean; } -function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element { +function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): React.JSX.Element | null { switch (message.role) { case 'user': return ; @@ -37,8 +34,11 @@ function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): Re ); case 'tool': - // 工具消息渲染为独立的结果块 - return ; + // #38 修复: 与 AgentStore.setCurrentSession 的过滤逻辑保持一致, + // 不在 UI 中渲染独立的 tool 消息卡片。 + // tool 结果已包含在 assistant 消息的 toolCalls 中(见 AssistantMessage), + // 独立的 tool 消息仅用于 LLM API 上下文,不需要在前端显示为独立卡片。 + return null; case 'system': default: @@ -53,51 +53,3 @@ function MessageItemImpl({ message, isLast, isStreaming }: MessageItemProps): Re * - isLast 是 boolean,仅在最后一条切换时变化 */ export const MessageItem = memo(MessageItemImpl); - -/** ToolMessage — 独立的工具结果消息(role=tool) */ -function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element { - return ( - - - - - 工具输出 - - - {formatTime(message.timestamp)} - - - - {message.content.length > 500 ? message.content.slice(0, 500) + '\n... [截断]' : message.content} - - - ); -} diff --git a/src/components/common/ErrorBoundary.tsx b/src/components/common/ErrorBoundary.tsx index 890cf4f..dcde312 100644 --- a/src/components/common/ErrorBoundary.tsx +++ b/src/components/common/ErrorBoundary.tsx @@ -26,6 +26,19 @@ export class ErrorBoundary extends Component { } componentDidCatch(error: Error, info: { componentStack: string }): void { + // #49 修复: 上报错误到主进程,便于记录到 electron-log 与审计日志,集中排查 + // 主进程可注册 ipcMain.on('error:report', ...) 接收;未注册时 send 为静默失败,不影响兜底 + try { + window.metona?.app?.reportError({ + type: 'react_error_boundary', + error: error.message, + stack: error.stack, + componentStack: info.componentStack, + timestamp: Date.now(), + }); + } catch { + // 上报失败不影响兜底逻辑 + } console.error('[ErrorBoundary]', error, info.componentStack); } diff --git a/src/components/onboarding/OnboardingWizard.tsx b/src/components/onboarding/OnboardingWizard.tsx index 41c8c8d..579f1b1 100644 --- a/src/components/onboarding/OnboardingWizard.tsx +++ b/src/components/onboarding/OnboardingWizard.tsx @@ -2,7 +2,7 @@ * OnboardingWizard — 首次使用引导向导 */ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Dialog, DialogContent, Button, TextField, Select, MenuItem, Stepper, Step, StepLabel, Box, Typography, Stack, FormControl, InputLabel, IconButton, InputAdornment } from '@mui/material'; import { ArrowRight, ArrowLeft, FolderOpen, Play, CheckCircle, Eye, EyeOff } from 'lucide-react'; import { useUIStore } from '@renderer/stores/ui-store'; @@ -10,6 +10,9 @@ import { useAgentStore } from '@renderer/stores/agent-store'; const STEPS = ['欢迎', '配置 LLM', '自定义 Agent', '工作空间', '开始使用']; +// #48 修复: Onboarding 进度持久化 key +const ONBOARDING_PROGRESS_KEY = 'onboarding_progress'; + export function OnboardingWizard(): React.JSX.Element | null { const onboardingCompleted = useUIStore((s) => s.onboardingCompleted); const setOnboardingCompleted = useUIStore((s) => s.setOnboardingCompleted); @@ -23,6 +26,46 @@ export function OnboardingWizard(): React.JSX.Element | null { // 上下文窗口(联动 Provider:ollama 可空=由模型决定;其他 provider 默认值见 DEFAULT_CTX) const [contextWindow, setContextWindow] = useState(null); + // #48 修复: 启动时从 localStorage 恢复未完成的引导进度,避免中途退出后需重新填写 + // 注意: apiKey 属于敏感信息,不持久化到 localStorage,恢复时需用户重新输入 + useEffect(() => { + try { + const saved = localStorage.getItem(ONBOARDING_PROGRESS_KEY); + if (!saved) return; + const p = JSON.parse(saved) as { + step?: number; provider?: string; baseURL?: string; + model?: string; workspacePath?: string; + contextWindow?: number | null; + }; + if (typeof p.step === 'number' && p.step >= 0 && p.step < STEPS.length) setStep(p.step); + if (typeof p.provider === 'string') setProvider(p.provider); + if (typeof p.baseURL === 'string') setBaseURL(p.baseURL); + if (typeof p.model === 'string') setModel(p.model); + if (typeof p.workspacePath === 'string') setWorkspacePath(p.workspacePath); + if (p.contextWindow !== undefined) setContextWindow(p.contextWindow); + // 审查修复: 恢复后如果 provider 需要 apiKey 但 apiKey 为空(未持久化), + // 回退到步骤 1(配置 LLM)让用户重新输入 apiKey,避免配置不完整 + // ollama 不需要 apiKey + if (p.provider && p.provider !== 'ollama' && p.step && p.step >= 2) { + setStep(1); + } + } catch { + // 解析失败忽略,使用默认值 + } + }, []); + + // #48 修复: 每步完成后保存进度到 localStorage,中途退出(关闭窗口/刷新)可恢复 + // 注意: 不保存 apiKey(敏感信息不写入 localStorage) + useEffect(() => { + try { + localStorage.setItem(ONBOARDING_PROGRESS_KEY, JSON.stringify({ + step, provider, baseURL, model, workspacePath, contextWindow, + })); + } catch { + // 写入失败(如隐私模式)忽略 + } + }, [step, provider, baseURL, model, workspacePath, contextWindow]); + if (onboardingCompleted) return null; // 各 Provider 上下文窗口默认值(与 SettingsModal 保持一致) @@ -79,6 +122,8 @@ export function OnboardingWizard(): React.JSX.Element | null { useAgentStore.getState().setConfigLoaded(true); } setOnboardingCompleted(true); + // #48 修复: 引导完成后清理 localStorage 进度,下次启动不再恢复 + try { localStorage.removeItem(ONBOARDING_PROGRESS_KEY); } catch { /* ignore */ } } catch (err) { console.error('[OnboardingWizard]', 'Failed to save configuration:', err); // 用户主动操作失败必须有反馈,否则向导不关闭、用户卡死 diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 8041034..4c86c61 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -154,6 +154,28 @@ function WorkspaceSettings() { if (inheritSoul) filesToInherit.push('SOUL.md'); if (inheritDatabase) filesToInherit.push('.metona/agent.db'); + // #51 修复: 继承数据库前校验源数据库完整性,避免继承损坏的数据库导致新工作空间数据丢失 + if (inheritDatabase && currentPath) { + try { + const integrityResult = await window.metona?.workspace?.checkDatabaseIntegrity(currentPath); + if (!integrityResult?.success) { + import('metona-toast').then((mod) => mod.default.error(`源数据库校验失败:${integrityResult?.error ?? '未知错误'}`)).catch(() => {}); + setApplying(false); + return; + } + if (!integrityResult.ok) { + import('metona-toast').then((mod) => mod.default.error(`源数据库损坏(${integrityResult.detail}),无法继承`)).catch(() => {}); + setApplying(false); + return; + } + } catch (err) { + console.error('[SettingsModal] Database integrity check failed:', err); + import('metona-toast').then((mod) => mod.default.error(`源数据库校验异常:${(err as Error).message}`)).catch(() => {}); + setApplying(false); + return; + } + } + if (filesToInherit.length > 0 && currentPath) { try { await window.metona.workspace.inheritFiles({ diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index 6877aa1..ff50e2a 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -36,6 +36,16 @@ function matchModifier(event: KeyboardEvent, ctrl?: boolean, shift?: boolean): b return true; } +/** + * #39 修复: 检测键盘事件目标是否为输入框(input/textarea/contentEditable) + * 用于在输入框聚焦时屏蔽单字符快捷键,避免拦截用户正常输入 + */ +function isInputTarget(e: KeyboardEvent): boolean { + const target = e.target as HTMLElement | null; + if (!target) return false; + return ['INPUT', 'TEXTAREA'].includes(target.tagName) || target.isContentEditable; +} + /** * 全局快捷键 Hook * @@ -46,6 +56,12 @@ export function useKeyboardShortcuts(): void { const handleKeyDown = (e: KeyboardEvent): void => { const key = e.key.toLowerCase(); + // #39 修复: 输入框聚焦时,单字符无修饰键直接放行,避免拦截用户输入 + // Ctrl/Cmd 组合键不受影响(如 Ctrl+N/L/D 等仍可触发) + if (isInputTarget(e) && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + return; + } + // Esc — 关闭弹窗 if (key === 'escape') { const { settingsOpen, closeSettings } = useUIStore.getState(); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index a65269a..9b8f966 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -175,6 +175,7 @@ interface MetonaAppAPI { showItemInFolder: (path: string) => Promise<{ success: boolean; error?: string }>; selectFolder: (defaultPath?: string) => Promise<{ canceled: boolean; path: string }>; restart: () => Promise<{ success: boolean }>; + reportError: (payload: unknown) => void; } // ===== Workspace API ===== @@ -222,6 +223,13 @@ interface MetonaWorkspaceAPI { inheritFiles: (params: { targetPath: string; sourcePath: string; files: string[] }) => Promise; getInfo: () => Promise; + // #51 修复: 校验源数据库完整性(PRAGMA integrity_check) + checkDatabaseIntegrity: (sourcePath: string) => Promise<{ + success: boolean; + ok?: boolean; + detail?: string; + error?: string; + }>; } // ===== Toast API =====