Files
metona-ai-desktop/electron/harness/tools/built-in/browser-window-manager.ts
T
thzxx 025f00171b feat: 升级至 v0.3.0 — 安全增强、死循环检测、六轮全面审计修复
大版本迭代,新增安全增强、Agent Loop 增强、UI/UX 增强,经六轮全面审计修复所有问题。

新增功能:
- OutputValidator 事实一致性检查 + 幻觉检测
- PromptInjectionDefender 语义级检测(指令性动词密度、角色边界、分隔符嵌套)
- DeadLoopError 死循环检测(连续3轮相同工具调用自动终止)
- PolicyEngine maxFrequency 滑动窗口频率限制
- MemoryManager TF-IDF 语义检索 + IDF 缓存原子替换
- 斜杠命令(/tool /memory /clear /export)+ 快捷键(Ctrl+B/J/Shift+F/N/[/])
- 专注模式(Ctrl+Shift+F)带面板状态快照保存/恢复

六轮审计修复(共修复 3 CRITICAL + 8 HIGH + 13 MEDIUM + 11 LOW):

CRITICAL:
- main.ts 传入 createAdapter 而非 reloadAdapter,导致 Provider 切换完全失效
- Ctrl+N/Ctrl+[/Ctrl+] 不同步 agent-store,导致消息发到错误会话
- engine.ts emit('error') 无监听器导致 DONE 事件丢失、前端卡死

HIGH:
- 死循环检测在工具执行之后(移入 PARSING 后 EXECUTING 前)
- deadLoop 事件前端未处理
- ConfirmationHook.clearPending 从未调用导致定时器泄漏
- engine.ts retry abort listener 未移除导致监听器堆积
- MCPManager JSON.parse 无 try-catch 导致初始化崩溃
- sendMessage 自动创建会话不同步 session-store
- setCurrentSession 竞态导致旧请求覆盖新会话数据

MEDIUM:
- toggleFocusMode 覆盖用户原有面板状态
- 正则检测可被常见词绕过
- 频率限制内存泄漏 + customPolicies 覆盖
- LIKE 回退转义未包含反斜杠
- OutputValidator 新功能未传入 toolResults/context
- MemoryConsolidator LLM 调用无超时保护
- AuditService 每次 log 都查询数据库
- browser-window-manager 超时后未停止页面加载
- output-validator URL 比较大小写敏感
- FileReader 无 onerror 导致 Promise 永久挂起
- /clear /export 不关闭斜杠菜单
- 专注模式下 toggleSidebar/toggleDetail 未恢复另一面板快照

LOW:
- abortPromise 事件监听器堆积
- RateLimitHook Map 内存泄漏
- memory.ts await 同步方法
- PolicyEngine 死代码清理
- Orchestrator sessionDepth 会话结束不清理
- workspace.service.ts 元数据插入边界问题
2026-07-12 19:46:47 +08:00

384 lines
11 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.
/**
* BrowserWindowManager — 单例浏览器窗口管理器
*
* 提供网页加载、截图、JS 执行、内容提取、交互操作能力。
* 单例懒加载,首次调用时创建,browserClose() 显式销毁。
*
* @see docs/Agent网络工具通用设计-v2.md — 第 4 章 browser 浏览器设计
*/
import { BrowserWindow } from 'electron';
import log from 'electron-log';
export interface BrowserOpenOptions {
url: string;
waitSelector?: string;
}
export interface BrowserOpenResult {
title: string;
url: string;
}
export interface ScreenshotOptions {
fullPage?: boolean;
selector?: string;
}
export interface ScreenshotResult {
data: string; // Base64 PNG
width: number;
height: number;
}
export interface ExtractResult {
text: string;
links: Array<{ text: string; url: string }>;
}
export interface ScrollOptions {
direction?: 'down' | 'up' | 'top' | 'bottom';
selector?: string;
}
export interface WaitOptions {
selector?: string;
timeMs?: number;
}
export class BrowserWindowManager {
private win: BrowserWindow | null = null;
private currentUrl: string | null = null;
/** 窗口是否就绪 */
get ready(): boolean {
return this.win !== null && !this.win.isDestroyed();
}
// ===== browserOpen =====
async open(options: BrowserOpenOptions): Promise<BrowserOpenResult> {
// 若已有窗口加载了不同 URL → 先关闭重建
if (this.win && this.currentUrl !== options.url) {
this.destroy();
}
if (!this.win) {
this.win = new BrowserWindow({
width: 1280,
height: 800,
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: false,
},
});
}
await this.loadURLWithTimeout(this.win, options.url, 30_000);
this.currentUrl = options.url;
// 可选等待选择器
if (options.waitSelector) {
await this.waitForSelector(options.waitSelector, 10_000);
}
const title = await this.evaluate('document.title');
return { title: String(title ?? ''), url: options.url };
}
// ===== browserScreenshot =====
async screenshot(options: ScreenshotOptions = {}): Promise<ScreenshotResult> {
this.ensureReady();
const win = this.win!;
// 元素截图
if (options.selector) {
const rect = await win.webContents.executeJavaScript(
`(() => {
const el = document.querySelector(${JSON.stringify(options.selector)});
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
})()`,
true,
) as { x: number; y: number; width: number; height: number } | null;
if (!rect) throw new Error(`Element not found: ${options.selector}`);
const image = await win.webContents.capturePage({
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
});
return { data: image.toPNG().toString('base64'), width: rect.width, height: rect.height };
}
// 全页截图
if (options.fullPage) {
const dims = await win.webContents.executeJavaScript(
`({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight })`,
true,
) as { width: number; height: number };
// 先滚动到底部触发懒加载
await win.webContents.executeJavaScript(
`window.scrollTo(0, document.body.scrollHeight)`,
true,
);
await this.sleep(500);
await win.webContents.executeJavaScript(
`window.scrollTo(0, 0)`,
true,
);
await this.sleep(300);
const image = await win.webContents.capturePage({
x: 0, y: 0,
width: dims.width,
height: dims.height,
});
return { data: image.toPNG().toString('base64'), width: dims.width, height: dims.height };
}
// 视口截图
const image = await win.webContents.capturePage();
const size = win.getContentSize();
return { data: image.toPNG().toString('base64'), width: size[0], height: size[1] };
}
// ===== browserEvaluate =====
async evaluate(js: string): Promise<unknown> {
this.ensureReady();
const result = await this.win!.webContents.executeJavaScript(js, true);
if (typeof result === 'string') return result;
return result;
}
// ===== browserExtract =====
async extract(selector?: string): Promise<ExtractResult> {
this.ensureReady();
const result = await this.win!.webContents.executeJavaScript(
`(() => {
const root = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : 'document.body'};
if (!root) return null;
const clone = root.cloneNode(true);
clone.querySelectorAll('script, style, noscript, iframe, svg, nav, header, footer, aside').forEach(el => el.remove());
const text = (clone.innerText || '').trim();
const links = [];
const anchors = (selector ? root : document).querySelectorAll('a[href^="http"]');
for (const a of anchors) {
if (links.length >= 50) break;
const text = (a.textContent || '').trim();
if (text && a.href) links.push({ text, url: a.href });
}
return { text, links };
})()`,
true,
) as { text: string; links: Array<{ text: string; url: string }> } | null;
if (!result) throw new Error(selector ? `Element not found: ${selector}` : 'No body content');
return { text: result.text, links: result.links };
}
// ===== browserClick =====
async click(selector: string, wait = false): Promise<void> {
this.ensureReady();
if (wait) {
await this.waitForSelector(selector, 10_000);
}
const found = await this.win!.webContents.executeJavaScript(
`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return false;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
return true;
})()`,
true,
);
if (!found) throw new Error(`Element not found: ${selector}`);
await this.sleep(300);
await this.win!.webContents.executeJavaScript(
`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return;
el.click();
})()`,
true,
);
await this.sleep(500);
}
// ===== browserType =====
async type(selector: string, text: string, options: { clear?: boolean; submit?: boolean } = {}): Promise<void> {
this.ensureReady();
const found = await this.win!.webContents.executeJavaScript(
`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return false;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.focus();
return true;
})()`,
true,
);
if (!found) throw new Error(`Element not found: ${selector}`);
await this.sleep(300);
// 清空 + 赋值 + 触发事件(兼容 React/Vue
await this.win!.webContents.executeJavaScript(
`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return;
${options.clear ? 'el.value = "";' : ''}
el.value += ${JSON.stringify(text)};
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
})()`,
true,
);
if (options.submit) {
await this.win!.webContents.executeJavaScript(
`(() => {
const el = document.querySelector(${JSON.stringify(selector)});
if (!el) return;
if (el.form) el.form.submit();
else el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
})()`,
true,
);
await this.sleep(1_000);
} else {
await this.sleep(300);
}
}
// ===== browserScroll =====
async scroll(options: ScrollOptions = {}): Promise<void> {
this.ensureReady();
if (options.selector) {
await this.win!.webContents.executeJavaScript(
`(() => {
const el = document.querySelector(${JSON.stringify(options.selector)});
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
})()`,
true,
);
return;
}
const direction = options.direction ?? 'down';
const js = {
down: 'window.scrollBy(0, 500)',
up: 'window.scrollBy(0, -500)',
top: 'window.scrollTo(0, 0)',
bottom: 'window.scrollTo(0, document.body.scrollHeight)',
}[direction] ?? 'window.scrollBy(0, 500)';
await this.win!.webContents.executeJavaScript(js, true);
await this.sleep(300);
}
// ===== browserWait =====
async wait(options: WaitOptions = {}): Promise<void> {
if (options.selector) {
await this.waitForSelector(options.selector, options.timeMs ?? 10_000);
} else {
await this.sleep(options.timeMs ?? 1_000);
}
}
// ===== browserClose =====
close(): void {
this.destroy();
}
// ===== 内部辅助 =====
private ensureReady(): void {
if (!this.ready) {
throw new Error('Browser window not ready. Call browser_open first.');
}
}
/** 带超时的 loadURLElectron 原生不支持 timeout 选项) */
private async loadURLWithTimeout(win: BrowserWindow, url: string, timeoutMs: number): Promise<void> {
let timer: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Page load timeout after ${timeoutMs}ms: ${url}`)), timeoutMs);
});
try {
await Promise.race([
win.loadURL(url),
timeoutPromise,
]);
} catch (e) {
// v0.3.0 修复: 超时后停止页面加载,避免后台继续消耗网络和 CPU 资源
if (!win.isDestroyed()) {
try { win.webContents.stop(); } catch { /* ignore */ }
}
throw e;
} finally {
if (timer) clearTimeout(timer);
}
}
private async waitForSelector(selector: string, timeoutMs: number): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const found = await this.win!.webContents.executeJavaScript(
`!!document.querySelector(${JSON.stringify(selector)})`,
true,
);
if (found) return;
await this.sleep(300);
}
throw new Error(`Timeout waiting for selector: ${selector}`);
}
private destroy(): void {
if (this.win && !this.win.isDestroyed()) {
try {
this.win.close();
} catch {
// 忽略关闭错误
}
}
this.win = null;
this.currentUrl = null;
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** 应用退出时清理 */
static cleanup(manager: BrowserWindowManager | null): void {
if (manager) manager.close();
log.info('[BrowserWindowManager] Cleaned up');
}
}