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 全链路接入
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user