Files
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

330 lines
13 KiB
TypeScript
Raw Permalink 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 App Handlers — 应用工具域(P2-9 从 handlers.ts 拆分)
*
* 版本/路径查询、外部链接、文件夹选择、重启、SearXNG 连接测试、
* 审计日志链验证/查询、渲染进程错误上报(P0-3 修复断链)。
*/
import { ipcMain, shell, app, dialog, BrowserWindow } from 'electron';
import type { IPCContext } from './context';
import type { AuditEventType } from '../services/audit.service';
import { UpdateService } from '../services/update.service';
import { assertSafeConfigTarget } from '../harness/tools/built-in/ssrf-guard';
// v0.8.2 P2-5: 用户可见文案出层(ui.locale 驱动)
import { mt } from '../utils/main-locale';
import log from 'electron-log';
export function registerAppHandlers(ctx: IPCContext): void {
const { mainWindow, auditService } = ctx;
ipcMain.handle('app:getVersion', async () => {
return app.getVersion();
});
ipcMain.handle('app:getAppDataPath', async () => {
return app.getPath('userData');
});
// v0.6.4 P4-2: 手动检查更新(feed 比对式 —— electron-updater 接入时仅替换实现)
ipcMain.handle('app:updateCheck', async () => {
const feedUrl = ctx.configService.get<string>('app.updateFeedUrl') ?? '';
const service = new UpdateService(
() => app.getVersion(),
() => (feedUrl ? feedUrl : null),
);
const result = await service.check();
log.info('[Update] check result:', JSON.stringify(result));
return result;
});
// ===== v0.8.0 P2-3: 下载并安装更新(electron-updater;未启用时明确失败) =====
// v0.8.2 P1-3: 语义拆分 —— updateInstall 仅下载(完成后广播 downloaded
// 不再自动重启退出);重启安装由用户确认后经 app:updateInstallNow 触发
ipcMain.handle('app:updateInstall', async () => {
const { getAutoUpdaterHandle } = await import('../services/update.service');
const handle = getAutoUpdaterHandle();
if (!handle) {
return { success: false, error: mt('app.update.disabled') };
}
// fire-and-forget:进度经 update:status 广播,完成后等待用户确认安装
void handle.downloadAndInstall();
return { success: true };
});
// ===== v0.8.2 P1-3: 安装已下载的更新并重启(用户显式确认后调用) =====
ipcMain.handle('app:updateInstallNow', async () => {
const { getAutoUpdaterHandle } = await import('../services/update.service');
const handle = getAutoUpdaterHandle();
if (!handle) {
return { success: false, error: mt('app.update.disabled') };
}
handle.installNow();
return { success: true };
});
// ===== v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告)=====
ipcMain.handle('app:healthSnapshot', async () => {
try {
return { success: true, data: ctx.getHealthSnapshot() };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// ===== v0.7.3 P3-3: JSONL 录制文件统计与清理(设置页展示 + 手动清理)=====
ipcMain.handle('logs:traceStats', async () => {
try {
return { success: true, data: ctx.sessionRecorder.getRecordingStats() };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('logs:pruneTraceFiles', async () => {
try {
const deleted = ctx.sessionRecorder.pruneOldRecordings();
log.info(`[LOGS] Manual trace prune: ${deleted} file(s) removed`);
return { success: true, data: { deleted } };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('app:openExternal', async (_event, url: unknown) => {
// M-12 修复: URL 协议白名单校验,防止打开 file:///smb:// 等危险协议
if (typeof url !== 'string' || !url) {
return { success: false, error: 'Invalid URL' };
}
try {
const parsed = new URL(url);
const ALLOWED_PROTOCOLS = ['http:', 'https:', 'mailto:'];
if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
log.warn(`[IPC] openExternal blocked: protocol "${parsed.protocol}" not in whitelist`);
return { success: false, error: `Protocol not allowed: ${parsed.protocol}` };
}
await shell.openExternal(url);
return { success: true };
} catch (error) {
log.warn('[IPC] openExternal failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('app:showItemInFolder', async (_event, path: unknown) => {
// M-36 修复: path 类型校验
if (typeof path !== 'string' || !path) {
return { success: false, error: 'Invalid path' };
}
try {
shell.showItemInFolder(path);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('app:selectFolder', async (event, defaultPath?: string) => {
// F-4 补充: 从 event.sender 动态解析窗口 —— ctx.mainWindow 是注册时捕获的引用,
// macOS activate 重建窗口后为已销毁实例(dialog 挂 destroyed 窗口行为未定义)。
// fromWebContents 对任何存活窗口(含未来多窗口)都正确,解析失败回退无父对话框。
const callerWin = BrowserWindow.fromWebContents(event.sender) ?? mainWindow;
const result = await dialog.showOpenDialog(callerWin, {
properties: ['openDirectory', 'createDirectory'],
defaultPath: defaultPath ?? app.getPath('home'),
title: mt('app.dialog.selectWorkspace.title'),
});
if (result.canceled || result.filePaths.length === 0) return { canceled: true, path: '' };
return { canceled: false, path: result.filePaths[0] };
});
// ===== v0.8.1 P2-4: 开机自启(app.setLoginItemSettings,跨平台尽力语义)=====
ipcMain.handle('app:setLoginItem', async (_event, enabled: unknown) => {
if (typeof enabled !== 'boolean') {
return { success: false, error: 'Invalid enabled flag' };
}
try {
app.setLoginItemSettings({ openAtLogin: enabled });
// 设置后回读真实状态(Linux 等 setLoginItemSettings 不可用平台保持 false
const actual = app.getLoginItemSettings().openAtLogin;
if (enabled && !actual) {
log.warn('[APP] setLoginItemSettings not effective on this platform (Linux?)');
}
return { success: true, data: { openAtLogin: actual } };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('app:getLoginItem', async () => {
try {
return { success: true, data: { openAtLogin: app.getLoginItemSettings().openAtLogin } };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 重启应用(工作空间切换后调用)
ipcMain.handle('app:restart', async () => {
log.info('[APP] Restart requested');
// 延迟 200ms 让 IPC 响应先返回
setTimeout(() => {
app.relaunch();
app.exit(0);
}, 200);
return { success: true };
});
// ===== P0-3 修复: 渲染进程错误上报通道(原 preload 发送端存在但主进程无 handler,上报被静默丢弃) =====
ipcMain.on('error:report', (_event, payload: unknown) => {
try {
if (!payload || typeof payload !== 'object') return;
const p = payload as Record<string, unknown>;
// 截断超长字段(stack 可达数十 KB),防止日志膨胀
const truncateStr = (v: unknown, max = 4000): unknown =>
typeof v === 'string' ? v.slice(0, max) : v;
const details = {
type: truncateStr(p.type, 100),
error: truncateStr(p.error, 1000),
stack: truncateStr(p.stack),
componentStack: truncateStr(p.componentStack, 4000),
timestamp: p.timestamp,
};
log.error('[Renderer Error]', JSON.stringify(details));
// 同步写入审计日志(TOOL 层),便于事后追溯
auditService.log({
sessionId: '',
eventType: 'error',
actor: 'user',
target: 'renderer',
details,
outcome: 'error',
});
} catch (err) {
// 错误上报自身失败仅记录,不抛出(单向通道无响应方)
log.warn('[IPC] error:report handler failed:', err);
}
});
// ===== SearXNG 连接测试 =====
ipcMain.handle(
'searxng:testConnection',
async (_event, url: string, authKey: string, authType: string) => {
try {
if (!url || !/^https?:\/\//.test(url)) {
return { success: false, error: 'URL 需以 http:// 或 https:// 开头' };
}
// v0.7.4 P2-9: 高危目标校验(云元数据/链路本地/组播/保留段拦截;
// 本地/私网 SearXNG 实例放行——自建实例常见于局域网)
assertSafeConfigTarget(url);
const startTime = Date.now();
const headers: Record<string, string> = {};
if (authKey) {
if (authType === 'bearer') {
headers['Authorization'] = `Bearer ${authKey}`;
} else if (authType === 'basic') {
headers['Authorization'] = `Basic ${Buffer.from(authKey).toString('base64')}`;
}
}
const testUrl = `${url.replace(/\/$/, '')}/search?q=test&format=json&pageno=1`;
const response = await fetch(testUrl, {
headers,
signal: AbortSignal.timeout(10_000),
});
const latencyMs = Date.now() - startTime;
if (response.ok) {
return { success: true, statusCode: response.status, latencyMs };
}
return {
success: false,
statusCode: response.status,
latencyMs,
error: `HTTP ${response.status} ${response.statusText}`,
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
},
);
// ===== v0.2.0: 审计日志链式哈希验证 =====
ipcMain.handle('audit:verifyChain', async () => {
try {
const result = auditService.verifyChain();
log.info(
`[AUDIT] Chain verification: ${result.valid ? 'valid' : 'TAMPERED'} (${result.verifiedRecords}/${result.totalRecords})`,
);
return { success: true, ...result };
} catch (error) {
log.error('[AUDIT] Chain verification failed:', error);
return { success: false, error: (error as Error).message };
}
});
// v0.5.0: 审计日志导出(JSONL / CSV 两种格式)
ipcMain.handle('audit:export', async (_event, format: unknown) => {
const fmt = format === 'csv' ? 'csv' : 'jsonl';
try {
const content = fmt === 'csv' ? auditService.exportCSV() : auditService.exportJSONL();
const recordCount = content.trim()
? fmt === 'csv'
? content.trim().split('\n').length - 1
: content.trim().split('\n').length
: 0;
log.info(`[AUDIT] Exported ${recordCount} records as ${fmt.toUpperCase()}`);
return { success: true, data: content, format: fmt, recordCount };
} catch (error) {
log.error('[AUDIT] Export failed:', error);
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('audit:query', async (_event, filters?: unknown) => {
// M-45 修复: 校验 filters 参数类型和范围
const VALID_AUDIT_EVENT_TYPES: readonly AuditEventType[] = [
'tool_call',
'permission_check',
'error',
'llm_request',
'llm_response',
'session_start',
'session_end',
'config_change',
];
const safeFilters: { sessionId?: string; eventType?: AuditEventType; limit?: number } = {};
if (filters && typeof filters === 'object') {
const f = filters as Record<string, unknown>;
if (typeof f.sessionId === 'string' && f.sessionId) safeFilters.sessionId = f.sessionId;
if (typeof f.eventType === 'string' && f.eventType) {
// 校验 eventType 是否在合法枚举内
if (!(VALID_AUDIT_EVENT_TYPES as readonly string[]).includes(f.eventType)) {
return {
success: false,
error: `Invalid eventType (must be one of: ${VALID_AUDIT_EVENT_TYPES.join(', ')})`,
};
}
safeFilters.eventType = f.eventType as AuditEventType;
}
if (f.limit !== undefined) {
const limit = Number(f.limit);
// 限制 1-1000 范围,防止过大查询拖慢性能
if (!Number.isFinite(limit) || limit < 1 || limit > 1000) {
return { success: false, error: 'Invalid limit (must be 1-1000)' };
}
safeFilters.limit = Math.floor(limit);
}
}
try {
return { success: true, data: auditService.query(safeFilters) };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
}