feat: MEMORY.md 访问保护、格式简化及预存问题修复

MEMORY.md 保护: 新建 file-guard.ts 共享守卫模块; read_file/write_file/search_files/run_command 禁止访问根目录 MEMORY.md; permissions.ts 增加 deniedPatterns 深度防御。格式简化: 元数据从 # 注释改为 > 引用语法; 校验规则从 6 条简化为 3 条; context-builder extractContent 适配。预存问题: 修复路径遍历前缀碰撞漏洞; 移除 browser_extract 内容截断; SearXNG 配置实时读取; web_search/web_fetch 移除截断; 侧边栏动态获取工具列表; 版本号 0.1.1
This commit is contained in:
thzxx
2026-07-05 21:24:22 +08:00
parent f4532a2bb2
commit 8cdb93f8bf
29 changed files with 2472 additions and 353 deletions
@@ -0,0 +1,377 @@
/**
* 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,
]);
} 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');
}
}