feat: v0.6.0 全量审计修复 — 安全加固 + 死代码激活 + 依赖治理
基于全量源码审计(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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.ts(SecurityScanHook)与 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++,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新托盘状态
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user