安全修复: - 开启 webSecurity(CORS 改为 webRequest 允许清单精确放行 Ollama 地址) - 新增 net-guard SSRF 防护:web_fetch/download_file/browser_open 拦截环回/内网/链路本地地址(DNS 解析后校验) - browser_open 协议白名单(仅 http/https,阻止 file:// 绕过路径安全层) - git 参数注入防护(用户可控参数禁止 - 开头;git add 强制 -- 分隔) - 身份文件保护:SOUL.md/AGENT.md/USER.md 工具只读(防提示注入持久化劫持) - 系统目录硬红线 + 工作空间/白名单不可豁免系统目录 - spawn_task 权限只降不升(封顶于用户设置 subAgentMaxPermission) - 子代理写类工具接入主 Agent 确认管线 + 完整路径沙箱 - toast 改 textContent、HTML 导出 escapeHtml(XSS 修复) - Agent 浏览器改用 memory: 内存分区(退出清空 cookie/storage) 数据层重构: - sql.js 写入改防抖批量落盘(300ms 合并快照 + temp 原子替换 + 退出刷盘) - Schema 迁移改 PRAGMA user_version 顺序迁移数组 - 消息/设置/轨迹批量写(单事务);SearXNG 配置 13 次写合并为 1 次 - 会话摘要查询(getSessionSummaries/searchSessions 单条 SQL)消除 N+1 - 导出改 getAllSessionsData 一次 IPC 取回全部行 Bug 修复: - edit_file 替换符污染($&/$1 被特殊解释导致文件写坏) - truncateToolResult 暴力截断拼接非法 JSON 必然崩溃 - diff 算法 100MB dp 数组 → 前缀/后缀裁剪 + LCS 限额 + 回退 - move_file 跨盘 rename 失败回退 copy+delete - Ctrl+K 快捷键冲突(双注册);全局错误处理器双注册 - ffmpeg stderr 无限累积 + 帧进度 O(n²) 正则 - 搜索可达性预检只取响应头(Range: bytes=0-0) - 备份导出逐字节 base64 拼接(O(n²))改 FileReader - MCP clientInfo 版本硬编码 5.0.0 改真实版本;tools/list 支持 nextCursor 分页 - 看门狗默认值统一为 30 分钟;download_file 超时跟随用户配置 架构改进: - 主进程工具分发注册表 tool-dispatch.ts(消除 switch 硬编码) - agent-engine 拆分 result-formatter.ts / tool-parsing.ts(纯函数) - 文本兜底解析白名单改从注册表派生(补齐 browser_*/diff/spawn_task/mcp_*) - diff 工具默认启用;MODE_TOOLS 单一事实来源(tools-modal 复用) - 记忆系统:条目缓存 + 访问统计(hits/last)持久化 + removeById 按 ID 删除 - 度量历史启动恢复 + Metrics 仪表盘接入 JSON/Prometheus 导出 - 子代理模型下拉框打开设置时刷新(此前从未填充) 死代码清理(约 1400 行): - 删除 context-indexer 整模块、agent-safety 震荡检测/性能报告/依赖图/记忆调优/归档取回 - 删除 context-manager 水印/跳过压缩/自适应窗口/趋势分析/预算分配等未接线函数 - 删除 sanitizeToolArgs(污染 write_file 内容,防注入职责移交主进程安全层) - infra-service 裁剪为全局错误处理器唯一定义 文档对齐: - 新增内置 AGENT.md(工作空间同名文件可覆盖) - README/帮助面板/DEVELOPMENT 移除失实描述(WAL/内部URL拦截/5层防御/并行白名单/Hook 数量) - 工具数量口径统一 33;安全机制表新增 SSRF/身份保护/子代理权限等 9 项 工程化: - Vitest + 34 个单元测试(myers-diff/calculator/net-guard/MEMORY.md 格式) - Gitea Actions CI(typecheck + test + build) - package.json 新增 typecheck/test 脚本
This commit is contained in:
@@ -1,303 +1,30 @@
|
||||
/**
|
||||
* Infrastructure Service - 基础设施服务 (R41-R50)
|
||||
* 内存泄漏防护、全局错误处理、性能监控、配置验证、健康检查
|
||||
* Infrastructure Service - 基础设施服务
|
||||
* 全局错误边界:统一捕获未处理异常(渲染进程唯一定义,避免重复弹窗)
|
||||
*/
|
||||
|
||||
import { logInfo, logWarn, logError } from './log-service.js';
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// R41: 内存泄漏防护 — 事件监听器管理
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** R41: 已注册的事件监听器追踪表 */
|
||||
const _trackedListeners = new Map<string, { target: EventTarget; type: string; listener: EventListenerOrEventListenerObject; options?: boolean | AddEventListenerOptions }>();
|
||||
|
||||
let _listenerIdCounter = 0;
|
||||
import { logError, logInfo } from './log-service.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
|
||||
/**
|
||||
* R41: 注册并追踪事件监听器,便于统一清理
|
||||
* @returns 监听器 ID,可用于单独移除
|
||||
* 初始化全局错误处理(应用启动时调用一次)
|
||||
* - 未捕获 JS 错误 / Promise rejection → 日志 + Toast
|
||||
* - 阻止默认行为(避免原生错误对话框)
|
||||
*/
|
||||
export function trackEventListener(
|
||||
target: EventTarget,
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): string {
|
||||
const id = `listener_${++_listenerIdCounter}`;
|
||||
_trackedListeners.set(id, { target, type, listener, options });
|
||||
target.addEventListener(type, listener, options);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** R41: 移除单个事件监听器 */
|
||||
export function removeTrackedListener(id: string): void {
|
||||
const entry = _trackedListeners.get(id);
|
||||
if (entry) {
|
||||
entry.target.removeEventListener(entry.type, entry.listener, entry.options);
|
||||
_trackedListeners.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** R41: 清理所有已追踪的事件监听器(用于页面卸载或会话切换时) */
|
||||
export function cleanupAllListeners(): void {
|
||||
let count = 0;
|
||||
for (const [id, entry] of _trackedListeners) {
|
||||
try {
|
||||
entry.target.removeEventListener(entry.type, entry.listener, entry.options);
|
||||
count++;
|
||||
} catch { /* ignore */ }
|
||||
_trackedListeners.delete(id);
|
||||
}
|
||||
if (count > 0) {
|
||||
logInfo(`R41: 已清理 ${count} 个事件监听器`);
|
||||
}
|
||||
}
|
||||
|
||||
/** R41: 获取当前追踪的监听器数量(供调试用) */
|
||||
export function getTrackedListenerCount(): number {
|
||||
return _trackedListeners.size;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// R42: 全局错误边界 — 捕获未处理的异常
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** R42: 初始化全局错误处理 */
|
||||
export function initGlobalErrorHandler(): void {
|
||||
// 捕获未处理的 JS 错误
|
||||
window.addEventListener('error', (e) => {
|
||||
logError('R42: 未捕获错误', `${e.message} @ ${e.filename}:${e.lineno}:${e.colno}`);
|
||||
// 阻止默认的错误处理(避免弹出丑陋的错误对话框)
|
||||
logError('未捕获错误', `${e.message} @ ${e.filename}:${e.lineno}:${e.colno}`);
|
||||
showToast(`发生错误: ${e.message}`, 'error', 5000);
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// 捕获未处理的 Promise rejection
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const reason = e.reason;
|
||||
const msg = reason instanceof Error ? reason.message : String(reason);
|
||||
logError('R42: 未处理的 Promise Rejection', msg);
|
||||
logError('未处理的 Promise Rejection', msg);
|
||||
showToast(`操作失败: ${msg}`, 'error', 5000);
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
logInfo('R42: 全局错误处理器已初始化');
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// R43: 性能监控 — 关键操作耗时追踪
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
export interface PerfMetric {
|
||||
name: string;
|
||||
duration: number;
|
||||
timestamp: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const _perfMetrics: PerfMetric[] = [];
|
||||
const MAX_PERF_METRICS = 200;
|
||||
const _perfTimers = new Map<string, number>();
|
||||
|
||||
/** R43: 开始性能计时 */
|
||||
export function perfStart(name: string): void {
|
||||
_perfTimers.set(name, performance.now());
|
||||
}
|
||||
|
||||
/** R43: 结束性能计时并记录 */
|
||||
export function perfEnd(name: string, metadata?: Record<string, unknown>): number {
|
||||
const startTime = _perfTimers.get(name);
|
||||
if (startTime === undefined) {
|
||||
logWarn(`R43: perfEnd 未找到对应的 perfStart: ${name}`);
|
||||
return 0;
|
||||
}
|
||||
const duration = performance.now() - startTime;
|
||||
_perfTimers.delete(name);
|
||||
|
||||
_perfMetrics.push({ name, duration, timestamp: Date.now(), metadata });
|
||||
|
||||
// 超过上限时移除最早的
|
||||
if (_perfMetrics.length > MAX_PERF_METRICS) {
|
||||
_perfMetrics.shift();
|
||||
}
|
||||
|
||||
// 慢操作警告(超过 1 秒)
|
||||
if (duration > 1000) {
|
||||
logWarn(`R43: 慢操作: ${name} 耗时 ${duration.toFixed(0)}ms`);
|
||||
}
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
/** R43: 获取性能指标 */
|
||||
export function getPerfMetrics(): PerfMetric[] {
|
||||
return [..._perfMetrics];
|
||||
}
|
||||
|
||||
/** R43: 获取平均性能指标 */
|
||||
export function getAvgPerfMetric(name: string): number {
|
||||
const metrics = _perfMetrics.filter(m => m.name === name);
|
||||
if (metrics.length === 0) return 0;
|
||||
return metrics.reduce((sum, m) => sum + m.duration, 0) / metrics.length;
|
||||
}
|
||||
|
||||
/** R43: 清空性能指标 */
|
||||
export function clearPerfMetrics(): void {
|
||||
_perfMetrics.length = 0;
|
||||
_perfTimers.clear();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// R48: 配置验证 — 启动时验证关键配置
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
export interface ConfigValidationResult {
|
||||
valid: boolean;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** R48: 验证应用配置 */
|
||||
export function validateConfig(config: Record<string, unknown>): ConfigValidationResult {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// 验证 numCtx
|
||||
const numCtx = config.numCtx as number;
|
||||
if (numCtx !== undefined) {
|
||||
if (numCtx < 2048) {
|
||||
warnings.push(`numCtx=${numCtx} 过小,可能导致上下文截断。建议至少 4096。`);
|
||||
}
|
||||
if (numCtx > 131072) {
|
||||
warnings.push(`numCtx=${numCtx} 过大,可能导致内存不足。建议不超过 131072。`);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 temperature
|
||||
const temperature = config.temperature as number;
|
||||
if (temperature !== undefined) {
|
||||
if (temperature < 0 || temperature > 2) {
|
||||
errors.push(`temperature=${temperature} 超出有效范围 [0, 2]`);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 maxTurns
|
||||
const maxTurns = config.maxTurns as number;
|
||||
if (maxTurns !== undefined) {
|
||||
if (maxTurns < 1) {
|
||||
errors.push(`maxTurns=${maxTurns} 不能小于 1`);
|
||||
}
|
||||
if (maxTurns > 50) {
|
||||
warnings.push(`maxTurns=${maxTurns} 过大,可能导致长时间运行。建议不超过 20。`);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 streamTimeout
|
||||
const streamTimeout = config.streamTimeout as number;
|
||||
if (streamTimeout !== undefined) {
|
||||
if (streamTimeout < 10000) {
|
||||
warnings.push(`streamTimeout=${streamTimeout} 过短,可能导致大模型生成被中断。建议至少 30000ms。`);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 subAgentTimeout
|
||||
const subAgentTimeout = config.subAgentTimeout as number;
|
||||
if (subAgentTimeout !== undefined && subAgentTimeout < 5000) {
|
||||
warnings.push(`subAgentTimeout=${subAgentTimeout} 过短,子代理可能无法完成任务。`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// R50: 健康检查 — 系统健康监控
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
export interface HealthCheckResult {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
checks: Array<{ name: string; status: 'pass' | 'fail' | 'warn'; message: string }>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** R50: 执行系统健康检查 */
|
||||
export async function runHealthCheck(): Promise<HealthCheckResult> {
|
||||
const checks: Array<{ name: string; status: 'pass' | 'fail' | 'warn'; message: string }> = [];
|
||||
|
||||
// 检查 1: 桌面 API 可用性
|
||||
const bridge = window.metonaDesktop;
|
||||
if (bridge?.isDesktop) {
|
||||
checks.push({ name: '桌面 API', status: 'pass', message: '桌面 API 可用' });
|
||||
} else {
|
||||
checks.push({ name: '桌面 API', status: 'fail', message: '桌面 API 不可用(Web 模式)' });
|
||||
}
|
||||
|
||||
// 检查 2: 数据库可用性
|
||||
if (bridge?.db) {
|
||||
try {
|
||||
const sessions = await bridge.db.getAllSessions();
|
||||
checks.push({ name: '数据库', status: 'pass', message: `数据库正常(${sessions.length} 个会话)` });
|
||||
} catch (err) {
|
||||
checks.push({ name: '数据库', status: 'fail', message: `数据库访问失败: ${(err as Error).message}` });
|
||||
}
|
||||
} else {
|
||||
checks.push({ name: '数据库', status: 'warn', message: '数据库 API 不可用' });
|
||||
}
|
||||
|
||||
// 检查 3: 内存使用
|
||||
const memInfo = (performance as any).memory;
|
||||
if (memInfo) {
|
||||
const usedMB = (memInfo.usedJSHeapSize / 1024 / 1024).toFixed(0);
|
||||
const limitMB = (memInfo.jsHeapSizeLimit / 1024 / 1024).toFixed(0);
|
||||
const usageRatio = memInfo.usedJSHeapSize / memInfo.jsHeapSizeLimit;
|
||||
if (usageRatio > 0.8) {
|
||||
checks.push({ name: '内存', status: 'warn', message: `内存使用较高: ${usedMB}/${limitMB}MB (${(usageRatio * 100).toFixed(0)}%)` });
|
||||
} else {
|
||||
checks.push({ name: '内存', status: 'pass', message: `内存使用正常: ${usedMB}/${limitMB}MB` });
|
||||
}
|
||||
} else {
|
||||
checks.push({ name: '内存', status: 'pass', message: '内存监控不可用(非 Chromium)' });
|
||||
}
|
||||
|
||||
// 检查 4: 工作空间可用性
|
||||
if (bridge?.workspace) {
|
||||
try {
|
||||
const result = await bridge.workspace.getDir();
|
||||
if (result.dir) {
|
||||
checks.push({ name: '工作空间', status: 'pass', message: `工作空间: ${result.dir}` });
|
||||
} else {
|
||||
checks.push({ name: '工作空间', status: 'warn', message: '工作空间未设置' });
|
||||
}
|
||||
} catch {
|
||||
checks.push({ name: '工作空间', status: 'warn', message: '工作空间访问失败' });
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 5: 事件监听器数量(内存泄漏检测)
|
||||
const listenerCount = _trackedListeners.size;
|
||||
if (listenerCount > 100) {
|
||||
checks.push({ name: '事件监听器', status: 'warn', message: `追踪的事件监听器较多: ${listenerCount} 个,可能存在内存泄漏` });
|
||||
} else {
|
||||
checks.push({ name: '事件监听器', status: 'pass', message: `事件监听器数量正常: ${listenerCount} 个` });
|
||||
}
|
||||
|
||||
// 确定整体状态
|
||||
const hasFail = checks.some(c => c.status === 'fail');
|
||||
const hasWarn = checks.some(c => c.status === 'warn');
|
||||
const status: 'healthy' | 'degraded' | 'unhealthy' = hasFail ? 'unhealthy' : hasWarn ? 'degraded' : 'healthy';
|
||||
|
||||
return { status, checks, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
/** R50: 格式化健康检查结果为可读字符串 */
|
||||
export function formatHealthCheck(result: HealthCheckResult): string {
|
||||
const statusIcon = result.status === 'healthy' ? '✅' : result.status === 'degraded' ? '⚠️' : '❌';
|
||||
const lines = [`${statusIcon} 系统健康检查 — ${result.status.toUpperCase()}`, ''];
|
||||
for (const check of result.checks) {
|
||||
const icon = check.status === 'pass' ? '✅' : check.status === 'warn' ? '⚠️' : '❌';
|
||||
lines.push(`${icon} ${check.name}: ${check.message}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
logInfo('全局错误处理器已初始化');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user