feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
@@ -22,7 +22,20 @@
|
||||
* @see standard/开发规范.md — 使用 fs/path 内置模块
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, readdir, stat, mkdir, open, unlink, rmdir, rm, rename, realpath, type FileHandle } from 'fs/promises';
|
||||
import {
|
||||
readFile,
|
||||
writeFile,
|
||||
readdir,
|
||||
stat,
|
||||
mkdir,
|
||||
open,
|
||||
unlink,
|
||||
rmdir,
|
||||
rm,
|
||||
rename,
|
||||
realpath,
|
||||
type FileHandle,
|
||||
} from 'fs/promises';
|
||||
import { join, relative, resolve, dirname } from 'path';
|
||||
import { existsSync, realpathSync } from 'fs';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||
@@ -38,6 +51,7 @@ import {
|
||||
decodeBufferWithDetection,
|
||||
MAX_FILE_SIZE_BYTES,
|
||||
FILE_TOOL_TIMEOUT_MS,
|
||||
isPotentiallyCatastrophicRegex,
|
||||
MAX_LINE_LENGTH,
|
||||
} from './file-guard';
|
||||
|
||||
@@ -66,7 +80,11 @@ async function isBinaryFile(filePath: string): Promise<boolean> {
|
||||
} finally {
|
||||
// M-20 修复: 无论 read 成功或失败,都关闭文件句柄
|
||||
if (fd) {
|
||||
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
|
||||
try {
|
||||
await fd.close();
|
||||
} catch {
|
||||
/* 忽略关闭错误 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,9 +108,20 @@ export class ReadFileTool implements IMetonaTool {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: 'Absolute or relative path to the file to read' },
|
||||
offset: { type: 'number', description: 'Start line number (1-indexed, default 1). Ignored if tail is specified.' },
|
||||
limit: { type: 'number', description: 'Maximum lines to read (default 500, max 2000). Ignored if tail is specified.' },
|
||||
tail: { type: 'number', description: 'Read the last N lines from the file. Takes precedence over offset/limit. Useful for reading log tails. Max 2000.' },
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Start line number (1-indexed, default 1). Ignored if tail is specified.',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Maximum lines to read (default 500, max 2000). Ignored if tail is specified.',
|
||||
},
|
||||
tail: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Read the last N lines from the file. Takes precedence over offset/limit. Useful for reading log tails. Max 2000.',
|
||||
},
|
||||
},
|
||||
required: ['file_path'],
|
||||
},
|
||||
@@ -108,9 +137,10 @@ export class ReadFileTool implements IMetonaTool {
|
||||
const offset = Math.max(1, (args.offset as number) ?? 1);
|
||||
const limit = Math.min(2000, Math.max(1, (args.limit as number) ?? 500));
|
||||
// F2-2: tail 模式 — 从文件末尾读取 N 行(优先于 offset/limit)
|
||||
const tail = args.tail !== undefined
|
||||
? Math.min(2000, Math.max(1, Math.floor(args.tail as number)))
|
||||
: undefined;
|
||||
const tail =
|
||||
args.tail !== undefined
|
||||
? Math.min(2000, Math.max(1, Math.floor(args.tail as number)))
|
||||
: undefined;
|
||||
|
||||
// v0.3.2: 文件存在性 + 大小预检(先 stat 再决定是否读取,避免大文件 OOM)
|
||||
let stats;
|
||||
@@ -137,7 +167,8 @@ export class ReadFileTool implements IMetonaTool {
|
||||
returned_lines: 0,
|
||||
truncated: false,
|
||||
file_size: stats.size,
|
||||
error: 'Binary file detected. Use view_image tool for images, or run_command for other binary content.',
|
||||
error:
|
||||
'Binary file detected. Use view_image tool for images, or run_command for other binary content.',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
@@ -192,13 +223,17 @@ export class WriteFileTool implements IMetonaTool {
|
||||
readonly definition: MetonaToolDef = {
|
||||
name: 'write_file',
|
||||
description:
|
||||
'Write content to a file. Creates the file if it doesn\'t exist, overwrites if it does. Supports append mode. Uses atomic write (temp file + rename) for safety. Content size limit: 10MB.',
|
||||
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Supports append mode. Uses atomic write (temp file + rename) for safety. Content size limit: 10MB.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: 'Path to the file to write' },
|
||||
content: { type: 'string', description: 'Content to write to the file' },
|
||||
mode: { type: 'string', description: 'Write mode: "overwrite" (default) or "append"', enum: ['overwrite', 'append'] },
|
||||
mode: {
|
||||
type: 'string',
|
||||
description: 'Write mode: "overwrite" (default) or "append"',
|
||||
enum: ['overwrite', 'append'],
|
||||
},
|
||||
},
|
||||
required: ['file_path', 'content'],
|
||||
},
|
||||
@@ -216,7 +251,10 @@ export class WriteFileTool implements IMetonaTool {
|
||||
|
||||
// v0.3.2: content 参数必须提供(undefined 时报错),但允许空字符串(用于创建空文件)
|
||||
if (args.content === undefined) {
|
||||
return { error: 'content is required (use empty string to create empty file)', success: false };
|
||||
return {
|
||||
error: 'content is required (use empty string to create empty file)',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// v0.3.2: 内容大小上限(防止 OOM)
|
||||
@@ -242,24 +280,56 @@ export class WriteFileTool implements IMetonaTool {
|
||||
// - 大内容(> 4KB)非完全原子:可能被分成多个 write
|
||||
// 改进:显式 open + 循环 write + fsync + close,保证数据持久化到磁盘
|
||||
// 保持 append 语义(O_APPEND 内核级追加),不改变并发行为
|
||||
// v0.7.4 P2-4: append 模式 TOCTOU 防护 —— safeResolvePath 校验时目标可能
|
||||
// 是普通文件(realpath 通过),但 open(filePath,'a') 跟随符号链接;攻击者
|
||||
// 在校验后把目标替换为指向工作空间外文件的 symlink,O_APPEND 会写入外部
|
||||
// 文件。仿 delete_file 的 #20 双 realpath 比对:open 前记录 realpath 与
|
||||
// 工作空间边界比对,open 后(写入前)再次比对,任一不一致即拒绝。
|
||||
const fileExisted = existsSync(filePath);
|
||||
const oldSize = fileExisted ? (await stat(filePath)).size : 0;
|
||||
|
||||
// v0.7.4 P2-4 修正: 统一 TOCTOU 校验 —— 已存在与新文件分支都走
|
||||
// "open 后 realpath 边界校验"。旧实现新文件分支(!fileExisted)无校验:
|
||||
// 攻击者在 existsSync=false 与 open('a') 之间放置指向工作空间外文件的
|
||||
// symlink,O_APPEND 会跟随写入外部文件(正是本防护要防的形态)。
|
||||
// 统一流程:open → realpath(filePath) → isPathWithinWorkspace 校验 →
|
||||
// 校验通过才写入;失败拒绝并关闭 fd。
|
||||
let fd: FileHandle | null = null;
|
||||
try {
|
||||
fd = await open(filePath, 'a');
|
||||
// 'a' 模式下 write 追加到末尾(O_APPEND 内核级保证)
|
||||
// 循环写入确保大内容完整写入(单次 write 可能不完整)
|
||||
// open 后(写入前)realpath 校验 —— 防止目标被替换为工作空间外 symlink
|
||||
let realAfter: string;
|
||||
try {
|
||||
realAfter = realpathSync(filePath);
|
||||
} catch {
|
||||
realAfter = filePath;
|
||||
}
|
||||
if (!isPathWithinWorkspace(realAfter, context.workspacePath)) {
|
||||
return {
|
||||
error: 'Path escape detected (TOCTOU protection)',
|
||||
path: filePath,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
// 校验通过后正式执行追加写入
|
||||
const buffer = Buffer.from(content, 'utf-8');
|
||||
let totalWritten = 0;
|
||||
while (totalWritten < buffer.length) {
|
||||
const { bytesWritten } = await fd.write(buffer, totalWritten, buffer.length - totalWritten);
|
||||
const { bytesWritten } = await fd.write(
|
||||
buffer,
|
||||
totalWritten,
|
||||
buffer.length - totalWritten,
|
||||
);
|
||||
totalWritten += bytesWritten;
|
||||
}
|
||||
await fd.sync(); // fsync 保证数据持久化到磁盘
|
||||
} finally {
|
||||
if (fd) {
|
||||
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
|
||||
try {
|
||||
await fd.close();
|
||||
} catch {
|
||||
/* 忽略关闭错误 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +353,11 @@ export class WriteFileTool implements IMetonaTool {
|
||||
} catch (err) {
|
||||
// 原子写入失败,清理临时文件
|
||||
if (existsSync(tmpPath)) {
|
||||
try { await unlink(tmpPath); } catch { /* 忽略清理错误 */ }
|
||||
try {
|
||||
await unlink(tmpPath);
|
||||
} catch {
|
||||
/* 忽略清理错误 */
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -312,8 +386,15 @@ export class ListDirectoryTool implements IMetonaTool {
|
||||
properties: {
|
||||
dir_path: { type: 'string', description: 'Directory path (default: workspace root)' },
|
||||
depth: { type: 'number', description: 'Recursive depth (default 1, max 5)' },
|
||||
glob: { type: 'string', description: 'Filename filter pattern. Supports comma-separated multi-glob (e.g., "*.ts" or "*.ts,*.js,*.tsx")' },
|
||||
include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' },
|
||||
glob: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Filename filter pattern. Supports comma-separated multi-glob (e.g., "*.ts" or "*.ts,*.js,*.tsx")',
|
||||
},
|
||||
include_hidden: {
|
||||
type: 'boolean',
|
||||
description: 'Include hidden files/dirs starting with "." (default false)',
|
||||
},
|
||||
},
|
||||
},
|
||||
category: MetonaToolCategory.FILESYSTEM,
|
||||
@@ -333,7 +414,13 @@ export class ListDirectoryTool implements IMetonaTool {
|
||||
|
||||
// v0.3.2 修复 WARN-3: listDir 内部提前终止,避免大目录全量遍历
|
||||
const MAX_ENTRIES = 1000;
|
||||
const entries: Array<{ name: string; path: string; type: string; size?: number; modified?: string }> = [];
|
||||
const entries: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
type: string;
|
||||
size?: number;
|
||||
modified?: string;
|
||||
}> = [];
|
||||
await this.listDir(dirPath, dirPath, depth, glob, includeHidden, 0, entries, MAX_ENTRIES);
|
||||
return {
|
||||
entries,
|
||||
@@ -377,7 +464,16 @@ export class ListDirectoryTool implements IMetonaTool {
|
||||
// 目录始终列出(不受 glob 过滤),保证递归可进入子目录
|
||||
results.push({ name: entry.name, path: relativePath, type: 'directory' });
|
||||
if (currentDepth < maxDepth - 1) {
|
||||
await this.listDir(rootPath, fullPath, maxDepth, glob, includeHidden, currentDepth + 1, results, maxEntries);
|
||||
await this.listDir(
|
||||
rootPath,
|
||||
fullPath,
|
||||
maxDepth,
|
||||
glob,
|
||||
includeHidden,
|
||||
currentDepth + 1,
|
||||
results,
|
||||
maxEntries,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// F2-4: glob 过滤仅适用于文件,支持多 glob(逗号分隔,如 "*.ts,*.js")
|
||||
@@ -409,13 +505,31 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pattern: { type: 'string', description: 'Search pattern (regex for content, glob for filenames)' },
|
||||
target: { type: 'string', description: '"content" (default) to search file contents, "files" to search filenames', enum: ['content', 'files'] },
|
||||
pattern: {
|
||||
type: 'string',
|
||||
description: 'Search pattern (regex for content, glob for filenames)',
|
||||
},
|
||||
target: {
|
||||
type: 'string',
|
||||
description: '"content" (default) to search file contents, "files" to search filenames',
|
||||
enum: ['content', 'files'],
|
||||
},
|
||||
path: { type: 'string', description: 'Search directory (default: workspace root)' },
|
||||
file_glob: { type: 'string', description: 'Limit to specific file types. Supports comma-separated multi-glob (e.g., "*.py" or "*.ts,*.js,*.tsx")' },
|
||||
file_glob: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Limit to specific file types. Supports comma-separated multi-glob (e.g., "*.py" or "*.ts,*.js,*.tsx")',
|
||||
},
|
||||
limit: { type: 'number', description: 'Maximum results (default 50, max 200)' },
|
||||
context_lines: { type: 'number', description: 'Lines of context to show around content matches (default 0, max 5). Only for target="content".' },
|
||||
include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' },
|
||||
context_lines: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Lines of context to show around content matches (default 0, max 5). Only for target="content".',
|
||||
},
|
||||
include_hidden: {
|
||||
type: 'boolean',
|
||||
description: 'Include hidden files/dirs starting with "." (default false)',
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
},
|
||||
@@ -438,9 +552,24 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
const includeHidden = (args.include_hidden as boolean) ?? false;
|
||||
|
||||
if (target === 'files') {
|
||||
return await this.searchByFilename(searchPath, pattern, fileGlob, limit, includeHidden, context.workspacePath);
|
||||
return await this.searchByFilename(
|
||||
searchPath,
|
||||
pattern,
|
||||
fileGlob,
|
||||
limit,
|
||||
includeHidden,
|
||||
context.workspacePath,
|
||||
);
|
||||
}
|
||||
return await this.searchByContent(searchPath, pattern, fileGlob, limit, contextLines, includeHidden, context.workspacePath);
|
||||
return await this.searchByContent(
|
||||
searchPath,
|
||||
pattern,
|
||||
fileGlob,
|
||||
limit,
|
||||
contextLines,
|
||||
includeHidden,
|
||||
context.workspacePath,
|
||||
);
|
||||
} catch (error) {
|
||||
return { error: extractErrorMessage(error), success: false };
|
||||
}
|
||||
@@ -456,12 +585,18 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
): Promise<unknown> {
|
||||
const results: Array<{ path: string; name: string }> = [];
|
||||
|
||||
await this.walkDir(dirPath, async (filePath, name) => {
|
||||
if (results.length >= limit) return;
|
||||
if (matchGlob(name, pattern)) {
|
||||
results.push({ path: relative(dirPath, filePath), name });
|
||||
}
|
||||
}, fileGlob, includeHidden, workspacePath);
|
||||
await this.walkDir(
|
||||
dirPath,
|
||||
async (filePath, name) => {
|
||||
if (results.length >= limit) return;
|
||||
if (matchGlob(name, pattern)) {
|
||||
results.push({ path: relative(dirPath, filePath), name });
|
||||
}
|
||||
},
|
||||
fileGlob,
|
||||
includeHidden,
|
||||
workspacePath,
|
||||
);
|
||||
|
||||
return { results, count: results.length, success: true };
|
||||
}
|
||||
@@ -479,7 +614,24 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
|
||||
// 正则安全加固:限制 pattern 长度 + try-catch 防止 ReDoS
|
||||
if (pattern.length > 500) {
|
||||
return { results: [], count: 0, error: 'Search pattern too long (max 500 chars)', success: false };
|
||||
return {
|
||||
results: [],
|
||||
count: 0,
|
||||
error: 'Search pattern too long (max 500 chars)',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
// v0.7.4 P2-7: 灾难性正则(ReDoS)拦截 —— 与 file_editor 共用共享检测。
|
||||
// 旧实现仅限制长度,`(a+)+$` 对超长行(单行可达 10MB 文件内)可指数级回溯
|
||||
// 阻塞主进程事件循环。
|
||||
if (isPotentiallyCatastrophicRegex(pattern)) {
|
||||
return {
|
||||
results: [],
|
||||
count: 0,
|
||||
error:
|
||||
'Search pattern rejected: potentially catastrophic regex (ReDoS risk). Please simplify the pattern.',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
let regex: RegExp;
|
||||
try {
|
||||
@@ -488,50 +640,56 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
return { results: [], count: 0, error: `Invalid regex pattern: ${pattern}`, success: false };
|
||||
}
|
||||
|
||||
await this.walkDir(dirPath, async (filePath) => {
|
||||
if (results.length >= limit) return;
|
||||
// 跳过工作空间根目录的 MEMORY.md(受保护文件,使用完整路径精确匹配)
|
||||
if (isProtectedWorkspaceFile(filePath, workspacePath)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// v0.3.2: 跳过大文件(避免读取超大文件导致 OOM)
|
||||
const fileStats = await stat(filePath);
|
||||
if (fileStats.size > MAX_FILE_SIZE_BYTES) return;
|
||||
|
||||
// F2-3: 跳过二进制文件(避免读取乱码 + 提升性能)
|
||||
// 空文件不算二进制,直接放行(size=0 时 isBinaryFile 内部 bytesRead=0 返回 false)
|
||||
if (fileStats.size > 0 && await isBinaryFile(filePath)) return;
|
||||
|
||||
// F2-3: 用智能编码检测读取文件(支持 GBK 等非 UTF-8 编码)
|
||||
const buffer = await readFile(filePath);
|
||||
const { content } = decodeBufferWithDetection(buffer);
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (results.length >= limit) break;
|
||||
// v0.3.2 修复 regex bug: 每次匹配前重置 lastIndex,防止 g 标志导致漏匹配
|
||||
regex.lastIndex = 0;
|
||||
if (regex.test(lines[i])) {
|
||||
const match = lines[i].trim().slice(0, 200);
|
||||
// v0.3.2: context lines 支持
|
||||
let context: string[] | undefined;
|
||||
if (contextLines > 0) {
|
||||
const start = Math.max(0, i - contextLines);
|
||||
const end = Math.min(lines.length - 1, i + contextLines);
|
||||
context = lines.slice(start, end + 1);
|
||||
}
|
||||
results.push({
|
||||
path: relative(dirPath, filePath),
|
||||
line: i + 1,
|
||||
match,
|
||||
context,
|
||||
});
|
||||
}
|
||||
await this.walkDir(
|
||||
dirPath,
|
||||
async (filePath) => {
|
||||
if (results.length >= limit) return;
|
||||
// 跳过工作空间根目录的 MEMORY.md(受保护文件,使用完整路径精确匹配)
|
||||
if (isProtectedWorkspaceFile(filePath, workspacePath)) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// 跳过不可读文件
|
||||
}
|
||||
}, fileGlob, includeHidden, workspacePath);
|
||||
try {
|
||||
// v0.3.2: 跳过大文件(避免读取超大文件导致 OOM)
|
||||
const fileStats = await stat(filePath);
|
||||
if (fileStats.size > MAX_FILE_SIZE_BYTES) return;
|
||||
|
||||
// F2-3: 跳过二进制文件(避免读取乱码 + 提升性能)
|
||||
// 空文件不算二进制,直接放行(size=0 时 isBinaryFile 内部 bytesRead=0 返回 false)
|
||||
if (fileStats.size > 0 && (await isBinaryFile(filePath))) return;
|
||||
|
||||
// F2-3: 用智能编码检测读取文件(支持 GBK 等非 UTF-8 编码)
|
||||
const buffer = await readFile(filePath);
|
||||
const { content } = decodeBufferWithDetection(buffer);
|
||||
const lines = content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (results.length >= limit) break;
|
||||
// v0.3.2 修复 regex bug: 每次匹配前重置 lastIndex,防止 g 标志导致漏匹配
|
||||
regex.lastIndex = 0;
|
||||
if (regex.test(lines[i])) {
|
||||
const match = lines[i].trim().slice(0, 200);
|
||||
// v0.3.2: context lines 支持
|
||||
let context: string[] | undefined;
|
||||
if (contextLines > 0) {
|
||||
const start = Math.max(0, i - contextLines);
|
||||
const end = Math.min(lines.length - 1, i + contextLines);
|
||||
context = lines.slice(start, end + 1);
|
||||
}
|
||||
results.push({
|
||||
path: relative(dirPath, filePath),
|
||||
line: i + 1,
|
||||
match,
|
||||
context,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 跳过不可读文件
|
||||
}
|
||||
},
|
||||
fileGlob,
|
||||
includeHidden,
|
||||
workspacePath,
|
||||
);
|
||||
|
||||
return { results, count: results.length, success: true };
|
||||
}
|
||||
@@ -570,7 +728,16 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
if (entry.isDirectory()) {
|
||||
// #21 修复: 不跟随符号链接目录,防止循环遍历与越权访问
|
||||
if (!entry.isSymbolicLink()) {
|
||||
await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath, depth + 1, maxDepth, visited);
|
||||
await this.walkDir(
|
||||
fullPath,
|
||||
callback,
|
||||
fileGlob,
|
||||
includeHidden,
|
||||
workspacePath,
|
||||
depth + 1,
|
||||
maxDepth,
|
||||
visited,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx")
|
||||
@@ -616,7 +783,8 @@ export class DeleteFileTool implements IMetonaTool {
|
||||
file_path: { type: 'string', description: 'Path to the file or directory to delete' },
|
||||
recursive: {
|
||||
type: 'boolean',
|
||||
description: 'Allow recursive deletion of non-empty directories (default false). Use with caution.',
|
||||
description:
|
||||
'Allow recursive deletion of non-empty directories (default false). Use with caution.',
|
||||
},
|
||||
},
|
||||
required: ['file_path'],
|
||||
@@ -671,7 +839,11 @@ export class DeleteFileTool implements IMetonaTool {
|
||||
realBefore = resolvedPath;
|
||||
}
|
||||
if (!isPathWithinWorkspace(realBefore, context.workspacePath)) {
|
||||
return { error: 'Path escape detected (TOCTOU protection)', path: filePath, success: false };
|
||||
return {
|
||||
error: 'Path escape detected (TOCTOU protection)',
|
||||
path: filePath,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const stats = await stat(resolvedPath);
|
||||
@@ -685,7 +857,11 @@ export class DeleteFileTool implements IMetonaTool {
|
||||
realAfter = resolvedPath;
|
||||
}
|
||||
if (realAfter !== realBefore) {
|
||||
return { error: 'TOCTOU detected: file replaced during deletion', path: filePath, success: false };
|
||||
return {
|
||||
error: 'TOCTOU detected: file replaced during deletion',
|
||||
path: filePath,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
@@ -710,7 +886,12 @@ export class DeleteFileTool implements IMetonaTool {
|
||||
} catch (error) {
|
||||
const errMsg = extractErrorMessage(error);
|
||||
// 区分"目录非空"错误,给用户清晰提示
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOTEMPTY') {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as NodeJS.ErrnoException).code === 'ENOTEMPTY'
|
||||
) {
|
||||
return {
|
||||
error: 'Directory not empty. Set recursive: true to delete non-empty directories.',
|
||||
path: filePath,
|
||||
@@ -745,7 +926,10 @@ export class FileMoveTool implements IMetonaTool {
|
||||
properties: {
|
||||
source_path: { type: 'string', description: 'Path to the file/directory to move' },
|
||||
destination_path: { type: 'string', description: 'Destination path' },
|
||||
overwrite: { type: 'boolean', description: 'Overwrite if destination exists (default false)' },
|
||||
overwrite: {
|
||||
type: 'boolean',
|
||||
description: 'Overwrite if destination exists (default false)',
|
||||
},
|
||||
},
|
||||
required: ['source_path', 'destination_path'],
|
||||
},
|
||||
@@ -917,7 +1101,11 @@ export class FileInfoTool implements IMetonaTool {
|
||||
// 读取失败,不报告编码信息
|
||||
} finally {
|
||||
if (fd) {
|
||||
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
|
||||
try {
|
||||
await fd.close();
|
||||
} catch {
|
||||
/* 忽略关闭错误 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user