Files
metona-ai-desktop/electron/harness/tools/built-in/file-editor.ts
T
thzxx 4554177db0 feat: 升级至 v0.3.3 — 新增 delete_file 工具 + 文件工具全面优化
## 主要变更

### 1. 新增 delete_file 工具(26 → 27 个)
- 支持:删除文件/空目录(默认)/递归删除非空目录(recursive: true)
- 5 层安全防护:
  * isPathWithinWorkspace — 路径遍历防护
  * isProtectedWorkspaceFile — MEMORY.md 拦截
  * 工作空间根目录保护 — 禁止删除 workspace 本身
  * 递归删除需显式开启 — 默认仅删空目录
  * riskLevel: HIGH + requiresPermission — 破坏性操作必须确认
- 友好错误处理:ENOTEMPTY 时提示设置 recursive: true

### 2. 文件工具全面优化(6 个工具)
- 抽取共享代码到 file-guard.ts:
  * safeResolvePath — 合并路径遍历 + MEMORY.md 校验
  * matchGlob — 简易通配符匹配
  * extractErrorMessage — 统一错误提取
  * MAX_FILE_SIZE_BYTES (10MB) / FILE_TOOL_TIMEOUT_MS (15s) / MAX_LINE_LENGTH (10000)
- read_file:stat 预检 + 超长行截断 + 二进制检测 + 大小上限
- write_file:原子写入(临时文件+rename)+ 内容大小上限 + append 返回 new_file_size
- list_directory:1000 结果上限 + modified time + include_hidden 参数 + 提前终止优化
- search_files:regex lastIndex 修复 + context_lines + include_hidden + 大文件跳过
- file_editor:dry_run 预览模式 + 文件大小上限

### 3. 审计修复(1 FAIL + 3 WARN)
- FAIL: isBinaryFile 用 bytesRead 限制循环,修复 < 8KB 文本误判为二进制
- WARN-1: file-editor dry_run preview 分模式计算,修复 insert 范围过大
- WARN-2: write_file 允许空字符串创建空文件
- WARN-3: list_directory listDir 提前终止,避免大目录全量遍历
2026-07-14 21:42:46 +08:00

286 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.
/**
* 文件编辑工具(1 个)
*
* file_editor — 精准文件编辑,支持行替换/插入/删除/正则替换
*
* 相比 write_file 的整文件覆盖,file_editor 支持精准定位修改,
* 避免大文件全量重写,减少 token 消耗和出错风险。
*
* v0.3.2 优化:
* - 使用共享 safeResolvePath(消除重复代码)
* - 使用共享 extractErrorMessage、FILE_TOOL_TIMEOUT_MS、MAX_FILE_SIZE_BYTES
* - 新增 dry_run 模式(预览变更不写入)
* - 文件大小上限(防止 OOM)
* - 统一 timeoutMs 为 15_000
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
*/
import { readFile, writeFile, rename, mkdir, stat, unlink } from 'fs/promises';
import { dirname } from 'path';
import { existsSync } from 'fs';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import {
safeResolvePath,
extractErrorMessage,
MAX_FILE_SIZE_BYTES,
FILE_TOOL_TIMEOUT_MS,
} from './file-guard';
export class FileEditorTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'file_editor',
description:
'Edit a file with precision. Supports operations: replace (replace lines), insert (insert at line), delete (delete lines), regex (regex replace in range). More efficient than write_file for targeted edits. Supports dry_run mode for preview. File size limit: 10MB.',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the file to edit' },
operation: {
type: 'string',
description: 'Edit operation',
enum: ['replace', 'insert', 'delete', 'regex'],
},
start_line: { type: 'number', description: 'Start line number (1-indexed). Used by replace/insert/delete/regex' },
end_line: { type: 'number', description: 'End line number (inclusive). Used by replace/delete' },
content: { type: 'string', description: 'New content for replace/insert operations' },
pattern: { type: 'string', description: 'Regex pattern for regex operation' },
replacement: { type: 'string', description: 'Replacement string for regex operation' },
flags: { type: 'string', description: 'Regex flags (default "g")' },
dry_run: { type: 'boolean', description: 'Preview mode: return the diff without writing to file (default false)' },
},
required: ['file_path', 'operation'],
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: true,
timeoutMs: FILE_TOOL_TIMEOUT_MS,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
try {
// F1.5: file_path 空值校验
if (!args.file_path || typeof args.file_path !== 'string') {
return { success: false, error: 'file_path is required' };
}
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
const operation = args.operation as 'replace' | 'insert' | 'delete' | 'regex';
const dryRun = (args.dry_run as boolean) ?? false;
// 文件必须存在(不支持创建新文件,请用 write_file
if (!existsSync(filePath)) {
return { success: false, error: `File not found: ${args.file_path}. Use write_file to create new files.` };
}
// v0.3.2: 文件大小上限(防止 OOM)
const stats = await stat(filePath);
if (stats.size > MAX_FILE_SIZE_BYTES) {
return {
success: false,
error: `File too large (${stats.size} bytes, max ${MAX_FILE_SIZE_BYTES} bytes). Consider using write_file to rewrite.`,
};
}
const originalContent = await readFile(filePath, 'utf-8');
const lines = originalContent.split('\n');
let newLines: string[];
let affectedRange: { start: number; end: number };
let replaceCount = 0; // v0.3.2: 统一在外层声明,供 dry_run 返回
switch (operation) {
case 'replace': {
const startLine = Math.max(1, (args.start_line as number) ?? 1);
const endLine = Math.min(lines.length, (args.end_line as number) ?? startLine);
if (endLine < startLine) {
return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` };
}
const content = (args.content as string) ?? '';
const contentLines = content.split('\n');
newLines = [
...lines.slice(0, startLine - 1),
...contentLines,
...lines.slice(endLine),
];
affectedRange = { start: startLine, end: endLine };
replaceCount = endLine - startLine + 1;
break;
}
case 'insert': {
let startLine = Math.max(1, (args.start_line as number) ?? 1);
startLine = Math.min(startLine, lines.length + 1); // 允许追加到末尾
const content = (args.content as string) ?? '';
const contentLines = content.split('\n');
newLines = [
...lines.slice(0, startLine - 1),
...contentLines,
...lines.slice(startLine - 1),
];
affectedRange = { start: startLine, end: startLine + contentLines.length - 1 };
replaceCount = contentLines.length;
break;
}
case 'delete': {
const startLine = Math.max(1, (args.start_line as number) ?? 1);
const endLine = Math.min(lines.length, (args.end_line as number) ?? startLine);
if (endLine < startLine) {
return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` };
}
newLines = [
...lines.slice(0, startLine - 1),
...lines.slice(endLine),
];
affectedRange = { start: startLine, end: endLine };
replaceCount = endLine - startLine + 1;
break;
}
case 'regex': {
const pattern = args.pattern as string;
const replacement = (args.replacement as string) ?? '';
const flags = (args.flags as string) ?? 'g';
if (!pattern) {
return { success: false, error: 'Regex operation requires "pattern" parameter' };
}
// F1.3: regex pattern 长度限制
if (pattern.length > 500) {
return { success: false, error: 'Regex pattern too long (max 500 chars)' };
}
const startLine = Math.max(1, (args.start_line as number) ?? 1);
const endLine = Math.min(lines.length, (args.end_line as number) ?? lines.length);
if (endLine < startLine) {
return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` };
}
let regex: RegExp;
try {
regex = new RegExp(pattern, flags);
} catch (err) {
return { success: false, error: `Invalid regex: ${(err as Error).message}` };
}
// F1.4: 用带 g 标志的正则统计匹配数
const targetLines = lines.slice(startLine - 1, endLine);
const countRegex = new RegExp(pattern, flags.includes('g') ? flags : flags + 'g');
const replacedLines = targetLines.map((line) => {
const matches = line.match(countRegex);
if (matches) replaceCount += matches.length;
return line.replace(regex, replacement);
});
newLines = [
...lines.slice(0, startLine - 1),
...replacedLines,
...lines.slice(endLine),
];
affectedRange = { start: startLine, end: endLine };
// 如果没有替换,返回提示
if (replaceCount === 0) {
return {
success: true,
operation,
file_path: args.file_path,
message: 'No matches found for regex pattern',
replacements: 0,
affected_range: affectedRange,
dry_run: dryRun,
};
}
break;
}
default:
return { success: false, error: `Unknown operation: ${operation}` };
}
// v0.3.2: dry_run 模式 — 预览变更不写入
if (dryRun) {
// v0.3.2 修复 WARN-1: 分模式计算 preview 范围
// - insert: affectedRange.end 是新文件行号,原文件无被替换内容 → original 为空
// - replace/delete/regex: affectedRange.end 是原文件行号,original/modified 取相同范围
let originalPreview: string;
let modifiedPreview: string;
if (operation === 'insert') {
// insert: 原文件无被替换内容;新文件显示插入的内容
originalPreview = '';
modifiedPreview = newLines.slice(
affectedRange.start - 1,
Math.min(affectedRange.end, newLines.length),
).join('\n');
} else {
// replace/delete/regex: 原文件取 [start-1, end) 区间
originalPreview = lines.slice(
affectedRange.start - 1,
Math.min(affectedRange.end, lines.length),
).join('\n');
// 新文件取相同范围 + 行数差修正(replace 可能改变行数,delete 后该位置为空)
const lineDelta = newLines.length - lines.length;
const modifiedEnd = Math.min(
affectedRange.end + lineDelta,
newLines.length,
);
modifiedPreview = newLines.slice(
affectedRange.start - 1,
Math.max(affectedRange.start - 1, modifiedEnd),
).join('\n');
}
return {
success: true,
dry_run: true,
operation,
file_path: args.file_path,
affected_range: affectedRange,
replacements: replaceCount,
lines_before: lines.length,
lines_after: newLines.length,
preview: {
original: originalPreview,
modified: modifiedPreview,
},
};
}
// 自动创建父目录 (F1.8: 异步 mkdir)
const parentDir = dirname(filePath);
if (!existsSync(parentDir)) {
await mkdir(parentDir, { recursive: true });
}
const newContent = newLines.join('\n');
// F1.7: 原子写入 - 临时文件 + rename
const tmpPath = `${filePath}.tmp_${Date.now()}`;
try {
await writeFile(tmpPath, newContent, 'utf-8');
await rename(tmpPath, filePath);
} catch (err) {
// 原子写入失败,清理临时文件
if (existsSync(tmpPath)) {
try { await unlink(tmpPath); } catch { /* 忽略清理错误 */ }
}
throw err;
}
return {
success: true,
operation,
file_path: args.file_path,
affected_range: affectedRange,
replacements: replaceCount,
lines_before: lines.length,
lines_after: newLines.length,
bytes_written: Buffer.byteLength(newContent, 'utf-8'),
};
} catch (err) {
return { success: false, error: extractErrorMessage(err) };
}
}
}