feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s

This commit is contained in:
2026-09-08 14:30:27 +08:00
parent 69776e447f
commit 4cd6e997b5
86 changed files with 4303 additions and 956 deletions
+7 -1
View File
@@ -15,6 +15,8 @@
import type Database from 'better-sqlite3';
import { createHash } from 'crypto';
import log from 'electron-log';
// v0.8.2 P1-5: 审计 args 深度脱敏(与配置层脱敏同源)
import { deepMaskSensitive } from '../utils/mask';
/**
* #36 修复: 稳定序列化,递归按 key 字典序排序后序列化
@@ -194,6 +196,10 @@ export class AuditService {
/**
* 记录工具调用
*
* v0.8.2 P1-5: args 深度脱敏后落库 —— 工具参数中的密钥/鉴权头/token 此前
* 以明文进入 audit_logssafeStorage 只保护配置层),构成敏感信息二次扩散面。
* 键名匹配与配置层单源(utils/mask → secure-config 归一化匹配)。
*/
logToolCall(params: {
sessionId: string;
@@ -212,7 +218,7 @@ export class AuditService {
actor: 'agent',
target: params.toolName,
details: {
args: params.args,
args: deepMaskSensitive(params.args),
result: typeof params.result === 'string' ? params.result.slice(0, 1000) : params.result,
error: params.error,
},
+9 -1
View File
@@ -132,8 +132,9 @@ export class DatabaseService {
* 迁移 12(清理已废除的 llm.contextWindow 分 Provider 键与 ollama.numCtx)。
* v0.8.1 review: 4 → 5 —— 迁移 13working_memories 补 sessions 外键 CASCADE
* 孤儿行清理;根治历史 schema 缺失级联导致的孤儿数据)。
* v0.8.2: 5 → 6 —— 迁移 14(记忆表 embedding_model 列,嵌入模型指纹)。
*/
static readonly SCHEMA_VERSION = 5;
static readonly SCHEMA_VERSION = 6;
constructor(workspacePath?: string) {
const baseDir = workspacePath ?? join(app.getPath('userData'), 'MetonaWorkspaces', 'default');
@@ -751,6 +752,13 @@ export class DatabaseService {
}
}
// v0.8.2 P3-1 迁移 14: 记忆表 embedding_model 列(嵌入模型指纹)。
// 用户更换 memory.embeddingModel 后,旧 BLOB 以新查询向量算余弦 —— 维度
// 不同静默余弦为 0(降级 TF-IDF),同维不同模型产生噪声分数。现记录每条
// 向量的来源模型,检索时模型不匹配的向量视为缺失(惰性重算自愈)。
tryAddColumn('episodic_memories', 'embedding_model', 'TEXT');
tryAddColumn('semantic_memories', 'embedding_model', 'TEXT');
// v0.7.4 P4-4 迁移 9: messages_fts 升级 trigram tokenizer
// 存量库的 messages_fts 建表语句不含 trigram —— 直接 DROP + 重建 + rebuild
// 使中文非连续子串搜索(trigram ≥3 字符)可用。检测方式:读 sqlite_master 的
+79 -3
View File
@@ -23,7 +23,15 @@
import { app } from 'electron';
import { join } from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from 'fs';
import log from 'electron-log';
import { CONFIG_DEFAULTS, DEPRECATED_CONFIG_KEYS } from './database.service';
import {
@@ -34,6 +42,8 @@ import {
/** 全局配置文件路径(userData 下,与工作空间无关) */
const GLOBAL_CONFIG_FILE = join(app.getPath('userData'), 'global-config.json');
/** 最近一次成功落盘的备份(主文件损坏时的恢复源) */
const GLOBAL_CONFIG_BACKUP = join(app.getPath('userData'), 'global-config.backup.json');
/** 全局配置 key 前缀清单(匹配这些前缀的 key 视为全局配置) */
const GLOBAL_KEY_PREFIXES = [
@@ -105,7 +115,28 @@ export class GlobalConfigService {
try {
if (existsSync(GLOBAL_CONFIG_FILE)) {
const raw = readFileSync(GLOBAL_CONFIG_FILE, 'utf-8');
this.data = JSON.parse(raw) as GlobalConfigData;
try {
this.data = JSON.parse(raw) as GlobalConfigData;
} catch (parseErr) {
// v0.8.2 P0-3: 主文件损坏自愈 —— 依次尝试:① 最近一次成功落盘的备份
// 恢复(恢复成功即回写主文件);② 归档损坏文件后以空配置启动。
// 此前 parse 失败会静默以空配置运行,用户全部全局配置(含 LLM 凭据)
// 在下一次 set() 落盘时被覆盖丢失。
log.error('[GlobalConfig] Main config file corrupted:', parseErr);
this.data = this.recoverFromBackup();
if (Object.keys(this.data).length > 0) {
this.flush();
log.warn('[GlobalConfig] Restored global config from backup file');
} else {
const archived = `${GLOBAL_CONFIG_FILE}.corrupt-${Date.now()}`;
try {
renameSync(GLOBAL_CONFIG_FILE, archived);
log.warn(`[GlobalConfig] Corrupted config archived to ${archived}`);
} catch {
/* 归档失败不阻断启动 */
}
}
}
// v0.8.1 review: 清除已废除的配置键(分 Provider contextWindow / ollama.numCtx),
// 与工作空间 DB 迁移 12 对齐 —— 全局层残留会使双源语义复活
let purged = 0;
@@ -140,6 +171,23 @@ export class GlobalConfigService {
this.initialized = true;
}
/**
* v0.8.2 P0-3: 从备份恢复(仅 initialize 的损坏自愈路径调用)。
* 备份不存在或损坏时返回空对象。
*/
private recoverFromBackup(): GlobalConfigData {
try {
if (!existsSync(GLOBAL_CONFIG_BACKUP)) return {};
const raw = readFileSync(GLOBAL_CONFIG_BACKUP, 'utf-8');
const parsed = JSON.parse(raw) as GlobalConfigData;
if (parsed && typeof parsed === 'object') return parsed;
return {};
} catch (err) {
log.error('[GlobalConfig] Backup recovery failed:', err);
return {};
}
}
/**
* 读取全局配置(P0-1: 敏感 key 自动解密)
*/
@@ -236,12 +284,40 @@ export class GlobalConfigService {
/**
* 落盘到 JSON 文件
*
* v0.8.2 P0-3 根治:此前直接 writeFileSync 覆写主文件 —— 写盘中途崩溃
* (断电/强杀)会产生半截 JSON,下次启动 parse 失败后以空配置启动,
* **全部全局配置(含 LLM 凭据)随之丢失**。现改为:
* 1. 原子替换:先写同目录 tmp,再 renameSync 原子改名(与 write_file 工具 /
* workspace.rewriteMemory 同口径,Windows 下 rename 覆盖已存在目标);
* 2. 写后备份:成功落盘后同步维护 global-config.backup.json(尽力而为,
* 失败仅告警)—— 主文件因外部因素损坏时的恢复源。
*/
private flush(): void {
const tmpPath = `${GLOBAL_CONFIG_FILE}.tmp_${Date.now()}_${Math.random()
.toString(36)
.slice(2, 8)}`;
try {
writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(this.data, null, 2), 'utf-8');
writeFileSync(tmpPath, JSON.stringify(this.data, null, 2), 'utf-8');
try {
renameSync(tmpPath, GLOBAL_CONFIG_FILE);
} catch (err) {
try {
unlinkSync(tmpPath);
} catch {
/* 忽略清理失败 */
}
throw err;
}
} catch (err) {
log.error('[GlobalConfig] Failed to write config file:', err);
return;
}
// 备份为尽力而为:失败不影响主流程(备份缺失仅降低自愈成功率)
try {
copyFileSync(GLOBAL_CONFIG_FILE, GLOBAL_CONFIG_BACKUP);
} catch (err) {
log.warn(`[GlobalConfig] Backup write failed: ${(err as Error).message}`);
}
}
}
+112 -11
View File
@@ -23,7 +23,7 @@ import type Database from 'better-sqlite3';
import log from 'electron-log';
import type { ToolRegistry } from '../harness/tools/registry';
import type { IMetonaTool, ToolExecutionContext } from '../harness/types/metona-tool';
import type { MetonaToolDef } from '../harness/types';
import type { MetonaToolDef, MetonaParamField } from '../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../harness/types';
// v0.7.3 P3-2: 子进程环境净化收敛到 utils/safe-env.ts 单源(与 run_command 共用)
import { buildSafeChildEnv } from '../utils/safe-env';
@@ -289,24 +289,53 @@ class MCPToolAdapter implements IMetonaTool {
name: this.mcpTool.name,
arguments: args,
});
return result.content;
const content = result.content as Array<Record<string, unknown>> | undefined;
// v0.8.2 P2-7 根治: MCP 返回的 image block 此前被直接透传 —— registry 的
// 内联图片白名单只识别顶层 dataUrl/image 字段,嵌套在 content 数组中的图片
// 块不匹配白名单,被 50KB 截断为破损 base64。现将 text/image block 归并:
// text 拼接为顶层文本,首个 image block 提升为顶层 `image` 字段(data URI
// 命中 registry 白名单整段放行 → 渲染端可内联预览)。
if (Array.isArray(content)) {
const texts: string[] = [];
let imageDataUri: string | null = null;
for (const item of content) {
const type = item?.type;
if (type === 'text' && typeof item.text === 'string') {
texts.push(item.text);
} else if (type === 'image' && !imageDataUri) {
const data = typeof item.data === 'string' ? item.data : '';
const mimeType =
typeof item.mimeType === 'string' && item.mimeType ? item.mimeType : 'image/png';
if (data) {
imageDataUri = `data:${mimeType};base64,${data}`;
}
}
}
if (imageDataUri || texts.length > 0) {
return {
...(texts.length > 0 ? { text: texts.join('\n\n') } : {}),
...(imageDataUri ? { image: imageDataUri } : {}),
};
}
}
return content;
}
/**
* 将 MCP JSON Schema 转换为 MetonaToolParams
*
* v0.8.2 P2-7 根治: 旧实现只保留顶层 properties 的 type/description ——
* 丢弃 enum/anyOf/oneOf/嵌套对象/items/default,复杂 MCP 工具的参数约束
* 对 LLM 不可见,易产生非法参数。现递归保留 IR 支持的全部结构(见
* MetonaParamField 的 P2-7 扩展字段)。
*/
private convertSchema(schema: Record<string, unknown>): MetonaToolDef['parameters'] {
const properties: Record<
string,
{ type: 'string' | 'number' | 'boolean' | 'object' | 'array'; description: string }
> = {};
const properties: Record<string, MetonaToolDef['parameters']['properties'][string]> = {};
const schemaProps = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
for (const [key, prop] of Object.entries(schemaProps)) {
properties[key] = {
type: (prop.type as 'string' | 'number' | 'boolean' | 'object' | 'array') ?? 'string',
description: (prop.description as string) ?? '',
};
properties[key] = this.convertSchemaField(prop);
}
return {
@@ -315,6 +344,43 @@ class MCPToolAdapter implements IMetonaTool {
required: schema.required as string[] | undefined,
};
}
/** 单个参数字段递归转换(P2-7 schema 保真) */
private convertSchemaField(prop: Record<string, unknown>): MetonaParamField {
const field: MetonaParamField = {
type: (prop.type as MetonaParamField['type']) ?? 'string',
description: (prop.description as string) ?? '',
};
if (Array.isArray(prop.enum)) {
field.enum = prop.enum.map((v) => String(v));
}
if (prop.items && typeof prop.items === 'object') {
field.items = this.convertSchemaField(prop.items as Record<string, unknown>);
}
if (prop.properties && typeof prop.properties === 'object') {
const nested: Record<string, MetonaParamField> = {};
for (const [k, v] of Object.entries(
prop.properties as Record<string, Record<string, unknown>>,
)) {
nested[k] = this.convertSchemaField(v);
}
field.properties = nested;
}
if (Array.isArray(prop.required)) {
field.required = prop.required.map((v) => String(v));
}
for (const combinator of ['anyOf', 'oneOf'] as const) {
if (Array.isArray(prop[combinator])) {
field[combinator] = (prop[combinator] as Array<Record<string, unknown>>).map((v) =>
this.convertSchemaField(v),
);
}
}
if (prop.default !== undefined) {
field.default = prop.default;
}
return field;
}
}
// ===== MCP Manager =====
@@ -819,6 +885,11 @@ export class MCPManager {
/**
* 获取所有 Server 状态
*
* v0.8.2 P2-7 根治: 已配置但**禁用**的 server 此前不出现在列表中 ——
* initialize() 只连 enabled=1getServerStates 只映射 servers Map,设置面板
* 无法展示"已配置但禁用"的完整清单。现从 DB 补齐缺失条目(status=disconnected、
* enabled=false),并为每个条目标注 enabled。
*/
getServerStates(): Array<{
name: string;
@@ -827,15 +898,45 @@ export class MCPManager {
error?: string;
/** v0.7.3 P4-2: reconnecting 状态下的已尝试次数(第 N/3 次排程) */
reconnectAttempt?: number;
/** v0.8.2 P2-7: 是否为启用状态(DB enabled=1)—— 禁用 server 以 disconnected 呈现 */
enabled: boolean;
}> {
return Array.from(this.servers.values()).map((s) => ({
// DB 全量配置(enabled 标注 + 补齐禁用条目);DB 不可用时退回仅已连接集合
let enabledMap = new Map<string, boolean>();
try {
const db = this.getDB();
const rows = db.prepare('SELECT name, enabled FROM mcp_servers').all() as Array<{
name: string;
enabled: number;
}>;
enabledMap = new Map(rows.map((r) => [r.name, r.enabled === 1]));
} catch (err) {
log.warn(`[MCPManager] getServerStates DB lookup failed: ${(err as Error).message}`);
}
const states = Array.from(this.servers.values()).map((s) => ({
name: s.config.name,
status: s.status,
toolCount: s.tools.length,
error: s.error,
reconnectAttempt:
s.status === 'reconnecting' ? this.reconnectAttempts.get(s.config.name) : undefined,
enabled: enabledMap.get(s.config.name) ?? true,
}));
for (const [name, enabled] of enabledMap) {
if (!enabled && !states.some((s) => s.name === name)) {
states.push({
name,
status: 'disconnected' as MCPServerStatus,
toolCount: 0,
error: undefined,
reconnectAttempt: undefined,
enabled: false,
});
}
}
return states;
}
/**
+9 -4
View File
@@ -14,6 +14,8 @@ import { Tray, Menu, BrowserWindow, app, nativeImage, Notification } from 'elect
import { join } from 'path';
import { existsSync } from 'fs';
import log from 'electron-log';
// v0.8.2 P2-5: 托盘菜单文案双语(ui.locale 驱动)
import { mt } from '../utils/main-locale';
export type TrayStatus = 'idle' | 'thinking' | 'executing' | 'error';
@@ -202,12 +204,15 @@ export class TrayManager {
enabled: false,
},
{
label: `状态: ${statusIcons[this.currentStatus]} ${this.currentStatus}`,
// v0.8.2 P2-5: 托盘菜单出层(ui.locale 驱动 mt(),语言切换即热生效)
label: mt('tray.menu.status', {
status: `${statusIcons[this.currentStatus]} ${mt(`tray.status.${this.currentStatus}`)}`,
}),
enabled: false,
},
{ type: 'separator' },
{
label: '显示窗口',
label: mt('tray.menu.showWindow'),
click: () => {
if (this.mainWindow) {
this.mainWindow.show();
@@ -216,7 +221,7 @@ export class TrayManager {
},
},
{
label: '新建会话',
label: mt('tray.menu.newSession'),
click: () => {
if (this.mainWindow) {
this.mainWindow.show();
@@ -227,7 +232,7 @@ export class TrayManager {
},
{ type: 'separator' },
{
label: '退出',
label: mt('tray.menu.quit'),
click: () => {
TrayManager.isQuitting = true;
app.quit();
+16
View File
@@ -108,7 +108,13 @@ export class UpdateService {
/** AutoUpdater 句柄(IPC 层消费;null = 未启用/dev 模式/未配置 feed */
export interface AutoUpdaterHandle {
checkForUpdates: () => Promise<void>;
/** 仅下载更新包(下载完成后广播 downloaded,不自动重启) */
downloadAndInstall: () => Promise<void>;
/**
* v0.8.2 P1-3: 安装已下载的更新并重启应用。
* 仅在收到 downloaded 状态后由用户显式确认调用。
*/
installNow: () => void;
}
let activeHandle: AutoUpdaterHandle | null = null;
@@ -173,6 +179,16 @@ export async function startAutoUpdater(
try {
await autoUpdater.downloadUpdate();
onEvent({ status: 'downloaded' });
// v0.8.2 P1-3 根治: 不再下载完成立即 quitAndInstall —— 渲染层刚收到
// downloaded 事件应用就退出,用户可能丢失未保存内容(写了一半的输入、
// 进行中的会话操作)。安装动作拆分为独立的 installNow,由用户在收到
// "更新已下载"提示后显式确认触发。
} catch (err) {
onEvent({ status: 'error', message: (err as Error).message });
}
},
installNow: () => {
try {
autoUpdater.quitAndInstall();
} catch (err) {
onEvent({ status: 'error', message: (err as Error).message });
+94 -3
View File
@@ -10,9 +10,9 @@
* @see docs/MetonaAI-Desktop UI UX 设计集成方案.html — 窗口管理
*/
import { BrowserWindow, globalShortcut, shell } from 'electron';
import { BrowserWindow, globalShortcut, shell, app, screen } from 'electron';
import { join } from 'path';
import { existsSync } from 'fs';
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
import { is } from '@electron-toolkit/utils';
import log from 'electron-log';
@@ -24,6 +24,9 @@ export interface WindowState {
isMaximized?: boolean;
}
/** v0.8.2 P3-5: 窗口状态持久化文件(userData 下,机器级) */
const WINDOW_STATE_FILE = join(app.getPath('userData'), 'window-state.json');
export class WindowManager {
private windows = new Map<string, BrowserWindow>();
private activeWindowId: string | null = null;
@@ -51,7 +54,8 @@ export class WindowManager {
beforeLoad?: (win: BrowserWindow) => void;
}): BrowserWindow {
const id = options.id ?? `window_${Date.now()}`;
const state = options.state ?? { width: 1440, height: 900 };
// v0.8.2 P3-5: 未显式传 state 时自动读取持久化状态(恢复上次位置/尺寸/最大化)
const state = options.state ?? WindowManager.loadWindowState();
const win = new BrowserWindow({
width: state.width,
@@ -94,8 +98,42 @@ export class WindowManager {
win.show();
});
// v0.8.2 P3-5: 窗口状态持久化 —— move/resize 防抖 800ms 落盘 + close 时兜底
// 捕获一次(maximized 还原依赖 close 时的 isMaximized 标志)。此前状态通道
// state?: WindowState)存在但 main.ts 从未读写,每次启动固定 1440×900 居中。
let saveStateTimer: NodeJS.Timeout | null = null;
const captureState = (): WindowState => {
const bounds = win.getBounds();
return {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
isMaximized: win.isMaximized(),
};
};
const scheduleSaveState = (): void => {
if (saveStateTimer) clearTimeout(saveStateTimer);
saveStateTimer = setTimeout(() => {
saveStateTimer = null;
WindowManager.saveWindowState(captureState());
}, 800);
saveStateTimer.unref?.();
};
win.on('resize', scheduleSaveState);
win.on('move', scheduleSaveState);
win.on('close', () => {
if (saveStateTimer) {
clearTimeout(saveStateTimer);
saveStateTimer = null;
}
WindowManager.saveWindowState(captureState());
});
win.on('closed', () => {
this.windows.delete(id);
// v0.8.2 P3-5: 崩溃自愈退避记录同步清理(窗口销毁后 Map 条目残留属泄漏)
this.crashReloadAttempts.delete(win.id);
if (this.activeWindowId === id) {
this.activeWindowId =
this.windows.size > 0 ? (this.windows.keys().next().value ?? null) : null;
@@ -209,6 +247,59 @@ export class WindowManager {
return win;
}
/**
* v0.8.2 P3-5: 读取持久化的窗口状态。
* 文件缺失/损坏返回默认尺寸;恢复的坐标不在任何显示器可见范围时丢弃坐标
* (防止显示器拔除后窗口"消失")。
*/
static loadWindowState(): WindowState {
const fallback: WindowState = { width: 1440, height: 900 };
try {
if (!existsSync(WINDOW_STATE_FILE)) return fallback;
const raw = JSON.parse(readFileSync(WINDOW_STATE_FILE, 'utf-8')) as WindowState;
if (typeof raw?.width !== 'number' || typeof raw?.height !== 'number') return fallback;
if (raw.width < 400 || raw.height < 300) return fallback;
// 坐标可见性校验:x/y 必须落在某个显示器的可见区域内
if (typeof raw.x === 'number' && typeof raw.y === 'number') {
const visible = screen.getAllDisplays().some((d) => {
const { x, y, width, height } = d.bounds;
return (
raw.x! >= x - 100 && raw.x! < x + width && raw.y! >= y - 100 && raw.y! < y + height
);
});
if (!visible) {
return { width: raw.width, height: raw.height, isMaximized: raw.isMaximized };
}
} else {
delete raw.x;
delete raw.y;
}
return raw;
} catch {
return fallback;
}
}
/** v0.8.2 P3-5: 原子落盘窗口状态(tmp + rename;尽力而为,失败仅告警) */
static saveWindowState(state: WindowState): void {
try {
const tmp = `${WINDOW_STATE_FILE}.tmp`;
writeFileSync(tmp, JSON.stringify(state), 'utf-8');
try {
renameSync(tmp, WINDOW_STATE_FILE);
} catch (err) {
try {
unlinkSync(tmp);
} catch {
/* ignore */
}
throw err;
}
} catch (err) {
log.warn(`[WindowManager] Failed to persist window state: ${(err as Error).message}`);
}
}
/**
* 获取窗口
*/