P1 修复面收口: /clear 全链路根治(前端清空联动 DB messages+摘要游标+TRACE 快照, IPC 语义改"操作完成"; 流式中拒绝); web_browser open 补 SSRF 校验(Chromium 旁路关闭, 与 web_fetch/http_request 同源 validateSSRF); MCP 工具结果纳入注入扫描(mcp_* 前缀 按网络来源同级 full 模式, 收敛 resolveScanMode 单点); Trace 落库/入 store 双重瘦身 (tool_result base64/超长字段剥离, metadata 防 MB 级膨胀); 文本附件 512KB 闸门 (file.slice 首段读取+truncated 标志随消息持久化+主进程附件提示感知截断); 单实例锁(requestSingleInstanceLock + second-instance 聚焦已有窗口) P2 安全纵深: ConfirmationHook 多窗口化(确认请求/超时提示改全窗口广播, getAllWindows 空时回退 mainWindow, fail-closed 判定升级双通道); mcp_servers.headers 全链路接线(safeParseHeaders 容错解析+SSE/StreamableHTTP requestInit 注入+IPC 逐项 校验+设置页 JSON 输入, 远程 MCP 鉴权头可用) P3 断链接线: llm:listModels IPC(六家 adapter 动态模型发现首次接线, 配置完整性 前置校验); Ollama pullModel IPC+设置页下载卡片(进度/取消/能力徽标, v0.7.0 死代码 激活); 后台会话运行指示(sessionRunStates 图+Sidebar 状态点, 多会话并发可见); IR 卫生(移除 THINKING_START/END 死枚举, constraints 标注预留) P4 质量与文档: i18n 第二阶段(确认弹框/侧栏/状态栏/AgentMonitor/终止原因出层, 外观设置 zh-CN/en-US 切换, ui.locale 持久化, 渲染时求值规避异步注册); README/D1 文档对齐(http_request 风险等级/用例数/实现状态注记); 版本号 0.7.2 测试: 507 → 737 用例(+230, 11 个新文件)。覆盖补齐: context-builder/consolidator/ orchestrator/workspace.service/session-recorder/config-layering/secure-config/ network-proxy + IPC mcp/tasks/memory/app/data 域 + 渲染层 store 与流事件管线纯函数。 测试驱动修复: workspace.appendMemory 中文分区 \b 词边界失效(JS \b 不含 CJK), 固化条目恒追加文件末尾产生重复分区头 → (?=\n|$) 前瞻断言根治 回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 687 通过 50 跳过; Electron ABI 全量 737/737 零跳过
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
/**
|
||
* web_browser — 统一浏览器交互工具
|
||
*
|
||
* 将 9 个浏览器操作合并为单个工具,通过 `action` 参数路由:
|
||
* 1. open — 打开 URL
|
||
* 2. screenshot — 截图(视口/元素/全页三模式)
|
||
* 3. evaluate — 执行 JavaScript
|
||
* 4. extract — 提取页面文本与链接
|
||
* 5. click — 点击元素
|
||
* 6. type — 输入文本
|
||
* 7. scroll — 滚动页面
|
||
* 8. wait — 等待条件
|
||
* 9. close — 关闭窗口
|
||
*
|
||
* 基于单例 BrowserWindowManager 实现。
|
||
*
|
||
* @see docs/Agent网络工具通用设计-v2.md — 第 4 章 browser 浏览器设计
|
||
*/
|
||
|
||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||
import type { MetonaToolDef } from '../../../harness/types';
|
||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||
import { BrowserWindowManager } from './browser-window-manager';
|
||
import { logTool } from './network-utils';
|
||
// v0.7.2 A2 根治: web_browser open 此前仅校验协议 —— 隐藏浏览器可直接导航
|
||
// http://127.0.0.1:* / http://169.254.169.254 等内网/云元数据地址,等于借
|
||
// Chromium 网络栈绕过 web_fetch / http_request 已有的整条 SSRF 防线。
|
||
// 现与 web_fetch 同源复用 ssrf-guard(DNS 解析全部 IP + 私有段判定),
|
||
// 在创建任何窗口之前拦截。
|
||
import { validateSSRF } from './ssrf-guard';
|
||
|
||
// ===== 单例 Manager =====
|
||
|
||
let managerInstance: BrowserWindowManager | null = null;
|
||
|
||
function getManager(): BrowserWindowManager {
|
||
if (!managerInstance) {
|
||
managerInstance = new BrowserWindowManager();
|
||
}
|
||
return managerInstance;
|
||
}
|
||
|
||
/** 获取共享浏览器管理器单例(供 web_fetch 等工具复用) */
|
||
export function getBrowserManager(): BrowserWindowManager {
|
||
return getManager();
|
||
}
|
||
|
||
/** 应用退出时清理(由 main.ts 调用;close 含 session 存储终态清理,需 await) */
|
||
export async function cleanupBrowser(): Promise<void> {
|
||
await BrowserWindowManager.cleanup(managerInstance);
|
||
managerInstance = null;
|
||
}
|
||
|
||
// ===== WebBrowserTool =====
|
||
|
||
export class WebBrowserTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'web_browser',
|
||
description: [
|
||
'Control a hidden browser window to navigate, inspect, and interact with web pages.',
|
||
'Use the "action" parameter to specify the operation:',
|
||
' open — Load a URL; optionally wait for a CSS selector before returning. Returns page title and URL.',
|
||
' screenshot — Capture the viewport (default), full page, or a specific element by CSS selector. Returns base64 PNG.',
|
||
' evaluate — Execute JavaScript in the page context. Returns the result of the last expression.',
|
||
' extract — Extract clean text and up to 50 links from the page or a specific element.',
|
||
' click — Click an element by CSS selector. Scrolls into view first. Can optionally wait for the selector.',
|
||
' type — Type text into an input element. Optionally clear first and submit the form.',
|
||
' scroll — Scroll the page: down/up (500px), top/bottom, or scroll to an element by selector.',
|
||
' wait — Wait for a CSS selector to appear (with timeout) or a fixed duration.',
|
||
' close — Close the browser window and release resources.',
|
||
].join('\n'),
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
action: {
|
||
type: 'string',
|
||
enum: [
|
||
'open',
|
||
'screenshot',
|
||
'evaluate',
|
||
'extract',
|
||
'click',
|
||
'type',
|
||
'scroll',
|
||
'wait',
|
||
'close',
|
||
],
|
||
description: 'Browser operation to perform',
|
||
},
|
||
url: {
|
||
type: 'string',
|
||
description: '[open] Target URL (http/https only)',
|
||
},
|
||
wait_selector: {
|
||
type: 'string',
|
||
description: '[open] CSS selector to wait for before returning (optional)',
|
||
},
|
||
script: {
|
||
type: 'string',
|
||
description: '[evaluate] JavaScript code to execute (must return a value)',
|
||
},
|
||
selector: {
|
||
type: 'string',
|
||
description: '[screenshot|extract|click|type|scroll] CSS selector of the target element',
|
||
},
|
||
full_page: {
|
||
type: 'boolean',
|
||
description: '[screenshot] Capture entire scrollable page (default false)',
|
||
},
|
||
text: {
|
||
type: 'string',
|
||
description: '[type] Text to type into the element',
|
||
},
|
||
clear: {
|
||
type: 'boolean',
|
||
description: '[type] Clear the field before typing (default true)',
|
||
},
|
||
submit: {
|
||
type: 'boolean',
|
||
description: '[type] Submit the form after typing (default false)',
|
||
},
|
||
direction: {
|
||
type: 'string',
|
||
enum: ['down', 'up', 'top', 'bottom'],
|
||
description: '[scroll] Scroll direction (default down). Ignored if selector is provided.',
|
||
},
|
||
wait: {
|
||
type: 'boolean',
|
||
description: '[click] Wait for selector to appear before clicking (default false)',
|
||
},
|
||
time_ms: {
|
||
type: 'number',
|
||
description:
|
||
'[wait] Fixed wait time in milliseconds (default 1000). Used as timeout when selector is also provided.',
|
||
},
|
||
},
|
||
required: ['action'],
|
||
},
|
||
category: MetonaToolCategory.NETWORK,
|
||
riskLevel: MetonaRiskLevel.HIGH,
|
||
requiresPermission: true,
|
||
timeoutMs: 60_000,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
|
||
const action = args.action as string;
|
||
if (!action) {
|
||
return { success: false, error: 'Missing required parameter: action' };
|
||
}
|
||
|
||
logTool('web_browser', `action=${action}`);
|
||
|
||
switch (action) {
|
||
// ===== open =====
|
||
case 'open': {
|
||
const url = args.url as string;
|
||
if (!url || !/^https?:\/\//i.test(url)) {
|
||
return { success: false, error: 'URL must start with http:// or https://' };
|
||
}
|
||
// v0.7.2 A2: SSRF 校验 —— 与 web_fetch / http_request 同源同行为。
|
||
// 必须先于 getManager() 执行:私有段 IP / 云元数据地址在创建任何
|
||
// BrowserWindow 之前即被拒绝,不存在"先开窗再拒"的旁路。
|
||
try {
|
||
await validateSSRF(url);
|
||
} catch (ssrfErr) {
|
||
logTool('web_browser', `SSRF blocked: ${(ssrfErr as Error).message}`);
|
||
return { success: false, action, error: (ssrfErr as Error).message };
|
||
}
|
||
const waitSelector = args.wait_selector as string | undefined;
|
||
try {
|
||
const result = await getManager().open({ url, waitSelector });
|
||
return { success: true, action, ...result };
|
||
} catch (err) {
|
||
return { success: false, action, error: (err as Error).message };
|
||
}
|
||
}
|
||
|
||
// ===== screenshot =====
|
||
case 'screenshot': {
|
||
const fullPage = (args.full_page as boolean) ?? false;
|
||
const selector = args.selector as string | undefined;
|
||
try {
|
||
const result = await getManager().screenshot({ fullPage, selector });
|
||
return {
|
||
success: true,
|
||
action,
|
||
image: result.data,
|
||
width: result.width,
|
||
height: result.height,
|
||
mime_type: 'image/png',
|
||
};
|
||
} catch (err) {
|
||
return { success: false, action, error: (err as Error).message };
|
||
}
|
||
}
|
||
|
||
// ===== evaluate =====
|
||
case 'evaluate': {
|
||
const script = args.script as string;
|
||
if (!script) {
|
||
return { success: false, action, error: 'No script provided' };
|
||
}
|
||
try {
|
||
const result = await getManager().evaluate(script);
|
||
return { success: true, action, result };
|
||
} catch (err) {
|
||
return { success: false, action, error: (err as Error).message };
|
||
}
|
||
}
|
||
|
||
// ===== extract =====
|
||
case 'extract': {
|
||
const selector = args.selector as string | undefined;
|
||
try {
|
||
const result = await getManager().extract(selector);
|
||
return {
|
||
success: true,
|
||
action,
|
||
text: result.text,
|
||
links: result.links,
|
||
link_count: result.links.length,
|
||
};
|
||
} catch (err) {
|
||
return { success: false, action, error: (err as Error).message };
|
||
}
|
||
}
|
||
|
||
// ===== click =====
|
||
case 'click': {
|
||
const selector = args.selector as string;
|
||
const wait = (args.wait as boolean) ?? false;
|
||
if (!selector) {
|
||
return { success: false, action, error: 'No selector provided' };
|
||
}
|
||
try {
|
||
await getManager().click(selector, wait);
|
||
return { success: true, action, selector, clicked: true };
|
||
} catch (err) {
|
||
return { success: false, action, error: (err as Error).message };
|
||
}
|
||
}
|
||
|
||
// ===== type =====
|
||
case 'type': {
|
||
const selector = args.selector as string;
|
||
const text = args.text as string;
|
||
const clear = (args.clear as boolean) ?? true;
|
||
const submit = (args.submit as boolean) ?? false;
|
||
if (!selector) {
|
||
return { success: false, action, error: 'No selector provided' };
|
||
}
|
||
if (text === undefined || text === null) {
|
||
return { success: false, action, error: 'No text provided' };
|
||
}
|
||
try {
|
||
await getManager().type(selector, text, { clear, submit });
|
||
return { success: true, action, selector, typed: text.length };
|
||
} catch (err) {
|
||
return { success: false, action, error: (err as Error).message };
|
||
}
|
||
}
|
||
|
||
// ===== scroll =====
|
||
case 'scroll': {
|
||
const direction = (args.direction as string) ?? 'down';
|
||
const selector = args.selector as string | undefined;
|
||
try {
|
||
await getManager().scroll({
|
||
direction: direction as 'down' | 'up' | 'top' | 'bottom',
|
||
selector,
|
||
});
|
||
return { success: true, action, direction, selector };
|
||
} catch (err) {
|
||
return { success: false, action, error: (err as Error).message };
|
||
}
|
||
}
|
||
|
||
// ===== wait =====
|
||
case 'wait': {
|
||
const selector = args.selector as string | undefined;
|
||
const timeMs = (args.time_ms as number) ?? 1_000;
|
||
try {
|
||
await getManager().wait({ selector, timeMs });
|
||
return { success: true, action, waited_for: selector ?? `${timeMs}ms` };
|
||
} catch (err) {
|
||
return { success: false, action, error: (err as Error).message };
|
||
}
|
||
}
|
||
|
||
// ===== close =====
|
||
case 'close': {
|
||
await getManager().close();
|
||
return { success: true, action, closed: true };
|
||
}
|
||
|
||
default:
|
||
return { success: false, error: `Unknown action: ${action}` };
|
||
}
|
||
}
|
||
}
|