Files
metona-ai-desktop/electron/services/global-config.service.ts
T
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

324 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Global Config Service — 全局配置层(跨工作空间共享)
*
* 设计原因:
* 原实现中所有配置存在工作空间 DB{workspacePath}/.metona/agent.db)的 app_config 表里。
* 切换到新工作空间时,新 DB 被 seedDefaults 灌入空值,导致 LLM 凭据、onboarding.completed
* 等本应"机器级"的配置全部丢失,用户被迫重新配置。
*
* 方案:
* 将"机器级全局配置"抽到 userData/global-config.json,与工作空间解耦。
* 工作空间 DB 仍保留会话/消息/记忆/审计等"工作空间级数据"。
*
* 全局 key 规则:
* - llm.* / agent.* / security.* / ui.* / logging.* / memory.*
* - onboarding.completed
* v0.8.1: 分 Provider contextWindow 键与 ollama.numCtx 废除 —— 相应前缀从
* 全局判定清单移除,存量废键由 initialize 清理,与工作空间 DB 迁移 12 对齐)
* 写入时同时写工作空间 DB(兼容旧逻辑)和全局 JSON;
* 读取时优先工作空间 DB,miss 时回退到全局 JSON。
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 配置存储
*/
import { app } from 'electron';
import { join } from 'path';
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 {
decryptConfigValue,
encryptConfigValue,
isSensitiveConfigKey,
} from '../utils/secure-config';
/** 全局配置文件路径(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 = [
'llm.',
'agent.',
'security.',
'ui.',
'logging.',
'onboarding.',
// v0.7.3 P1-5: 记忆固化节流(机器级策略,跨工作空间一致)
'memory.',
];
/** 判断 key 是否属于全局配置 */
export function isGlobalKey(key: string): boolean {
return GLOBAL_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
}
/**
* seedDefaults 灌入的默认值映射表(用于判断"DB 值等于默认值时回退全局层")
*
* P1-13: 由 database.service.ts 的 CONFIG_DEFAULTS 单一来源派生,
* 不再手工维护副本(原双源曾出现漂移风险)。
*/
export const SEED_DEFAULTS: Record<string, unknown> = Object.fromEntries(
CONFIG_DEFAULTS.map((d) => [d.key, d.value]),
);
/**
* 判断全局 key 在工作空间 DB 中的值是否为"未配置"(应回退到全局层)
*
* 判断规则:
* - 值为空字符串/null/undefined → 未配置
* - 值等于 seedDefaults 默认值 → 未配置(新工作空间 DB 被灌入的默认值)
* - 其他 → 已配置(用户主动设置过,不回退)
*/
export function isUnconfiguredGlobalKey(key: string, value: unknown): boolean {
// 空值一律视为未配置
if (value === '' || value === null || value === undefined) {
return true;
}
// 值等于 seedDefaults 默认值 → 视为未配置(被 seedDefaults 灌入的)
const defaultValue = SEED_DEFAULTS[key];
if (defaultValue !== undefined && value === defaultValue) {
return true;
}
return false;
}
interface GlobalConfigData {
/** key → value(已 JSON.parse */
[key: string]: unknown;
}
export class GlobalConfigService {
private data: GlobalConfigData = {};
private initialized = false;
/**
* 初始化:读取全局配置文件
* 若文件不存在则创建空文件(首次启动)
*/
initialize(): void {
if (this.initialized) {
log.warn('[GlobalConfig] Already initialized');
return;
}
try {
if (existsSync(GLOBAL_CONFIG_FILE)) {
const raw = readFileSync(GLOBAL_CONFIG_FILE, 'utf-8');
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;
for (const key of DEPRECATED_CONFIG_KEYS) {
if (key in this.data) {
delete this.data[key];
purged++;
}
}
if (purged > 0) {
this.flush();
log.info(`[GlobalConfig] Purged ${purged} deprecated config key(s)`);
}
log.info(
`[GlobalConfig] Loaded ${Object.keys(this.data).length} keys from ${GLOBAL_CONFIG_FILE}`,
);
} else {
// 确保父目录存在
const dir = join(GLOBAL_CONFIG_FILE, '..');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
this.data = {};
this.flush();
log.info(`[GlobalConfig] Created new global config file: ${GLOBAL_CONFIG_FILE}`);
}
} catch (err) {
log.error('[GlobalConfig] Failed to load, starting with empty config:', err);
this.data = {};
}
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 自动解密)
*/
get<T = unknown>(key: string): T | null {
if (!this.initialized) {
log.warn('[GlobalConfig] get() called before initialize()');
return null;
}
if (key in this.data) {
const raw = this.data[key];
return (isSensitiveConfigKey(key) ? decryptConfigValue(raw) : raw) as T;
}
return null;
}
/**
* 写入全局配置(内存 + 立即落盘;P0-1: 敏感 key 加密后存储)
*/
set(key: string, value: unknown): void {
if (!this.initialized) {
log.warn('[GlobalConfig] set() called before initialize()');
return;
}
this.data[key] = isSensitiveConfigKey(key) ? encryptConfigValue(value) : value;
this.flush();
log.debug(`[GlobalConfig] set: ${key}`);
}
/**
* 批量写入(仅落盘一次,避免多次 IO;敏感 key 加密后存储)
*/
setBatch(entries: Array<{ key: string; value: unknown }>): void {
if (!this.initialized) {
log.warn('[GlobalConfig] setBatch() called before initialize()');
return;
}
for (const { key, value } of entries) {
this.data[key] = isSensitiveConfigKey(key) ? encryptConfigValue(value) : value;
}
this.flush();
log.debug(`[GlobalConfig] setBatch: ${entries.length} keys`);
}
/**
* 获取所有全局配置(用于迁移和调试;敏感 key 解密后返回)
*/
getAll(): GlobalConfigData {
const out: GlobalConfigData = {};
for (const [key, value] of Object.entries(this.data)) {
out[key] = isSensitiveConfigKey(key) ? decryptConfigValue(value) : value;
}
return out;
}
/**
* 从工作空间 DB 迁移配置到全局层(首次启用全局配置层时调用)
*
* 迁移规则:
* - 仅迁移 isGlobalKey 匹配的 key
* - 仅当全局层该 key 不存在时才写入(不覆盖用户已保存的全局配置)
* - 仅迁移"已配置"的值(空值/seedDefaults 默认值不迁移)
* - false 不一律跳过:若 seedDefaults 默认值不是 false,则 false 是用户主动设置,应迁移
*
* @param workspaceConfig 工作空间 DB 的全部 app_config
*/
migrateFromWorkspaceDB(workspaceConfig: Record<string, unknown>): number {
if (!this.initialized) {
log.warn('[GlobalConfig] migrateFromWorkspaceDB() called before initialize()');
return 0;
}
let migrated = 0;
for (const [key, value] of Object.entries(workspaceConfig)) {
if (!isGlobalKey(key)) continue;
// 全局层已有该 key,不覆盖
if (key in this.data) continue;
// 空值不迁移(seedDefaults 灌入的空值)
if (value === '' || value === null || value === undefined) continue;
// 值等于 seedDefaults 默认值不迁移(避免把默认值同步到全局层)
// 注意:isUnconfiguredGlobalKey 已包含"值等于默认值"判断,此处复用
if (isUnconfiguredGlobalKey(key, value)) continue;
this.data[key] = isSensitiveConfigKey(key) ? encryptConfigValue(value) : value;
migrated++;
}
if (migrated > 0) {
this.flush();
log.info(`[GlobalConfig] Migrated ${migrated} keys from workspace DB to global config`);
}
return migrated;
}
/**
* 落盘到 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(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}`);
}
}
}