feat: v0.6.0 全量审计修复 — 安全加固 + 死代码激活 + 依赖治理
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m0s

基于全量源码审计(electron/ 70+ 文件、src/ 45+ 文件完整读取)的
四阶段迭代,修复 1 项安全缺陷、8 项功能缺陷、依赖与文档系统性脱节。

【P0 安全与数据正确性】
- S-1 数据导出泄露明文密钥:data:export 全量导出直接透传
  configService.getAll()(敏感 key 解密返回),导出文件含明文 API Key。
  新增 sanitizeExportConfig(shared.ts)逐 key 脱敏 + 5 项回归测试
- F-3 session_summaries 无级联删除:删会话后摘要永久残留。建表语句补
  FOREIGN KEY ON DELETE CASCADE + 迁移 8 重建存量表(幂等检测)+ 2 项测试
- F-4 macOS activate 重建窗口后确认弹框失效:窗口创建收敛为 createMainWindow
  单一入口(beforeLoad 补 setMainWindow/IPC 注册),TrayManager 补
  rebindWindow,app:selectFolder 改 event.sender 动态解析窗口

【P1 死代码激活与功能补全】
- F-1 会话右键菜单挂载:ContextMenu session 分支(重命名/置顶/归档/
  导出/删除)约 200 行此前无任何触发点,Sidebar SessionItem 挂载
  onContextMenu;置顶/归档 label 随状态切换显示
- F-2 归档会话不可找回:Sidebar 新增「已归档」折叠面板(恢复入口),
  归档功能形成完整闭环
- F-7 Provider 白名单校验:createAdapter/buildFallbackAdapter 未知
  provider 显式拒绝(此前静默落入 DeepSeekAdapter 以空配置失败)
- F-8 死配置治理:接通 5 项(llm.temperature/llm.maxTokens 注入引擎、
  security.promptInjectionDefense 控制 SecurityScanHook+消息检测、
  logging.auditEnabled 控制 AuditLogHook、logging.traceEnabled 控制
  SessionRecorder,均 fail-secure 仅显式 false 关闭);删除 4 项
  无消费者配置(requireWriteConfirmation/maxFileWriteSizeKB/fontSize/
  animationMode)

【P2 依赖治理与 UX 修缮】
- D-1 移除僵尸依赖 electron-store/zod/rehype-raw(源码零引用,
  共裁 24 包);README 技术栈表同步删除虚假宣称
- metona-toast 升级 0.2.1 → 0.5.0(API 全兼容:107 种图标类型、
  配置项超集,default/configure/use 接口不变)
- F-5 工作空间手输路径实时落库中间态:改 pendingPath 草稿 + 显式
  「校验」按钮(选择文件夹与手输共用 validatePath 流程)
- F-6 确认弹框超时滞留:倒计时归零时主动 refreshPending(后端超时
  已删条目,拉取后弹框自然消解)
- D-3 ChatInput accept 移除 .pdf(分类器不识别,误导性入口);
  Onboarding 切换 Provider 自动填充默认 URL(与 LLMSettings 一致)

【P3 文档口径收敛】
- README:工具数 30+→28、版本 0.6.0、测试数 252、配置表补 F-8 接通项
- built-in/index.ts 计数注释 30→28
- docs/网络工具 v2 存储键名统一为点号口径(searxng.enabled)
- docs/完整设计指南修正 db.handlers.ts 失效路径引用为 data.ts

【验证】
- lint 0 error / 0 warning
- typecheck 双工程(node+web)0 错误
- test:electron 24 文件 252 用例全通过(+7 新增:导出脱敏 ×5、
  级联删除 ×2)
- electron-vite build 成功(metona-toast 0.5.0 chunk 正常)
- 系统 Node 模式 npm test 225 通过 + 27 ABI skip(符合预期)
This commit is contained in:
2026-08-22 18:28:31 +08:00
parent 22028c91c3
commit 6b2b587c94
22 changed files with 723 additions and 504 deletions
+10 -3
View File
@@ -2,7 +2,7 @@
* 内置工具导出
*
* v0.3.13: 删除 todo_write 工具(零价值 + 死代码 clearSession + 与 task_manager 功能重叠)
* 工具总数:30 个(29 + DelegateTaskTool
* 工具总数:28 个(27 + DelegateTaskTool
*
* v0.3.3: 从 27 个工具扩展到 29 个工具
* 新增:file_move, file_info
@@ -21,7 +21,15 @@
*/
// 文件系统工具(7 个)
export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool, DeleteFileTool, FileMoveTool, FileInfoTool } from './filesystem';
export {
ReadFileTool,
WriteFileTool,
ListDirectoryTool,
SearchFilesTool,
DeleteFileTool,
FileMoveTool,
FileInfoTool,
} from './filesystem';
// v0.2.0: 精准文件编辑工具
export { FileEditorTool } from './file-editor';
// v0.2.0: ripgrep 代码搜索工具
@@ -61,4 +69,3 @@ export { ThinkTool } from './think';
// v0.3.1: 图片查看工具(1 个)
export { ViewImageTool } from './view-image';
@@ -0,0 +1,64 @@
/**
* 导出脱敏回归测试(S-1
*
* 背景:data:export 全量导出曾直接透传 configService.getAll()
* 该方法对敏感 key 解密返回明文,导致导出文件泄露明文 API Key。
* sanitizeExportConfig 必须保证任何敏感 key 经其处理后不含明文。
*/
import { describe, it, expect } from 'vitest';
import { sanitizeExportConfig, maskSensitive } from '../shared';
describe('sanitizeExportConfig — 导出配置脱敏', () => {
it('llm.apiKey / fallbackApiKey / searxng.auth_key 导出为掩码', () => {
const config = {
'llm.provider': 'deepseek',
'llm.model': 'deepseek-v4-pro',
'llm.apiKey': 'sk-very-secret-key-1234',
'llm.fallbackApiKey': 'sk-fallback-secret-9876',
'searxng.auth_key': 'bearer-token-abcdef',
'searxng.enabled': true,
};
const out = sanitizeExportConfig(config);
expect(out['llm.apiKey']).not.toContain('sk-very-secret');
expect(out['llm.apiKey']).toBe('***1234');
expect(out['llm.fallbackApiKey']).toBe('***9876');
expect(out['searxng.auth_key']).toBe('***cdef');
// 非敏感 key 原样保留
expect(out['llm.provider']).toBe('deepseek');
expect(out['searxng.enabled']).toBe(true);
});
it('短敏感值(<=4 字符)完全掩码', () => {
expect(sanitizeExportConfig({ 'llm.apiKey': 'abc' })['llm.apiKey']).toBe('***');
expect(sanitizeExportConfig({ 'llm.apiKey': '' })['llm.apiKey']).toBe('');
});
it('不修改入参对象(纯函数)', () => {
const config = { 'llm.apiKey': 'sk-original-plaintext' };
const snapshot = { ...config };
sanitizeExportConfig(config);
expect(config).toEqual(snapshot);
});
it('空对象与混合类型安全', () => {
expect(sanitizeExportConfig({})).toEqual({});
const out = sanitizeExportConfig({
'agent.maxIterations': 20,
'agent.enableThinking': true,
'ollama.numCtx': null,
'tools.run_command.enabled': false,
});
expect(out).toEqual({
'agent.maxIterations': 20,
'agent.enableThinking': true,
'ollama.numCtx': null,
'tools.run_command.enabled': false,
});
});
it('maskSensitive:非字符串敏感值原样返回', () => {
expect(maskSensitive('llm.apiKey', 123)).toBe(123);
expect(maskSensitive('llm.apiKey', null)).toBe(null);
});
});
+27 -20
View File
@@ -460,26 +460,33 @@ export function registerAgentHandlers(ctx: IPCContext): void {
try {
// 提示注入检测(安全模块)
const injectionResult = promptInjectionDefender.detect(userMessage.content);
if (injectionResult.riskScore >= 7) {
log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings);
sendErrorEvent(
`Message blocked by prompt injection defense: ${injectionResult.recommendation}`,
sessionId,
);
sessionRecorder.stopRecording(sessionId, {
totalIterations: 0,
totalTokens: 0,
durationMs: 0,
terminationReason: 'error',
});
return { success: false, error: 'Message blocked by prompt injection defense' };
}
if (injectionResult.riskScore >= 4) {
log.warn(
'[PromptInjectionDefender] Suspicious patterns detected:',
injectionResult.findings,
);
// F-8 接通: security.promptInjectionDefense=false 时跳过用户消息检测
// (工具结果侧的 SecurityScanHook 由 main.ts 按同一配置决定是否挂载)
// fail-secure: 仅显式 false 才关闭 —— 配置值异常(空串/null/类型错误)时保持防护开启
const injectionEnabled =
configService.get<boolean>('security.promptInjectionDefense') !== false;
if (injectionEnabled) {
const injectionResult = promptInjectionDefender.detect(userMessage.content);
if (injectionResult.riskScore >= 7) {
log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings);
sendErrorEvent(
`Message blocked by prompt injection defense: ${injectionResult.recommendation}`,
sessionId,
);
sessionRecorder.stopRecording(sessionId, {
totalIterations: 0,
totalTokens: 0,
durationMs: 0,
terminationReason: 'error',
});
return { success: false, error: 'Message blocked by prompt injection defense' };
}
if (injectionResult.riskScore >= 4) {
log.warn(
'[PromptInjectionDefender] Suspicious patterns detected:',
injectionResult.findings,
);
}
}
// TRACE 层:记录上下文构建
+7 -3
View File
@@ -5,7 +5,7 @@
* 审计日志链验证/查询、渲染进程错误上报(P0-3 修复断链)。
*/
import { ipcMain, shell, app, dialog } from 'electron';
import { ipcMain, shell, app, dialog, BrowserWindow } from 'electron';
import type { IPCContext } from './context';
import type { AuditEventType } from '../services/audit.service';
import log from 'electron-log';
@@ -54,8 +54,12 @@ export function registerAppHandlers(ctx: IPCContext): void {
}
});
ipcMain.handle('app:selectFolder', async (_event, defaultPath?: string) => {
const result = await dialog.showOpenDialog(mainWindow, {
ipcMain.handle('app:selectFolder', async (event, defaultPath?: string) => {
// F-4 补充: 从 event.sender 动态解析窗口 —— ctx.mainWindow 是注册时捕获的引用,
// macOS activate 重建窗口后为已销毁实例(dialog 挂 destroyed 窗口行为未定义)。
// fromWebContents 对任何存活窗口(含未来多窗口)都正确,解析失败回退无父对话框。
const callerWin = BrowserWindow.fromWebContents(event.sender) ?? mainWindow;
const result = await dialog.showOpenDialog(callerWin, {
properties: ['openDirectory', 'createDirectory'],
defaultPath: defaultPath ?? app.getPath('home'),
title: '选择工作空间目录',
+56 -14
View File
@@ -5,6 +5,7 @@
import { ipcMain } from 'electron';
import type { IPCContext } from './context';
import log from 'electron-log';
import { sanitizeExportConfig } from './shared';
/**
* 导出限流常量:单会话导出的最大消息条数。
@@ -33,7 +34,10 @@ const sanitizeExportMessage = (msg: Record<string, unknown>): Record<string, unk
void _omit;
return {
...msg,
toolResult: { ...toolResult, result: { ...rest, _displayNote: '[image base64 omitted in export]' } },
toolResult: {
...toolResult,
result: { ...rest, _displayNote: '[image base64 omitted in export]' },
},
};
}
}
@@ -61,15 +65,26 @@ export function registerDataHandlers(ctx: IPCContext): void {
if (sessionId) {
// 导出单个会话:剥离超大 base64 后返回
const messages = sessionService.getMessages(sessionId);
const sanitized = messages.map((m) => sanitizeExportMessage(m as unknown as Record<string, unknown>));
const sanitized = messages.map((m) =>
sanitizeExportMessage(m as unknown as Record<string, unknown>),
);
return { success: true, data: sanitized };
}
// 导出所有会话:剥离 dataUrl + 每会话限条数,防渲染进程 Blob 序列化 OOM
// S-1 修复:config 走 sanitizeExportConfig 脱敏 —— getAll() 对敏感 key
// 解密返回明文,直接透传会把明文 API Key 写入用户下载的导出文件
const sessions = sessionService.list();
const allData: Record<string, unknown> = { sessions: [], config: configService.getAll() };
const allData: Record<string, unknown> = {
sessions: [],
config: sanitizeExportConfig(configService.getAll()),
};
for (const session of sessions) {
const rawMessages = sessionService.getMessages(session.id, { limit: MAX_EXPORT_MESSAGES_PER_SESSION });
const sanitizedMessages = rawMessages.map((m) => sanitizeExportMessage(m as unknown as Record<string, unknown>));
const rawMessages = sessionService.getMessages(session.id, {
limit: MAX_EXPORT_MESSAGES_PER_SESSION,
});
const sanitizedMessages = rawMessages.map((m) =>
sanitizeExportMessage(m as unknown as Record<string, unknown>),
);
(allData.sessions as Array<Record<string, unknown>>).push({
...session,
messages: sanitizedMessages,
@@ -93,10 +108,16 @@ export function registerDataHandlers(ctx: IPCContext): void {
db.exec('DELETE FROM messages');
db.exec('DELETE FROM sessions');
db.exec('COMMIT');
log.info(`[DATA] All sessions cleared: ${sessCount.c} sessions, ${msgCount.c} messages deleted`);
log.info(
`[DATA] All sessions cleared: ${sessCount.c} sessions, ${msgCount.c} messages deleted`,
);
return { success: true, deletedSessions: sessCount.c, deletedMessages: msgCount.c };
} catch (error) {
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
try {
db.exec('ROLLBACK');
} catch {
/* 忽略回滚错误 */
}
log.error('[DATA] clearSessions failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
@@ -108,19 +129,36 @@ export function registerDataHandlers(ctx: IPCContext): void {
const db = sessionService.getDB();
try {
// M-41 修复: 三个 DELETE 操作用事务包裹,防止部分失败导致三类记忆数据不一致
const epiCount = db.prepare('SELECT COUNT(*) as c FROM episodic_memories').get() as { c: number };
const semCount = db.prepare('SELECT COUNT(*) as c FROM semantic_memories').get() as { c: number };
const workCount = db.prepare('SELECT COUNT(*) as c FROM working_memories').get() as { c: number };
const epiCount = db.prepare('SELECT COUNT(*) as c FROM episodic_memories').get() as {
c: number;
};
const semCount = db.prepare('SELECT COUNT(*) as c FROM semantic_memories').get() as {
c: number;
};
const workCount = db.prepare('SELECT COUNT(*) as c FROM working_memories').get() as {
c: number;
};
db.exec('BEGIN');
db.exec('DELETE FROM episodic_memories');
db.exec('DELETE FROM semantic_memories');
db.exec('DELETE FROM working_memories');
db.exec('COMMIT');
log.info(`[DATA] All memories cleared: ${epiCount.c} episodic, ${semCount.c} semantic, ${workCount.c} working memories deleted`);
return { success: true, deletedEpisodic: epiCount.c, deletedSemantic: semCount.c, deletedWorking: workCount.c };
log.info(
`[DATA] All memories cleared: ${epiCount.c} episodic, ${semCount.c} semantic, ${workCount.c} working memories deleted`,
);
return {
success: true,
deletedEpisodic: epiCount.c,
deletedSemantic: semCount.c,
deletedWorking: workCount.c,
};
} catch (error) {
// M-41 修复: 失败时回滚事务,确保数据一致性
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
try {
db.exec('ROLLBACK');
} catch {
/* 忽略回滚错误 */
}
log.error('[DATA] clearMemories failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
@@ -143,7 +181,11 @@ export function registerDataHandlers(ctx: IPCContext): void {
log.info('[DATA] Audit logs cleared');
return { success: true };
} catch (error) {
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
try {
db.exec('ROLLBACK');
} catch {
/* 忽略回滚错误 */
}
return { success: false, error: (error as Error).message };
}
});
+61 -11
View File
@@ -13,11 +13,20 @@ import { isSensitiveConfigKey } from '../utils/secure-config';
/** LLM 相关配置 key(变更时触发热重载 Adapter */
export const LLM_CONFIG_KEYS = [
'llm.provider', 'llm.model', 'llm.apiKey', 'llm.baseURL',
'llm.fallbackProvider', 'llm.fallbackModel', 'llm.fallbackApiKey', 'llm.fallbackBaseURL',
'llm.provider',
'llm.model',
'llm.apiKey',
'llm.baseURL',
'llm.fallbackProvider',
'llm.fallbackModel',
'llm.fallbackApiKey',
'llm.fallbackBaseURL',
'ollama.numCtx',
'deepseek.contextWindow', 'agnes.contextWindow', 'mimo.contextWindow',
'openai.contextWindow', 'anthropic.contextWindow',
'deepseek.contextWindow',
'agnes.contextWindow',
'mimo.contextWindow',
'openai.contextWindow',
'anthropic.contextWindow',
];
/** 敏感配置值脱敏(审计日志用:长值保留后 4 位,短值完全掩码) */
@@ -28,20 +37,43 @@ export function maskSensitive(key: string, value: unknown): unknown {
return value;
}
/**
* 导出配置脱敏(S-1 修复):对 configService.getAll() 的结果逐 key 脱敏。
*
* 背景:getAll() 对敏感 key 解密后返回明文(ConfigService 的读取契约),
* data:export 直接透传会把明文 API Key / 认证密钥写入用户下载的 JSON 文件,
* 绕过 safeStorage 密钥链加密。此函数确保任何导出路径不泄露明文密钥。
*
* @param config configService.getAll() 的完整配置快照
* @returns 脱敏后的副本(敏感值替换为掩码,原对象不修改)
*/
export function sanitizeExportConfig(config: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(config)) {
out[key] = maskSensitive(key, value);
}
return out;
}
/**
* Provider 切换时清空 API keyC-1 修复,供 set/setBatch 共用)
*
* 必须在写入 entries 之前执行:若前端把 llm.apiKey 放在 llm.provider 之前,
* 先 set apiKey 再处理 provider 会把用户刚填的 key 清空。
*/
export function clearApiKeyOnProviderChange(ctx: IPCContext, entries: Array<{ key: string; value: unknown }>): void {
export function clearApiKeyOnProviderChange(
ctx: IPCContext,
entries: Array<{ key: string; value: unknown }>,
): void {
const providerEntry = entries.find((e) => e.key === 'llm.provider');
if (!providerEntry) return;
const oldProvider = ctx.configService.get<string>('llm.provider') ?? '';
const newProvider = (providerEntry.value as string) ?? '';
if (oldProvider && newProvider && oldProvider !== newProvider) {
ctx.configService.set('llm.apiKey', '');
log.info(`[CONFIG] Provider changed (${oldProvider}${newProvider}), API key cleared to prevent incompatible key usage`);
log.info(
`[CONFIG] Provider changed (${oldProvider}${newProvider}), API key cleared to prevent incompatible key usage`,
);
}
}
@@ -62,8 +94,12 @@ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknow
orchestrator.updateDefaultConfig({ thinkingEnabled: value as boolean });
break;
case 'agent.thinkingEffort':
agentEngineManager.updateConfigAll({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
orchestrator.updateDefaultConfig({ thinkingEffort: value as 'low' | 'medium' | 'high' | 'max' });
agentEngineManager.updateConfigAll({
thinkingEffort: value as 'low' | 'medium' | 'high' | 'max',
});
orchestrator.updateDefaultConfig({
thinkingEffort: value as 'low' | 'medium' | 'high' | 'max',
});
break;
case 'agent.toolExecutionTimeoutMs':
agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number });
@@ -121,8 +157,20 @@ export async function applyConfigSideEffects(
const logLevelEntry = entries.find((e) => e.key === 'logging.level');
if (logLevelEntry && typeof logLevelEntry.value === 'string') {
const { transports } = await import('electron-log');
transports.file.level = logLevelEntry.value as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly';
transports.console.level = logLevelEntry.value as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly';
transports.file.level = logLevelEntry.value as
| 'error'
| 'warn'
| 'info'
| 'debug'
| 'verbose'
| 'silly';
transports.console.level = logLevelEntry.value as
| 'error'
| 'warn'
| 'info'
| 'debug'
| 'verbose'
| 'silly';
log.info(`[CONFIG] Log level updated to ${logLevelEntry.value}`);
}
@@ -132,7 +180,9 @@ export async function applyConfigSideEffects(
}
// 5. 工作空间路径写入独立文件(下次启动生效)
const workspaceEntry = entries.find((e) => e.key === 'workspace.path' && typeof e.value === 'string');
const workspaceEntry = entries.find(
(e) => e.key === 'workspace.path' && typeof e.value === 'string',
);
if (workspaceEntry) {
try {
const { writeWorkspacePathToFile } = await import('../main');
+87 -33
View File
@@ -137,6 +137,14 @@ const ENV_FALLBACK: Record<string, { apiKey?: string; baseURL?: string }> = {
anthropic: { apiKey: 'ANTHROPIC_API_KEY', baseURL: 'ANTHROPIC_BASE_URL' },
};
/**
* F-7 修复: 已知 Provider 白名单
* 此前 createAdapter 的 switch default 静默落入 DeepSeekAdapter —— provider 配置
* 拼写错误(如 "deepsek")不报错,而是以空配置静默失败。显式校验后未知值
* 走"配置不完整"路径,用户可在设置中看到明确提示。
*/
const KNOWN_PROVIDERS = new Set(['deepseek', 'agnes', 'mimo', 'ollama', 'openai', 'anthropic']);
async function initialize(): Promise<void> {
log.info('MetonaAI Desktop starting...');
electronApp.setAppUserModelId('com.metona.ai-desktop');
@@ -222,6 +230,14 @@ async function initialize(): Promise<void> {
return null;
}
// F-7 修复: 未知 Provider 显式拒绝(此前 switch default 静默落入 DeepSeekAdapter
if (!KNOWN_PROVIDERS.has(provider)) {
log.warn(
`Unknown LLM provider "${provider}". Supported: ${[...KNOWN_PROVIDERS].join(', ')}.`,
);
return null;
}
if (!apiKey && provider !== 'ollama') {
log.warn(`API key is required for provider "${provider}". Please set it in Settings.`);
return null;
@@ -262,6 +278,15 @@ async function initialize(): Promise<void> {
// ===== 步骤 5: 工作空间文件 + System Prompt =====
const contextBuilder = new ContextBuilder();
// ===== F-8 接通: 此前为死配置的安全/日志开关真实消费 =====
// fail-secure: 仅显式 false 才关闭 —— 配置值异常(空串/null/类型错误)时保持默认开启
const promptInjectionEnabled =
configService.get<boolean>('security.promptInjectionDefense') !== false;
const auditEnabled = configService.get<boolean>('logging.auditEnabled') !== false;
const traceEnabled = configService.get<boolean>('logging.traceEnabled') !== false;
// SessionRecorder 录制总开关(false 时不写 TRACE JSONL 文件)
sessionRecorder.setEnabled(traceEnabled);
// ===== v0.2.0: 安全模块(必须在工具注册之前) =====
const policyEngine = new PolicyEngine();
const sandboxManager = new SandboxManager({
@@ -330,16 +355,24 @@ async function initialize(): Promise<void> {
const mcpManager = new MCPManager(() => db, toolRegistry);
// ===== HooksP0-2: SecurityScanHook 前置,对工具结果做间接注入防护) =====
// F-8 接通: SecurityScanHook / AuditLogHook 按 security.promptInjectionDefense
// 与 logging.auditEnabled 配置构建(此前两配置为死配置,hook 无条件挂载)
const preToolHooks = [
new PermissionCheckHook(policyEngine),
new RateLimitHook(20),
confirmationHook,
];
const postToolHooks = [
new SecurityScanHook(promptDefender),
new AuditLogHook(auditService),
...(promptInjectionEnabled ? [new SecurityScanHook(promptDefender)] : []),
...(auditEnabled ? [new AuditLogHook(auditService)] : []),
new MemoryTriggerHook(memoryManager),
];
if (!promptInjectionEnabled) {
log.warn('[Security] promptInjectionDefense disabled by config — injection scanning off');
}
if (!auditEnabled) {
log.warn('[Logging] auditEnabled disabled by config — tool audit logging off');
}
// ===== P2-10: Agent Engine Manager(每会话独立引擎,替代全局单引擎) =====
const buildAdapter = (): IMetonaProviderAdapter => createAdapter() ?? FALLBACK_ADAPTER;
@@ -360,6 +393,9 @@ async function initialize(): Promise<void> {
| 'max'
| null) ?? 'high',
toolExecutionTimeoutMs: configService.get<number>('agent.toolExecutionTimeoutMs') ?? 120_000,
// F-8 接通: llm.temperature / llm.maxTokens 此前为死配置(引擎硬编码 0.0/63488)
temperature: configService.get<number>('llm.temperature') ?? 0.0,
maxTokens: configService.get<number>('llm.maxTokens') ?? 63488,
},
toolRegistry,
preToolHooks,
@@ -383,6 +419,13 @@ async function initialize(): Promise<void> {
'';
if (!baseURL) return null;
if (!apiKey && provider !== 'ollama') return null;
// F-7 修复: 故障转移 Provider 同样做白名单校验(未知值禁用故障转移而非静默降级)
if (!KNOWN_PROVIDERS.has(provider)) {
log.warn(
`[Fallback] Unknown provider "${provider}". Supported: ${[...KNOWN_PROVIDERS].join(', ')}. Failover disabled.`,
);
return null;
}
const contextWindow =
provider !== 'ollama'
? (configService.get<number>(`${provider}.contextWindow`) ?? undefined)
@@ -573,36 +616,44 @@ async function initialize(): Promise<void> {
// ===== 窗口管理 =====
windowManager = new WindowManager();
const mainWindow = windowManager.createWindow({
id: 'main',
workspacePath: workspaceInfo.path,
title: 'MetonaAI Desktop',
// P2-11 修复: 在 loadURL 之前注册 IPC handler,消除渲染进程加载与 IPC 注册的时序窗口
beforeLoad: (win) => {
confirmationHook.setMainWindow(win);
registerAllIPCHandlers({
mainWindow: win,
sessionService,
configService,
workspaceService,
contextBuilder,
agentEngineManager,
toolRegistry,
auditService,
sessionRecorder,
memoryManager,
mcpManager,
reloadAdapter,
promptInjectionDefender: promptDefender,
outputValidator,
confirmationHook,
memoryConsolidator,
orchestrator,
sessionSummaryService,
toolsReadyRef,
});
},
});
// 局部 const 捕获:闭包内 TS 无法对模块级可空变量做流收窄
const wm = windowManager;
// F-4 修复: 窗口创建收敛为单一入口 —— macOS activate / 托盘重建窗口此前不走
// beforeLoad,导致 confirmationHook 仍指向已销毁的旧窗口,所有需确认工具被
// "no main window available" 永久阻断。此闭包保证任何重建路径都完成
// setMainWindow + IPC 注册(registerAllIPCHandlers 有防重入标志,重复调用安全)。
const createMainWindow = (): BrowserWindow =>
wm.createWindow({
id: 'main',
workspacePath: workspaceInfo.path,
title: 'MetonaAI Desktop',
// P2-11 修复: 在 loadURL 之前注册 IPC handler,消除渲染进程加载与 IPC 注册的时序窗口
beforeLoad: (win) => {
confirmationHook.setMainWindow(win);
registerAllIPCHandlers({
mainWindow: win,
sessionService,
configService,
workspaceService,
contextBuilder,
agentEngineManager,
toolRegistry,
auditService,
sessionRecorder,
memoryManager,
mcpManager,
reloadAdapter,
promptInjectionDefender: promptDefender,
outputValidator,
confirmationHook,
memoryConsolidator,
orchestrator,
sessionSummaryService,
toolsReadyRef,
});
},
});
const mainWindow = createMainWindow();
// ===== 系统托盘 =====
const resourcesPath = join(__dirname, '../../assets');
@@ -683,7 +734,10 @@ async function initialize(): Promise<void> {
app.on('activate', () => {
if (windowManager && windowManager.count === 0) {
windowManager.createWindow({ id: 'main', workspacePath: workspaceInfo.path });
// F-4 修复: 重建窗口走统一入口(beforeLoad 补 confirmationHook/IPC),
// 并把托盘引用重新绑定到新窗口(否则托盘点击指向已销毁窗口)
const win = createMainWindow();
trayManager?.rebindWindow(win);
} else {
windowManager?.focusWindow();
}
@@ -255,3 +255,78 @@ describe.skipIf(!dbAvailable)('SessionSummary 分层上下文 × 截断交互',
expect(history.every((m: any) => !m.images?.length)).toBe(true);
});
});
/**
* F-3 回归:session_summaries 级联删除
*
* 缺陷:该表此前无 FOREIGN KEY ON DELETE CASCADE —— 删除会话后摘要残留。
* 迁移 8 重建表补级联;此测试验证目标 schema 的行为契约:
* DELETE sessions 必须级联清除对应摘要(真实库由 DatabaseService 开启 foreign_keys pragma)。
*/
describe.skipIf(!dbAvailable)('session_summaries 级联删除(F-3', () => {
let db: any;
beforeAll(() => {
db = new Database(':memory:');
// 与 DatabaseService.initialize 一致:开启外键约束(级联依赖此开关)
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
title TEXT DEFAULT '新会话',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE session_summaries (
session_id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
summarized_until_rowid INTEGER NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
`);
});
afterAll(() => {
db?.close();
});
it('外键声明存在且为 CASCADE(迁移 8 后的目标 schema', () => {
const fks = db.prepare('PRAGMA foreign_key_list(session_summaries)').all() as Array<{
table: string;
on_delete: string;
}>;
expect(fks.some((fk) => fk.table === 'sessions' && fk.on_delete === 'CASCADE')).toBe(true);
});
it('删除会话后摘要级联清除(不再残留孤儿数据)', () => {
const now = Date.now();
db.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('s_cascade', ?, ?)`).run(
now,
now,
);
db.prepare(
`INSERT INTO session_summaries (session_id, summary, summarized_until_rowid, updated_at)
VALUES ('s_cascade', '将被级联删除的摘要', 99, ?)`,
).run(now);
db.prepare(`INSERT INTO sessions (id, created_at, updated_at) VALUES ('s_keep', ?, ?)`).run(
now,
now,
);
db.prepare(
`INSERT INTO session_summaries (session_id, summary, summarized_until_rowid, updated_at)
VALUES ('s_keep', '保留的摘要', 50, ?)`,
).run(now);
db.prepare(`DELETE FROM sessions WHERE id = 's_cascade'`).run();
const remaining = db.prepare(`SELECT session_id FROM session_summaries`).all() as Array<{
session_id: string;
}>;
expect(remaining.map((r) => r.session_id)).toEqual(['s_keep']);
});
});
+54 -5
View File
@@ -40,6 +40,7 @@ export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [
{ key: 'llm.model', value: '', category: 'llm' },
{ key: 'llm.apiKey', value: '', category: 'llm' },
{ key: 'llm.baseURL', value: '', category: 'llm' },
// F-8 接通: temperature/maxTokens 此前为死配置(引擎硬编码),现由 main.ts 注入引擎
{ key: 'llm.temperature', value: 0, category: 'llm' },
{ key: 'llm.maxTokens', value: 63488, category: 'llm' },
// v0.5.4: 多模态总开关 — 即使模型支持多模态,未开启也不能上传图片(默认关闭,
@@ -63,16 +64,18 @@ export const CONFIG_DEFAULTS: ConfigDefaultEntry[] = [
{ key: 'agent.toolExecutionTimeoutMs', value: 120000, category: 'agent' },
// 安全配置
{ key: 'security.requireWriteConfirmation', value: true, category: 'security' },
{ key: 'security.maxFileWriteSizeKB', value: 1024, category: 'security' },
// F-8 清理: 移除死配置 security.requireWriteConfirmation / security.maxFileWriteSizeKB
// (无任何消费者 —— 确认策略由工具定义的 requiresPermission/riskLevel 驱动,
// 文件大小上限由 file-guard.ts 的 MAX_FILE_SIZE_BYTES 常量控制)
// F-8 接通: promptInjectionDefense 由 main.tsSecurityScanHook)与 ipc/agent.ts(用户消息检测)消费
{ key: 'security.promptInjectionDefense', value: true, category: 'security' },
// UI 配置
// F-8 清理: 移除死配置 ui.fontSize / ui.animationMode(无消费者;主题走 localStorage
{ key: 'ui.theme', value: 'auto', category: 'ui' },
{ key: 'ui.animationMode', value: 'auto', category: 'ui' },
{ key: 'ui.fontSize', value: 'medium', category: 'ui' },
// 日志配置
// F-8 接通: auditEnabled/traceEnabled 由 main.ts 构建 hooks 与 SessionRecorder 时消费
{ key: 'logging.level', value: 'info', category: 'logging' },
{ key: 'logging.auditEnabled', value: true, category: 'logging' },
{ key: 'logging.traceEnabled', value: true, category: 'logging' },
@@ -286,11 +289,14 @@ export class DatabaseService {
);
-- ===== P2: 会话摘要表(分层上下文——超长会话早期消息压缩为摘要,LLM 只加载摘要 + 近期原文) =====
-- F-3 修复: 补 FOREIGN KEY ON DELETE CASCADE —— 此前无级联,删除会话后摘要残留,
-- 随使用无限累积(messages/tasks 均有级联,唯独此表遗漏)
CREATE TABLE IF NOT EXISTS session_summaries (
session_id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
summarized_until_rowid INTEGER NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
-- ===== 索引 =====
@@ -463,6 +469,49 @@ export class DatabaseService {
// 新写入的消息仍通过触发器正常进入索引
}
// F-3 迁移 8: 重建 session_summaries 表,补 FOREIGN KEY ON DELETE CASCADE
// 此前该表无级联删除 —— 删除会话(sessions:delete / data:clearSessions)后
// 摘要记录永久残留,随使用无限累积。SQLite 不支持 ALTER ADD CONSTRAINT,需重建表。
// 幂等:PRAGMA foreign_key_list 检测已有级联则跳过。
try {
const fkRows = db.prepare('PRAGMA foreign_key_list(session_summaries)').all() as Array<{
table: string;
on_delete: string;
}>;
const hasCascade = fkRows.some(
(fk) => fk.table === 'sessions' && fk.on_delete === 'CASCADE',
);
if (!hasCascade) {
log.info('[DB] Migration: rebuilding session_summaries table to add ON DELETE CASCADE');
const rebuildSummaries = db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS session_summaries_new (
session_id TEXT PRIMARY KEY,
summary TEXT NOT NULL,
summarized_until_rowid INTEGER NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
INSERT INTO session_summaries_new (session_id, summary, summarized_until_rowid, updated_at)
SELECT session_id, summary, summarized_until_rowid, updated_at
FROM session_summaries;
DROP TABLE session_summaries;
ALTER TABLE session_summaries_new RENAME TO session_summaries;
`);
});
rebuildSummaries();
log.info(
'[DB] Migration: session_summaries table rebuilt successfully (ON DELETE CASCADE added)',
);
}
} catch (error) {
const msg = toErrorMessage(error);
log.warn(`[DB] Migration 8 (session_summaries CASCADE) skipped: ${msg}`);
// 非致命 — 级联缺失仅导致摘要残留(不影响会话读写),但建议用户检查工作空间数据库
}
// 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,需要重建表
@@ -57,8 +57,22 @@ export class SessionRecorder {
/** P1-6: 每会话独立状态(支持并发会话录制) */
private sessions = new Map<string, SessionRecordState>();
/**
* F-8 接通: 录制总开关(logging.traceEnabled,默认 true
* 关闭时 writeEvent 丢弃所有事件(状态 Map 仍维护以保持接口兼容)
*/
private enabled = true;
constructor(private workspacePath: string) {}
/** F-8: 设置录制开关(main.ts 启动时按 logging.traceEnabled 注入) */
setEnabled(enabled: boolean): void {
this.enabled = enabled;
if (!enabled) {
log.info('[SessionRecorder] Trace recording disabled by config (logging.traceEnabled=false)');
}
}
/** 获取指定会话的录制状态(不存在返回 null) */
private state(sessionId: string): SessionRecordState | null {
return this.sessions.get(sessionId) ?? null;
@@ -262,6 +276,8 @@ export class SessionRecorder {
private writeEvent(sessionId: string, data: Record<string, unknown>): void {
const state = this.state(sessionId);
if (!state) return;
// F-8 接通: logging.traceEnabled=false 时丢弃事件(不写文件)
if (!this.enabled) return;
const event: TraceEvent = {
seq: state.seq++,
+17
View File
@@ -74,6 +74,23 @@ export class TrayManager {
TrayManager.isQuitting = true;
}
/**
* F-4 修复: 重新绑定主窗口引用(macOS activate / 托盘重建窗口后调用)
*
* 旧窗口销毁后 this.mainWindow 指向已 destroyed 实例,托盘点击(显示/隐藏)
* 与"关闭到托盘"行为全部失效。此方法更新引用并为新窗口重新挂载 close 拦截
* (原监听随旧窗口销毁自动释放,新窗口需要同样的 hide-to-tray 行为)。
*/
rebindWindow(mainWindow: BrowserWindow): void {
this.mainWindow = mainWindow;
mainWindow.on('close', (event) => {
if (!TrayManager.isQuitting) {
event.preventDefault();
mainWindow.hide();
}
});
}
/**
* 更新托盘状态
*/