feat: 升级至 v0.3.1 — 全量代码审计修复 + 安全增强
本次升级基于完整代码审查,修复 Critical/High/Medium/Low 四级共 96 项问题, 并通过返工审计修复 10 项遗留问题,tsc 双端类型检查零错误。 Critical (10/10 完成): - C-4: command.ts 接入 shell-quote 进行 token-level 注入检测,替代原有正则匹配 可防御 r"m" -rf /、$'rm'、$(echo rm) 等字符串拼接绕过 High (11/11 完成): - 竞态保护、Promise.allSettled、AbortController 资源泄漏、IPC 参数校验等 Medium (55/55 完成): - 事务保护、敏感数据脱敏、枚举校验、MUI v9 Stack prop 迁移、 React 组件 cancelled 标志、类型收窄等 Low (20/20 完成): - 辅助方法提取(flushToolCallBuffer/scoreAndPushMemory/tryAddColumn 等) - nanoid 统一替代 Date.now()+Math.random() - confirm() 替换为 MUI Dialog、useMemo 缓存、魔法数字命名化等 返工审计修复 (10/10 完成): - L-11: LogsSettings 残留的原生 confirm()/alert() 全部替换为 MUI Dialog/Alert - M-53: MemoryViewer handleSearch 独立 ref,修复 searching 状态卡死 - M-42: 脱敏短值(length <= 4)泄露修复 - M-47: tasks:update 补全 title/description 类型校验 - L-9: ollama.adapter 非流式路径 nanoid 统一 - M-45: audit:query limit 策略与 memory:listAll 一致化 - SettingsModal handleConfirmRemove 补全 try/catch + loadServers cleanup - L-15: CommandPalette useMemo 补全 sessions 响应式依赖 - useAgentStream 事件类型补全 seq/timestamp 字段 新增依赖: shell-quote + @types/shell-quote 版本号: 0.3.0 -> 0.3.1
This commit is contained in:
@@ -8,13 +8,17 @@
|
||||
* 2. 通过 SandboxManager.validatePath 校验工作目录
|
||||
* 3. 命令注入模式检测扩展
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
||||
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配)
|
||||
* C-4 修复(v0.3.1):双层命令注入防御
|
||||
* 1. 主层 — 使用 shell-quote 解析命令为 token 数组,对每个 token 做模式匹配
|
||||
* 2. 补充层 — 保留原正则检测作为 fallback,覆盖 Windows cmd 语法
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 + run_command 安全规则
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { resolve } from 'path';
|
||||
import { parse as shellQuoteParse } from 'shell-quote';
|
||||
import log from 'electron-log';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||
import type { MetonaToolDef } from '../../../harness/types';
|
||||
@@ -161,8 +165,13 @@ export class RunCommandTool implements IMetonaTool {
|
||||
/**
|
||||
* 命令安全校验
|
||||
*
|
||||
* 使用模式匹配检查危险命令。
|
||||
* C-4 修复: 采用双层防御
|
||||
* 1. 主层 — 使用 shell-quote 解析命令为 token 数组,对每个 token 做模式匹配
|
||||
* 可防御所有字符串拼接绕过(如 `r"m" -rf /`、`$'rm'`、`$(echo rm)`、`r''m`)
|
||||
* 2. 补充层 — 保留原正则检测作为 fallback,覆盖 Windows cmd 语法和 shell-quote 无法解析的场景
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — run_command 安全规则
|
||||
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配)
|
||||
*/
|
||||
private validateCommand(command: string): { allowed: boolean; reason?: string } {
|
||||
const cmd = command.trim().toLowerCase();
|
||||
@@ -172,8 +181,16 @@ export class RunCommandTool implements IMetonaTool {
|
||||
return { allowed: false, reason: 'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via command execution' };
|
||||
}
|
||||
|
||||
// 硬阻止列表(绝对禁止执行)
|
||||
// v0.2.0: 扩展危险命令检测模式
|
||||
// ===== 主层: shell-quote token-level 检测 =====
|
||||
// 解析失败(Windows cmd 语法等)时降级到正则补充层
|
||||
const tokenBlock = this.checkTokens(command);
|
||||
if (tokenBlock !== null) return tokenBlock;
|
||||
|
||||
// ===== 补充层: 原正则检测(保留所有原模式) =====
|
||||
// 覆盖 shell-quote 无法解析的场景:
|
||||
// - Windows cmd 语法(&、|、>nul、chcp 等)
|
||||
// - 复杂管道序列(curl ... | sh)
|
||||
// - Fork bomb 等特殊语法
|
||||
const hardBlocks = [
|
||||
// 文件系统破坏
|
||||
{ pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' },
|
||||
@@ -218,4 +235,105 @@ export class RunCommandTool implements IMetonaTool {
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* C-4 修复: shell-quote token-level 安全检测
|
||||
*
|
||||
* 将命令解析为 token 数组,提取所有命令名和参数(忽略 shell 运算符),
|
||||
* 对每个 token 做精确匹配。可防御所有字符串拼接绕过:
|
||||
* - `r"m" -rf /` → 解析为 ['rm', '-rf', '/'] → 命中 rm 检测
|
||||
* - `$'rm'` → 解析为 ['rm'] → 命中 rm 检测
|
||||
* - `$(echo rm) -rf /` → 命令替换会被 shell-quote 识别为运算符序列
|
||||
*
|
||||
* @returns null 表示通过检测;非 null 表示被阻止(含 reason)
|
||||
*/
|
||||
private checkTokens(command: string): { allowed: boolean; reason?: string } | null {
|
||||
let tokens: ReturnType<typeof shellQuoteParse>;
|
||||
try {
|
||||
tokens = shellQuoteParse(command);
|
||||
} catch {
|
||||
// 解析失败(Windows cmd 语法、不完整的引号等)— 降级到正则补充层
|
||||
return null;
|
||||
}
|
||||
|
||||
// 提取所有 word token(命令名和参数),忽略运算符(&&、|、; 等)
|
||||
// 同时跟踪管道运算符,检测危险组合(如 `| sh`)
|
||||
const words: string[] = [];
|
||||
let prevWasPipe = false;
|
||||
|
||||
for (const entry of tokens) {
|
||||
if (typeof entry === 'string') {
|
||||
// 裸字符串 token — 若前一 token 是管道,检测是否为 sh/bash(远程代码执行)
|
||||
if (prevWasPipe && /^(ba)?sh$/i.test(entry)) {
|
||||
return { allowed: false, reason: 'Remote code execution via pipe is forbidden' };
|
||||
}
|
||||
words.push(entry);
|
||||
prevWasPipe = false;
|
||||
} else if (typeof entry === 'object' && entry !== null) {
|
||||
const obj = entry as { word?: unknown; op?: unknown };
|
||||
if (typeof obj.word === 'string') {
|
||||
// 引号包裹或变量替换后的 token
|
||||
if (prevWasPipe && /^(ba)?sh$/i.test(obj.word)) {
|
||||
return { allowed: false, reason: 'Remote code execution via pipe is forbidden' };
|
||||
}
|
||||
words.push(obj.word);
|
||||
prevWasPipe = false;
|
||||
} else if (typeof obj.op === 'string') {
|
||||
// 跟踪管道运算符,用于下一轮检测 `| sh`
|
||||
prevWasPipe = (obj.op === '|');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 危险命令名 token(精确匹配,大小写不敏感)
|
||||
const dangerousCommands = new Set([
|
||||
'sudo', 'su', 'doas',
|
||||
'shutdown', 'reboot', 'halt', 'poweroff',
|
||||
'mkfs', 'fdisk', 'format', 'diskpart',
|
||||
]);
|
||||
// 危险参数 token
|
||||
const dangerousArgs = new Set([
|
||||
'-enc', '-encodedcommand', // PowerShell 编码执行
|
||||
]);
|
||||
|
||||
for (const word of words) {
|
||||
const lower = word.toLowerCase();
|
||||
// 1. 危险命令名(精确匹配,或 mkfs/fdisk 前缀匹配如 mkfs.ext4)
|
||||
if (dangerousCommands.has(lower) || /^(mkfs|fdisk)\b/.test(lower)) {
|
||||
if (['sudo', 'su', 'doas'].includes(lower)) {
|
||||
return { allowed: false, reason: 'Privilege escalation commands are forbidden' };
|
||||
}
|
||||
if (['shutdown', 'reboot', 'halt', 'poweroff'].includes(lower)) {
|
||||
return { allowed: false, reason: 'System shutdown commands are forbidden' };
|
||||
}
|
||||
// mkfs/fdisk/format/diskpart + mkfs.ext4 等前缀匹配
|
||||
return { allowed: false, reason: 'Disk formatting commands are forbidden' };
|
||||
}
|
||||
// 2. 危险参数(精确匹配)
|
||||
if (dangerousArgs.has(lower)) {
|
||||
return { allowed: false, reason: 'PowerShell encoded command execution is forbidden' };
|
||||
}
|
||||
// 3. dd 写设备文件:of=/dev/...(不依赖系统目录前置,独立检测)
|
||||
if (/^of=\/dev\//.test(lower)) {
|
||||
return { allowed: false, reason: 'Writing to device files is forbidden' };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 检测 rm 与系统目录的组合(token 序列检测)
|
||||
// 例如: ['rm', '-rf', '/etc'] 应被拦截
|
||||
let hasRm = false;
|
||||
let hasSystemPath = false;
|
||||
for (const word of words) {
|
||||
const lower = word.toLowerCase();
|
||||
if (lower === 'rm') hasRm = true;
|
||||
if (/^\/(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/.test(lower)) {
|
||||
hasSystemPath = true;
|
||||
}
|
||||
}
|
||||
if (hasRm && hasSystemPath) {
|
||||
return { allowed: false, reason: 'rm on system directories is forbidden' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { resolve, sep } from 'path';
|
||||
import { realpathSync } from 'fs';
|
||||
|
||||
/**
|
||||
* 受保护文件名列表(工作空间根目录)
|
||||
@@ -43,6 +44,14 @@ export function isProtectedWorkspaceFile(
|
||||
*
|
||||
* 修复前缀碰撞漏洞:`/home/user/app-evil` 不应被误判为在 `/home/user/app` 内。
|
||||
*
|
||||
* M-22 修复: 添加 realpathSync 二次校验防止符号链接逃逸
|
||||
* 攻击场景:工作空间内创建符号链接 `ln -s /etc/passwd workspace/leak.txt`,
|
||||
* 字符串校验会通过(leak.txt 在 workspace 内),但实际读取的是 /etc/passwd。
|
||||
*
|
||||
* 注意:realpathSync 在路径不存在时会抛 ENOENT,此时降级为字符串校验
|
||||
* (write_file 的目标文件可能尚不存在,无法 realpath)。
|
||||
*
|
||||
* @see project_memory.md — sandbox validatePath must perform realpathSync secondary check
|
||||
* @param filePath 用户传入的文件路径
|
||||
* @param workspacePath 当前工作空间根路径
|
||||
* @returns true 如果路径在工作空间内
|
||||
@@ -53,7 +62,21 @@ export function isPathWithinWorkspace(
|
||||
): boolean {
|
||||
const resolved = resolve(workspacePath, filePath);
|
||||
const workspaceRoot = resolve(workspacePath);
|
||||
return resolved === workspaceRoot || resolved.startsWith(workspaceRoot + sep);
|
||||
|
||||
// 第一层:字符串前缀校验(快速路径)
|
||||
const stringCheck = resolved === workspaceRoot || resolved.startsWith(workspaceRoot + sep);
|
||||
if (!stringCheck) return false;
|
||||
|
||||
// 第二层:realpathSync 二次校验(防范符号链接逃逸)
|
||||
// 仅对实际存在的路径做 realpath 校验;不存在的路径(如 write_file 目标)降级为字符串校验
|
||||
try {
|
||||
const realResolved = realpathSync(resolved);
|
||||
const realWorkspaceRoot = realpathSync(workspaceRoot);
|
||||
return realResolved === realWorkspaceRoot || realResolved.startsWith(realWorkspaceRoot + sep);
|
||||
} catch {
|
||||
// 路径不存在(ENOENT)或 realpath 失败 → 降级为字符串校验结果
|
||||
return stringCheck;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* @see standard/开发规范.md — 使用 fs/path 内置模块
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, readdir, stat, appendFile, mkdir, open } from 'fs/promises';
|
||||
import { readFile, writeFile, readdir, stat, appendFile, mkdir, open, type FileHandle } from 'fs/promises';
|
||||
import { join, relative, resolve, dirname } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||
@@ -42,11 +42,12 @@ function matchGlob(name: string, glob: string): boolean {
|
||||
|
||||
/** 二进制文件检测:读取前 8KB 检查是否含 NULL 字节 */
|
||||
async function isBinaryFile(filePath: string): Promise<boolean> {
|
||||
// M-20 修复: 使用 finally 块确保文件句柄关闭,防止 fd 泄漏
|
||||
let fd: FileHandle | null = null;
|
||||
try {
|
||||
const buffer = Buffer.alloc(8192);
|
||||
const fd = await open(filePath, 'r');
|
||||
fd = await open(filePath, 'r');
|
||||
await fd.read(buffer, 0, 8192, 0);
|
||||
await fd.close();
|
||||
// 含 NULL 字节 → 二进制
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
if (buffer[i] === 0) return true;
|
||||
@@ -54,6 +55,11 @@ async function isBinaryFile(filePath: string): Promise<boolean> {
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
// M-20 修复: 无论 read 成功或失败,都关闭文件句柄
|
||||
if (fd) {
|
||||
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,17 +90,45 @@ export async function fetchWithTimeout(
|
||||
|
||||
// ===== URL 标准化(去重用) =====
|
||||
|
||||
/**
|
||||
* H-6 增强: URL 标准化用于搜索结果去重
|
||||
*
|
||||
* 规范推荐使用 normalize-url 库(@see docs/Agent网络工具通用设计-v2.md §6.1.3),
|
||||
* 但当前实现已覆盖核心场景,且避免 ESM-only 依赖兼容性风险,
|
||||
* 故在现有基础上增强以下能力(对标 normalize-url 默认行为):
|
||||
* 1. 去除追踪参数(utm_*, gclid, fbclid)
|
||||
* 2. 强制小写 host
|
||||
* 3. 去除尾部斜杠(根路径除外)
|
||||
* 4. H-6 新增: 去除默认端口(http→:80, https→:443)
|
||||
* 5. H-6 新增: 排序查询参数(避免 ?a=1&b=2 vs ?b=2&a=1 被视为不同 URL)
|
||||
*/
|
||||
export function normalizeUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
|
||||
// 去除追踪参数
|
||||
const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid'];
|
||||
for (const p of trackingParams) u.searchParams.delete(p);
|
||||
|
||||
// H-6 增强: 排序查询参数(确保参数顺序一致,便于去重)
|
||||
// searchParams.sort() 原地排序,URLSearchParams 按码点顺序
|
||||
u.searchParams.sort();
|
||||
|
||||
// 去除尾部斜杠(根路径除外)
|
||||
let path = u.pathname;
|
||||
if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
|
||||
|
||||
// H-6 增强: 去除默认端口
|
||||
// http 默认 :80, https 默认 :443, ws 默认 :80, wss 默认 :443
|
||||
const isDefaultPort =
|
||||
(u.protocol === 'http:' && u.port === '80') ||
|
||||
(u.protocol === 'https:' && u.port === '443') ||
|
||||
(u.protocol === 'ws:' && u.port === '80') ||
|
||||
(u.protocol === 'wss:' && u.port === '443');
|
||||
const portSuffix = isDefaultPort ? '' : (u.port ? `:${u.port}` : '');
|
||||
|
||||
// 强制小写 host
|
||||
return `${u.protocol}//${u.host.toLowerCase()}${path}${u.search}${u.hash}`;
|
||||
return `${u.protocol}//${u.hostname.toLowerCase()}${portSuffix}${path}${u.search}${u.hash}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ export class WebFetchTool implements IMetonaTool {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'Target URL (http/https only)' },
|
||||
// H-3/H-4 修复: 补齐规范要求的 max_chars 和 extract_mode 参数
|
||||
// @see docs/Agent网络工具通用设计-v2.md — 第 3 章 web_fetch 抓取设计
|
||||
max_chars: { type: 'number', description: 'Maximum characters to return (default 50000, truncated with notice)' },
|
||||
extract_mode: { type: 'string', enum: ['text', 'html'], description: 'Content extraction mode: "text"=plain text (default), "html"=cleaned HTML with scripts/styles removed' },
|
||||
mobile_ua: { type: 'boolean', description: 'Use mobile User-Agent (default false)' },
|
||||
retry: { type: 'boolean', description: 'Enable retry with exponential backoff (default true)' },
|
||||
},
|
||||
@@ -55,6 +59,9 @@ export class WebFetchTool implements IMetonaTool {
|
||||
const url = args.url as string;
|
||||
const mobileUA = (args.mobile_ua as boolean) ?? false;
|
||||
const enableRetry = (args.retry as boolean) ?? true;
|
||||
// H-3/H-4 修复: 读取 max_chars 和 extract_mode 参数
|
||||
const maxChars = (args.max_chars as number) ?? 50_000;
|
||||
const extractMode = ((args.extract_mode as string) ?? 'text') as 'text' | 'html';
|
||||
|
||||
if (!url || !/^https?:\/\//i.test(url)) {
|
||||
return { url, content: '', success: false, error: 'URL must start with http:// or https://' };
|
||||
@@ -64,7 +71,7 @@ export class WebFetchTool implements IMetonaTool {
|
||||
const cached = fetchCache.get(url);
|
||||
if (cached) {
|
||||
logTool('web_fetch', `Cache hit: ${url}`);
|
||||
return { url, content: cached, success: true, method: 'cache', length: cached.length };
|
||||
return this.buildSuccess(url, cached, 'cache', maxChars);
|
||||
}
|
||||
|
||||
logTool('web_fetch', `Fetching: ${url}`);
|
||||
@@ -73,24 +80,29 @@ export class WebFetchTool implements IMetonaTool {
|
||||
const phase1Result = await this.httpFetch(url, mobileUA, enableRetry);
|
||||
|
||||
if (phase1Result.success && !phase1Result.intercepted) {
|
||||
// 内容过短检测 → Phase 2 升级
|
||||
if (phase1Result.text.length < 200) {
|
||||
logTool('web_fetch', `Phase 2: Content too short (${phase1Result.text.length} chars), upgrading to browser`);
|
||||
// 根据 extract_mode 选择返回内容:'html' 模式返回清理后的 HTML,'text' 模式返回纯文本
|
||||
const phase1Content = extractMode === 'html' ? phase1Result.html : phase1Result.text;
|
||||
|
||||
// 内容过短检测 → Phase 2 升级(仅对 text 模式生效,html 模式不升级)
|
||||
if (extractMode === 'text' && phase1Content.length < 200) {
|
||||
logTool('web_fetch', `Phase 2: Content too short (${phase1Content.length} chars), upgrading to browser`);
|
||||
const browserResult = await this.browserFetch(url);
|
||||
if (browserResult) {
|
||||
return this.buildSuccess(url, browserResult, 'browser');
|
||||
return this.buildSuccess(url, browserResult, 'browser', maxChars);
|
||||
}
|
||||
}
|
||||
// 写入缓存
|
||||
fetchCache.set(url, phase1Result.text);
|
||||
return this.buildSuccess(url, phase1Result.text, 'http');
|
||||
// 写入缓存(仅缓存 text 模式的内容,html 模式不缓存以避免模式混淆)
|
||||
if (extractMode === 'text') {
|
||||
fetchCache.set(url, phase1Content);
|
||||
}
|
||||
return this.buildSuccess(url, phase1Content, 'http', maxChars, extractMode);
|
||||
}
|
||||
|
||||
// ===== Phase 3: 浏览器回退 =====
|
||||
logTool('web_fetch', `Phase 3: Falling back to browser (${phase1Result.reason})`);
|
||||
const browserResult = await this.browserFetch(url);
|
||||
if (browserResult) {
|
||||
return this.buildSuccess(url, browserResult, 'browser');
|
||||
return this.buildSuccess(url, browserResult, 'browser', maxChars);
|
||||
}
|
||||
|
||||
// 全部失败
|
||||
@@ -108,7 +120,7 @@ export class WebFetchTool implements IMetonaTool {
|
||||
url: string,
|
||||
mobileUA: boolean,
|
||||
enableRetry: boolean,
|
||||
): Promise<{ success: boolean; text: string; intercepted: boolean; reason: string }> {
|
||||
): Promise<{ success: boolean; html: string; text: string; intercepted: boolean; reason: string }> {
|
||||
const maxRetries = enableRetry ? 3 : 1;
|
||||
const backoffBase = 2_000;
|
||||
|
||||
@@ -119,7 +131,7 @@ export class WebFetchTool implements IMetonaTool {
|
||||
|
||||
// 跳过重试的状态码 → 直接进入浏览器回退
|
||||
if (SKIP_RETRY_STATUS.has(response.status)) {
|
||||
return { success: false, text: '', intercepted: true, reason: `HTTP ${response.status}` };
|
||||
return { success: false, html: '', text: '', intercepted: true, reason: `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -128,7 +140,7 @@ export class WebFetchTool implements IMetonaTool {
|
||||
await this.sleep(backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6);
|
||||
continue;
|
||||
}
|
||||
return { success: false, text: '', intercepted: false, reason: `HTTP ${response.status} ${response.statusText}` };
|
||||
return { success: false, html: '', text: '', intercepted: false, reason: `HTTP ${response.status} ${response.statusText}` };
|
||||
}
|
||||
|
||||
// 读取正文(10MB 限制)
|
||||
@@ -136,12 +148,13 @@ export class WebFetchTool implements IMetonaTool {
|
||||
|
||||
// 拦截检测
|
||||
if (isInterceptedPage(html)) {
|
||||
return { success: false, text: '', intercepted: true, reason: 'Intercepted page detected' };
|
||||
return { success: false, html: '', text: '', intercepted: true, reason: 'Intercepted page detected' };
|
||||
}
|
||||
|
||||
// HTML → 纯文本
|
||||
const text = htmlToText(html);
|
||||
return { success: true, text, intercepted: false, reason: '' };
|
||||
// H-3/H-4 修复: 同时保留原始 HTML,供 extract_mode='html' 使用
|
||||
return { success: true, html, text, intercepted: false, reason: '' };
|
||||
} catch (err) {
|
||||
const errorMsg = (err as Error).message;
|
||||
if (attempt < maxRetries - 1) {
|
||||
@@ -149,11 +162,11 @@ export class WebFetchTool implements IMetonaTool {
|
||||
await this.sleep(backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6);
|
||||
continue;
|
||||
}
|
||||
return { success: false, text: '', intercepted: false, reason: errorMsg };
|
||||
return { success: false, html: '', text: '', intercepted: false, reason: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, text: '', intercepted: false, reason: 'All retries exhausted' };
|
||||
return { success: false, html: '', text: '', intercepted: false, reason: 'All retries exhausted' };
|
||||
}
|
||||
|
||||
// ===== Phase 2/3: 浏览器回退(使用共享 BrowserWindowManager 单例) =====
|
||||
@@ -214,13 +227,29 @@ export class WebFetchTool implements IMetonaTool {
|
||||
|
||||
// ===== 辅助方法 =====
|
||||
|
||||
private buildSuccess(url: string, text: string, method: string): unknown {
|
||||
private buildSuccess(
|
||||
url: string,
|
||||
text: string,
|
||||
method: string,
|
||||
maxChars?: number,
|
||||
extractMode?: 'text' | 'html',
|
||||
): unknown {
|
||||
// H-3/H-4 修复: 应用 max_chars 截断,防止过长内容消耗过多 token
|
||||
let content = text;
|
||||
let truncated = false;
|
||||
if (maxChars !== undefined && maxChars > 0 && text.length > maxChars) {
|
||||
content = text.slice(0, maxChars) + `\n\n[... content truncated at ${maxChars} chars ...]`;
|
||||
truncated = true;
|
||||
}
|
||||
return {
|
||||
url,
|
||||
content: text,
|
||||
content,
|
||||
success: true,
|
||||
method,
|
||||
length: text.length,
|
||||
length: content.length,
|
||||
original_length: text.length,
|
||||
truncated,
|
||||
extract_mode: extractMode ?? 'text',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -97,12 +97,15 @@ export class ToolRegistry {
|
||||
const startTs = Date.now();
|
||||
const timeoutMs = tool.definition.timeoutMs;
|
||||
|
||||
// M-15 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积
|
||||
// 工具正常完成时未触发的 timer 会持续占用事件循环 timeoutMs 毫秒
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
// 带超时执行 — 使用 Promise.race 防止工具卡死阻塞 Agent Loop
|
||||
const result = await Promise.race([
|
||||
tool.execute(toolCall.args, context),
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`Tool execution timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
@@ -130,6 +133,9 @@ export class ToolRegistry {
|
||||
durationMs: Date.now() - startTs,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} finally {
|
||||
// M-15 修复: 无论工具成功或失败,清理 timeout timer
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user