Files
metona-ai-desktop/electron/ipc/workspace.ts
T
thzxx 2230bcec3f feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展
P0 安全修复:
- API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容)
- 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级)
- error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计)
- abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止)
- run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化)
- .env 真实生效(dotenv 回退加载,应用内配置优先)

P1 工程基础:
- ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线)
- 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层)
- test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行)
- SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end)
- Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知)
- MCP 真就绪(等待全部连接完成再广播 tools:ready)
- SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态)
- CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移)

P2 架构升级:
- handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播)
- AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号)
- TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent
- 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染)
- 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI)
- Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入

P3 能力扩展:
- OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens)
- Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机)
- 设置页/Onboarding 六 Provider 全链路接入
2026-08-20 23:17:02 +08:00

255 lines
9.2 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.
/**
* IPC Workspace Handlers — 工作空间域(P2-9 从 handlers.ts 拆分)
*
* 工作空间校验、文件继承(SQLite backup API)、数据库完整性检查、信息查询。
*/
import { ipcMain } from 'electron';
import { join } from 'path';
import type { IPCContext } from './context';
import log from 'electron-log';
export function registerWorkspaceHandlers(ctx: IPCContext): void {
const { workspaceService } = ctx;
// 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在
ipcMain.handle('workspace:check', async (_event, targetPath: string) => {
if (!targetPath || typeof targetPath !== 'string') {
return { valid: false, reason: '路径不能为空' };
}
const { existsSync, statSync } = await import('fs');
const { resolve } = await import('path');
const resolvedPath = resolve(targetPath);
// 校验 1: 路径是否存在
if (!existsSync(resolvedPath)) {
return {
valid: true,
path: resolvedPath,
exists: false,
missingFiles: ['SOUL.md', 'MEMORY.md'],
isNewWorkspace: true,
dbExists: false,
reason: '目录不存在,将在切换后自动创建',
};
}
// 校验 2: 是否为目录
try {
const stat = statSync(resolvedPath);
if (!stat.isDirectory()) {
return { valid: false, reason: '路径不是目录' };
}
} catch {
return { valid: false, reason: '无法访问路径' };
}
// 校验 3: 检测 2 个必需文件状态(SOUL.md + MEMORY.md
const requiredFiles = ['SOUL.md', 'MEMORY.md'];
const missingFiles: string[] = [];
for (const f of requiredFiles) {
if (!existsSync(join(resolvedPath, f))) missingFiles.push(f);
}
// 校验 4: 检测 .metona/agent.db 是否存在(用于判断是否显示"继承数据库"选项)
const dbExists = existsSync(join(resolvedPath, '.metona', 'agent.db'));
return {
valid: true,
path: resolvedPath,
exists: true,
missingFiles,
isNewWorkspace: missingFiles.length === requiredFiles.length,
dbExists,
};
});
// 从旧工作空间继承文件到新工作空间
ipcMain.handle('workspace:inheritFiles', async (_event, params: {
targetPath: string;
sourcePath: string;
files: string[];
}) => {
const { targetPath, sourcePath, files } = params;
if (!targetPath || !sourcePath || !Array.isArray(files)) {
return { success: false, error: '参数无效' };
}
const { existsSync, mkdirSync, copyFileSync } = await import('fs');
const { resolve } = await import('path');
const resolvedTarget = resolve(targetPath);
const resolvedSource = resolve(sourcePath);
// 确保目标目录存在
if (!existsSync(resolvedTarget)) {
mkdirSync(resolvedTarget, { recursive: true });
}
// 继承白名单:键为前端传入的标识,值为实际相对路径分段
// 白名单映射防止路径遍历攻击(不直接使用用户传入的路径拼接到 fs 调用)
const INHERIT_WHITELIST: Record<string, string[]> = {
'SOUL.md': ['SOUL.md'],
'.metona/agent.db': ['.metona', 'agent.db'],
};
const inherited: string[] = [];
const failed: Array<{ file: string; error: string }> = [];
for (const fileName of files) {
const pathSegments = INHERIT_WHITELIST[fileName];
// 不在白名单中:拒绝(防止路径遍历)
if (!pathSegments) {
failed.push({ file: fileName, error: '不在继承白名单中' });
continue;
}
const srcFile = join(resolvedSource, ...pathSegments);
const dstFile = join(resolvedTarget, ...pathSegments);
try {
// 确保目标文件的父目录存在(如 .metona/)
const dstDir = join(resolvedTarget, ...pathSegments.slice(0, -1));
if (!existsSync(dstDir)) {
mkdirSync(dstDir, { recursive: true });
}
if (!existsSync(srcFile)) {
failed.push({ file: fileName, error: '源文件不存在' });
continue;
}
// === 数据库文件特殊处理 ===
// agent.db 启用了 WAL 模式,直接 copyFileSync 会丢失 WAL 中未 checkpoint 的事务;
// 用 better-sqlite3 的 backup API(自带 checkpoint + 一致性快照)
if (fileName === '.metona/agent.db') {
try {
const Database = (await import('better-sqlite3')).default;
const srcDb = new Database(srcFile, { readonly: true, fileMustExist: true });
try {
srcDb.backup(dstFile);
inherited.push(fileName);
log.info(`[WORKSPACE] Inherited ${fileName} (via SQLite backup): ${resolvedSource}${resolvedTarget}`);
} finally {
srcDb.close();
}
} catch (err) {
// backup 失败时回退到 copyFileSync(至少保证基本可用)
log.warn(`[WORKSPACE] SQLite backup failed, fallback to copyFileSync: ${(err as Error).message}`);
try {
copyFileSync(srcFile, dstFile);
inherited.push(fileName);
log.info(`[WORKSPACE] Inherited ${fileName} (fallback copyFileSync): ${resolvedSource}${resolvedTarget}`);
} catch (err2) {
failed.push({ file: fileName, error: `backup 和 fallback 均失败: ${(err2 as Error).message}` });
}
}
continue;
}
// === 普通文件直接复制 ===
copyFileSync(srcFile, dstFile);
inherited.push(fileName);
log.info(`[WORKSPACE] Inherited ${fileName}: ${resolvedSource}${resolvedTarget}`);
} catch (err) {
failed.push({ file: fileName, error: (err as Error).message });
}
}
return { success: true, inherited, failed };
});
// #51 修复: 校验源数据库完整性,防止继承损坏的数据库导致新工作空间数据丢失
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');
const workspacePath = workspaceService.getPath();
const files = workspaceService.reload(); // 同步外部可能的手动修改
const REQUIRED = ['SOUL.md', 'MEMORY.md'] as const;
const AUTO_DIRS = ['logs', '.metona'] as const;
const fileInfos = REQUIRED.map((name) => {
const filePath = join(workspacePath, name);
let exists = false;
let size = 0;
let mtime = 0;
let preview = '';
try {
if (existsSync(filePath)) {
const stat = statSync(filePath);
exists = true;
size = stat.size;
mtime = stat.mtimeMs;
// 截取前 500 字符作为预览
const content = readFileSync(filePath, 'utf-8');
preview = content.length > 500 ? content.slice(0, 500) + '\n...(已截断)' : content;
}
} catch {
// ignore
}
// 文件内容映射:SOUL.md → files.soul, MEMORY.md → files.memory
const contentKey = name.toLowerCase().replace('.md', '') as 'soul' | 'memory';
return {
name,
path: filePath,
exists,
size,
mtime,
preview: exists ? preview : (files[contentKey] ?? ''),
};
});
const dirInfos = AUTO_DIRS.map((name) => {
const dirPath = join(workspacePath, name);
let exists = false;
let fileCount = 0;
try {
if (existsSync(dirPath)) {
exists = true;
const stat = statSync(dirPath);
if (stat.isDirectory()) {
fileCount = readdirSync(dirPath).length;
}
}
} catch {
// ignore
}
return { name, path: dirPath, exists, fileCount };
});
return {
path: workspacePath,
files: fileInfos,
dirs: dirInfos,
};
});
}