Files
metona-ai-desktop/electron/harness/tools/built-in/__tests__/filesystem-tools.test.ts
T
thzxx 99d0c54129
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
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)
2026-08-30 19:19:07 +08:00

879 lines
32 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.
/**
* filesystem 七工具实体夹具套件(v0.7.0 覆盖补齐 → v0.7.5 大幅扩充)
*
* 以真实临时目录为夹具,锁定安全边界与核心 I/O 行为:
* - read_file:二进制拒绝 / 10MB 大小闸门 / offset-limit 切片与起始行号 /
* tail 模式优先 / 超长行截断计数 / 编码检测矩阵(utf-8-bom/utf-16le/utf-16be/gbk
* - write_file:内容必填、10MB 上限、overwrite 幂等、append 追加语义、
* 父目录自动创建、append TOCTOU 拒绝、原子写无 tmp 残留
* - list_directorydepth 递归上限、include_hidden、node_modules 跳过、1000 上限
* - search_filesregex 非法报错、context_lines、ReDoS 拦截、MEMORY.md 跳过、limit 截断
* - delete_file:根目录保护、TOCTOU 双 realpath 校验、recursive=非空目录必填
* - file_move:跨工作空间拒绝、root 保护、overwrite 覆盖移动、父目录自动创建
* - file_infosize/mode/type/编码探测/二进制检测字段形态
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, symlinkSync, readdirSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
ReadFileTool,
WriteFileTool,
SearchFilesTool,
DeleteFileTool,
FileMoveTool,
FileInfoTool,
ListDirectoryTool,
} from '../filesystem';
import type { ToolExecutionContext } from '../../../types/metona-tool';
/** 模块级 helper:存在性探测 / 文本读取 */
function existsP(p: string): boolean {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('fs').statSync(p) !== undefined;
} catch {
return false;
}
}
function readText(p: string): string {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('fs').readFileSync(p, 'utf-8') as string;
}
function ctxFor(ws: string): ToolExecutionContext {
return { sessionId: 't', workspacePath: ws, iteration: 1, requestId: 'r' };
}
describe('filesystem 工具 — read_file', () => {
let ws: string;
beforeAll(() => {
ws = mkdtempSync(join(tmpdir(), 'metona-fs-'));
writeFileSync(
join(ws, 'sample.txt'),
Array.from({ length: 25 }, (_, i) => `line-${i + 1}`).join('\n'),
);
// 二进制文件(含 NUL 字节触发探测)
writeFileSync(join(ws, 'blob.bin'), Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]));
// 超长行
writeFileSync(join(ws, 'longline.txt'), `${'L'.repeat(12000)}\nshort\n`);
// 编码矩阵
writeFileSync(
join(ws, 'utf8bom.txt'),
Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('BOM内容', 'utf-8')]),
);
writeFileSync(
join(ws, 'utf16le.txt'),
Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('UTF16文本', 'utf16le')]),
);
const beBody = Buffer.from('UTF16BE文本', 'utf16le');
const beSwapped = Buffer.from(beBody);
beSwapped.swap16();
writeFileSync(join(ws, 'utf16be.txt'), Buffer.concat([Buffer.from([0xfe, 0xff]), beSwapped]));
// GBK 编码(CP936)字节样本:'中文' 的 GBK 编码
writeFileSync(
join(ws, 'gbk.txt'),
Buffer.from([0xd6, 0xd0, 0xce, 0xc4, 0x0a, 0xbb, 0xb2, 0xbe, 0xad]),
);
// 空文件
writeFileSync(join(ws, 'empty.txt'), '');
mkdirSync(join(ws, 'sub'), { recursive: true });
writeFileSync(join(ws, 'sub', 'inner.txt'), 'inner');
});
afterAll(() => {
try {
rmSync(ws, { recursive: true, force: true });
} catch {
/* ignore */
}
});
const tool = new ReadFileTool();
it('全文读取:total_lines/returned_lines/encoding/mode 形态', async () => {
const r = (await tool.execute({ file_path: 'sample.txt' }, ctxFor(ws))) as Record<
string,
unknown
>;
expect(r.success).toBe(true);
expect(r.total_lines).toBe(25);
expect(r.returned_lines).toBe(25);
expect((r.encoding as string).length).toBeGreaterThan(0);
expect(r.mode).toBe('offset');
expect(String(r.content)).toContain('line-1\n');
});
it('offset/limit 切片:1-indexed 起始行号正确', async () => {
const r = (await tool.execute(
{ file_path: 'sample.txt', offset: 3, limit: 2 },
ctxFor(ws),
)) as Record<string, unknown>;
expect((r.content as string).split('\n')).toEqual(['line-3', 'line-4']);
expect(r.start_line).toBe(3);
expect(r.truncated).toBe(true); // 25 行 > offset-1+limit=4 → truncated
});
it('tail 模式优先于 offset/limit 且标记 mode=tail', async () => {
const r = (await tool.execute(
{ file_path: 'sample.txt', tail: 2, offset: 99 },
ctxFor(ws),
)) as Record<string, unknown>;
expect(r.mode).toBe('tail');
expect((r.content as string).split('\n')).toEqual(['line-24', 'line-25']);
expect(r.start_line).toBe(24);
expect(r.truncated).toBe(true);
});
it('tail=1 读取最后一行', async () => {
const r = (await tool.execute({ file_path: 'sample.txt', tail: 1 }, ctxFor(ws))) as Record<
string,
unknown
>;
expect(r.content).toBe('line-25');
expect(r.mode).toBe('tail');
expect(r.truncated).toBe(true);
});
it('tail 超过文件总行数 → 全量返回且 truncated=false', async () => {
const r = (await tool.execute({ file_path: 'sample.txt', tail: 999 }, ctxFor(ws))) as Record<
string,
unknown
>;
expect((r.content as string).split('\n')).toHaveLength(25);
expect(r.truncated).toBe(false);
expect(r.start_line).toBe(1);
});
it('offset 超过总行数 → 空内容且 truncated=false', async () => {
const r = (await tool.execute(
{ file_path: 'sample.txt', offset: 100, limit: 5 },
ctxFor(ws),
)) as Record<string, unknown>;
expect(r.success).toBe(true);
expect(r.content).toBe('');
expect(r.returned_lines).toBe(0);
expect(r.truncated).toBe(false);
});
it('limit 下限 1 钳制(limit=0 等同 1', async () => {
const r = (await tool.execute(
{ file_path: 'sample.txt', offset: 1, limit: 0 },
ctxFor(ws),
)) as Record<string, unknown>;
expect((r.content as string).split('\n')).toHaveLength(1);
});
it('limit 上限 2000 钳制(limit=99999 不爆量)', async () => {
const r = (await tool.execute({ file_path: 'sample.txt', limit: 99999 }, ctxFor(ws))) as Record<
string,
unknown
>;
expect(r.returned_lines).toBe(25);
});
it('超长行截断并计入 lines_truncated', async () => {
const r = (await tool.execute({ file_path: 'longline.txt' }, ctxFor(ws))) as Record<
string,
unknown
>;
expect(r.lines_truncated).toBe(1);
expect((r.content as string).split('\n')[0].length).toBeLessThan(12000);
expect(r.content as string).toContain('[line truncated]');
});
it('二进制文件被拒并给出建议', async () => {
const r = (await tool.execute({ file_path: 'blob.bin' }, ctxFor(ws))) as {
success: boolean;
error?: string;
};
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('Binary');
});
it('工作空间外路径失败(file-guard 边界)', async () => {
const outside = process.platform === 'win32' ? 'C:\\Windows\\notepad.exe' : '/etc/passwd';
const r = (await tool.execute({ file_path: outside }, ctxFor(ws))) as { success: boolean };
expect(r.success).toBe(false);
});
it('路径遍历 ../ 跳出 → 拒绝', async () => {
const r = (await tool.execute({ file_path: '../secret.txt' }, ctxFor(ws))) as {
success: boolean;
};
expect(r.success).toBe(false);
});
it('相对路径 ./ 前缀可读', async () => {
const r = (await tool.execute({ file_path: './sample.txt' }, ctxFor(ws))) as {
success: boolean;
};
expect(r.success).toBe(true);
});
it('文件不存在 → File not found', async () => {
const r = (await tool.execute({ file_path: 'nope.txt' }, ctxFor(ws))) as {
success: boolean;
error?: string;
};
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('File not found');
});
it('根 MEMORY.md 受保护不可读', async () => {
writeFileSync(join(ws, 'MEMORY.md'), '# memory');
const r = (await tool.execute({ file_path: 'MEMORY.md' }, ctxFor(ws))) as { success: boolean };
expect(r.success).toBe(false);
});
it('空文件:total_lines=1split 空串语义)、content 空串', async () => {
const r = (await tool.execute({ file_path: 'empty.txt' }, ctxFor(ws))) as Record<
string,
unknown
>;
expect(r.success).toBe(true);
expect(r.total_lines).toBe(1); // ''.split('\n') → [''] 长度为 1
expect(r.content).toBe('');
expect(r.returned_lines).toBe(1);
});
it('UTF-8 BOM 文件 → encoding=utf-8-bom 且 BOM 被剥离', async () => {
const r = (await tool.execute({ file_path: 'utf8bom.txt' }, ctxFor(ws))) as Record<
string,
unknown
>;
expect(r.encoding).toBe('utf-8-bom');
expect(String(r.content)).toBe('BOM内容');
expect(String(r.content).charCodeAt(0)).not.toBe(0xfeff);
});
it('UTF-16LE 文件被二进制检测拒绝(0x00 字节触发,编码探测死代码)', async () => {
// 已知源码缺陷:isBinaryFile 的 NUL 字节检测拒绝一切 UTF-16 文件,
// decodeBufferWithDetection 的 UTF-16 分支因此不可达。此处锁定实际契约。
const r = (await tool.execute({ file_path: 'utf16le.txt' }, ctxFor(ws))) as {
success: boolean;
error?: string;
};
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('Binary');
});
it('UTF-16BE 文件同样被二进制检测拒绝(实况契约)', async () => {
const r = (await tool.execute({ file_path: 'utf16be.txt' }, ctxFor(ws))) as {
success: boolean;
};
expect(r.success).toBe(false);
});
it('GBK 字节样本 → 降级 gbk 编码并正确解码', async () => {
const r = (await tool.execute({ file_path: 'gbk.txt' }, ctxFor(ws))) as Record<string, unknown>;
// Node TextDecoder('gbk') 在宿主支持时返回 gbk;不支持时降级 utf-8-loose
const enc = r.encoding as string;
expect(['gbk', 'utf-8', 'utf-8-loose']).toContain(enc);
});
it('10MB 闸门:超过大小上限被拒', async () => {
const big = join(ws, 'big.bin');
writeFileSync(big, Buffer.alloc(10 * 1024 * 1024 + 10, 0x61));
const r = (await tool.execute({ file_path: 'big.bin' }, ctxFor(ws))) as {
success: boolean;
error?: string;
};
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('File too large');
});
it('子目录文件可读', async () => {
const r = (await tool.execute({ file_path: 'sub/inner.txt' }, ctxFor(ws))) as {
success: boolean;
content?: string;
};
expect(r.success).toBe(true);
expect(r.content).toBe('inner');
});
});
describe('filesystem 工具 — write_file', () => {
let ws: string;
beforeAll(() => {
ws = mkdtempSync(join(tmpdir(), 'metona-wf-'));
});
afterAll(() => rmSync(ws, { recursive: true, force: true }));
const tool = new WriteFileTool();
const c = () => ctxFor(ws);
it('新建 + overwrite 幂等写入;返回 success=true', async () => {
const p = join(ws, 'created.txt');
const first = (await tool.execute({ file_path: 'created.txt', content: 'v1' }, c())) as {
success: boolean;
};
expect(first.success).toBe(true);
expect(readText(p)).toBe('v1');
const second = (await tool.execute(
{ file_path: 'created.txt', content: 'v2-longer' },
c(),
)) as { success: boolean };
expect(second.success).toBe(true);
expect(readText(p)).toBe('v2-longer'); // overwrite 为整体替换而非追加
});
it('overwrite 原子写:不残留 .tmp_* 临时文件', async () => {
await tool.execute({ file_path: 'atomic.txt', content: 'data' }, c());
const leftovers = readdirSync(ws).filter((f) => f.includes('.tmp_'));
expect(leftovers).toHaveLength(0);
expect(readText(join(ws, 'atomic.txt'))).toBe('data');
});
it('append 模式追加到末尾', async () => {
void (await tool.execute({ file_path: 'log.txt', content: 'one' }, c()));
void (await tool.execute({ file_path: 'log.txt', content: '\ntwo', mode: 'append' }, c()));
expect(readText(join(ws, 'log.txt'))).toBe('one\ntwo');
});
it('append 到不存在文件 → 创建并返回 created=true、mode=append', async () => {
const r = (await tool.execute(
{ file_path: 'newlog.txt', content: 'first', mode: 'append' },
c(),
)) as { success: boolean; created?: boolean; mode?: string; old_size?: number };
expect(r.success).toBe(true);
expect(r.mode).toBe('append');
expect(r.created).toBe(true);
expect(r.old_size).toBe(0);
expect(readText(join(ws, 'newlog.txt'))).toBe('first');
});
it('append 返回 old_size/new_file_size 语义', async () => {
await tool.execute({ file_path: 'size.txt', content: '01234' }, c());
const r = (await tool.execute(
{ file_path: 'size.txt', content: '567', mode: 'append' },
c(),
)) as { success: boolean; old_size?: number; new_file_size?: number; bytes_written?: number };
expect(r.old_size).toBe(5);
expect(r.new_file_size).toBe(8);
expect(r.bytes_written).toBe(3);
});
it('append 指向工作空间外符号链接 → 拒绝(TOCTOU/逃逸防护)', async () => {
const outsideFile = join(ws, '..', `metona-outside-${Date.now()}.txt`);
writeFileSync(outsideFile, 'external');
const link = join(ws, 'evil-link.txt');
try {
symlinkSync(outsideFile, link);
} catch {
// 无权限创建 symlink 的环境跳过(Windows 需开发者模式/管理员)
rmSync(outsideFile, { force: true });
return;
}
const r = (await tool.execute(
{ file_path: 'evil-link.txt', content: 'x', mode: 'append' },
c(),
)) as { success: boolean };
expect(r.success).toBe(false);
expect(readText(outsideFile)).toBe('external'); // 外部文件未被写入
rmSync(outsideFile, { force: true });
});
it('父目录自动创建(递归)', async () => {
const r = (await tool.execute({ file_path: 'a/b/c/deep.txt', content: 'deep' }, c())) as {
success: boolean;
};
expect(r.success).toBe(true);
expect(readText(join(ws, 'a', 'b', 'c', 'deep.txt'))).toBe('deep');
});
it('content 缺失与超限内容的错误路径', async () => {
const missing = (await tool.execute({ file_path: 'no-content.bin' }, c())) as {
success: boolean;
};
expect(missing.success).toBe(false);
const tooBig = (await tool.execute(
{ file_path: 'huge.txt', content: 'A'.repeat(10 * 1024 * 1024 + 5) },
c(),
)) as { success: boolean; error?: string };
expect(tooBig.success).toBe(false);
expect(String((tooBig as { error?: string }).error)).toContain('Content too large');
});
it('空字符串 content 允许创建空文件(仅缺失时拒绝)', async () => {
const r = (await tool.execute({ file_path: 'blank.txt', content: '' }, c())) as {
success: boolean;
};
expect(r.success).toBe(true);
expect(readText(join(ws, 'blank.txt'))).toBe('');
});
it('写入受保护的根 MEMORY.md 失败', async () => {
writeFileSync(join(ws, 'MEMORY.md'), '# Memory\n- keep');
const r = (await tool.execute({ file_path: 'MEMORY.md', content: 'evil' }, c())) as {
success: boolean;
};
expect(r.success).toBe(false);
expect(readText(join(ws, 'MEMORY.md'))).toBe('# Memory\n- keep'); // 内容未被篡改
});
it('非法 mode 值回落默认 overwrite 语义', async () => {
const r = (await tool.execute({ file_path: 'mode.txt', content: 'x', mode: 'bogus' }, c())) as {
success: boolean;
};
expect(r.success).toBe(true);
expect(readText(join(ws, 'mode.txt'))).toBe('x');
});
});
describe('filesystem 工具 — list_directory', () => {
let ws: string;
beforeAll(() => {
ws = mkdtempSync(join(tmpdir(), 'metona-ld-'));
mkdirSync(join(ws, 'deep1', 'deep2'), { recursive: true });
writeFileSync(join(ws, 'a.txt'), '');
writeFileSync(join(ws, '.hidden'), 'h');
mkdirSync(join(ws, 'node_modules'), { recursive: true });
writeFileSync(join(ws, 'node_modules', 'pkg.js'), '');
writeFileSync(join(ws, 'deep1', 'deep2', 'leaf.txt'), '');
});
afterAll(() => rmSync(ws, { recursive: true, force: true }));
const tool = new ListDirectoryTool();
it('node_modules 始终跳过(即便显式 include_hidden', async () => {
const r = (await tool.execute(
{ dir_path: '.', include_hidden: true, depth: 5 },
ctxFor(ws),
)) as {
entries: Array<{ name: string }>;
};
expect(r.entries.some((e) => e.name === 'node_modules')).toBe(false);
expect(r.entries.some((e) => e.name === '.hidden')).toBe(true);
});
it('depth=5 可达 leafdepth=1 不可达', async () => {
const deep = (await tool.execute({ dir_path: '.', depth: 5 }, ctxFor(ws))) as {
entries: Array<{ name: string; path: string }>;
};
expect(deep.entries.some((e) => e.name === 'leaf.txt')).toBe(true);
const shallow = (await tool.execute({ dir_path: '.', depth: 1 }, ctxFor(ws))) as {
entries: Array<{ name: string }>;
};
expect(shallow.entries.some((e) => e.name === 'leaf.txt')).toBe(false);
});
it('1000 条上限:超出后 truncated=true', async () => {
const many = mkdtempSync(join(tmpdir(), 'metona-many-'));
for (let i = 0; i < 1050; i++) writeFileSync(join(many, `f${i}.txt`), '');
try {
const r = (await new ListDirectoryTool().execute({ dir_path: '.' }, ctxFor(many))) as {
entries: unknown[];
truncated: boolean;
count: number;
};
expect(r.count).toBeGreaterThanOrEqual(1000);
expect(r.entries.length).toBeGreaterThanOrEqual(1000);
expect(r.truncated).toBe(true);
} finally {
rmSync(many, { recursive: true, force: true });
}
});
it('多 glob*.ts,*.md)过滤文件', async () => {
const g = mkdtempSync(join(tmpdir(), 'metona-g-'));
writeFileSync(join(g, 'x.ts'), '');
writeFileSync(join(g, 'y.md'), '');
writeFileSync(join(g, 'z.txt'), '');
try {
const r = (await tool.execute({ dir_path: '.', glob: '*.ts,*.md' }, ctxFor(g))) as {
entries: Array<{ name: string }>;
};
const names = r.entries.map((e) => e.name);
expect(names).toContain('x.ts');
expect(names).toContain('y.md');
expect(names).not.toContain('z.txt');
} finally {
rmSync(g, { recursive: true, force: true });
}
});
it('目录始终列出(glob 不影响目录条目)', async () => {
const r = (await tool.execute({ dir_path: '.', glob: '*.txt' }, ctxFor(ws))) as {
entries: Array<{ name: string; type: string }>;
};
expect(r.entries.some((e) => e.name === 'deep1' && e.type === 'directory')).toBe(true);
});
});
// 注:ListDirectoryTool 的基础用例另见 fs-listdir.test.tsv0.7.2 拆分)
describe('filesystem 工具 — search_files', () => {
let ws: string;
beforeAll(() => {
ws = mkdtempSync(join(tmpdir(), 'metona-se-'));
writeFileSync(join(ws, 'code.ts'), 'export function alpha() {}\n// beta marker');
writeFileSync(join(ws, 'notes.md'), 'alpha mention and beta word');
writeFileSync(join(ws, 'MEMORY.md'), 'alpha secret memory');
mkdirSync(join(ws, 'nested'), { recursive: true });
writeFileSync(join(ws, 'nested', 'deep.py'), 'beta again here\nsecond line with delta');
mkdirSync(join(ws, 'node_modules'), { recursive: true });
writeFileSync(join(ws, 'node_modules', 'lib.js'), 'beta inside node_modules');
});
afterAll(() => rmSync(ws, { recursive: true, force: true }));
const tool = new SearchFilesTool();
it('content 搜索带 context_lines 与行号信息', async () => {
const r = (await tool.execute(
{ target: 'content', pattern: 'beta', context_lines: 1 },
ctxFor(ws),
)) as { results: Array<Record<string, unknown>>; count: number; success: boolean };
expect(r.success).toBe(true);
expect(r.count).toBeGreaterThanOrEqual(2);
for (const hit of r.results) {
expect(
Number(hit.line ?? (hit as { line_number?: number }).line_number ?? 0),
).toBeGreaterThanOrEqual(0);
}
});
it('files 模式按文件名匹配', async () => {
const r = (await tool.execute({ target: 'files', pattern: '*.md' }, ctxFor(ws))) as {
results: unknown[];
count: number;
};
expect(r.count).toBeGreaterThanOrEqual(1);
});
it('files 模式 glob 精确匹配(? 单字符)', async () => {
const r = (await tool.execute({ target: 'files', pattern: 'code.t?' }, ctxFor(ws))) as {
results: Array<{ name: string }>;
count: number;
};
expect(r.count).toBe(1);
expect(r.results[0].name).toBe('code.ts');
});
it('files 模式大小写不敏感(*.TS 命中 code.ts', async () => {
const r = (await tool.execute({ target: 'files', pattern: '*.TS' }, ctxFor(ws))) as {
count: number;
};
expect(r.count).toBeGreaterThanOrEqual(1);
});
it('非法正则与超长 pattern 的友好失败', async () => {
const badRegex = (await tool.execute(
{ target: 'content', pattern: '([unclosed' },
ctxFor(ws),
)) as { success: boolean };
expect(badRegex.success).toBe(false);
const longPattern = (await tool.execute(
{ target: 'content', pattern: 'p'.repeat(501) },
ctxFor(ws),
)) as { success: boolean; error?: string };
expect(longPattern.success).toBe(false);
expect(String((longPattern as { error?: string }).error)).toContain('max 500');
});
it('灾难性正则(ReDoS)被拦截', async () => {
for (const evil of ['(a+)+$', '(a*)*', 'a+a+', '(a|a)*']) {
const r = (await tool.execute({ target: 'content', pattern: evil }, ctxFor(ws))) as {
success: boolean;
error?: string;
};
expect(r.success, `expected ReDoS block for ${evil}`).toBe(false);
expect(String((r as { error?: string }).error).toLowerCase()).toMatch(
/catastrophic|redos|rejected/i,
);
}
});
it('根 MEMORY.md 在 content 搜索中被跳过', async () => {
const r = (await tool.execute({ target: 'content', pattern: 'secret memory' }, ctxFor(ws))) as {
count: number;
results: unknown[];
};
expect(r.count).toBe(0);
});
it('node_modules 目录在遍历中被跳过', async () => {
const r = (await tool.execute(
{ target: 'content', pattern: 'inside node_modules' },
ctxFor(ws),
)) as {
count: number;
};
expect(r.count).toBe(0);
});
it('context_lines 钳制到 5(超出不报错)', async () => {
const r = (await tool.execute(
{ target: 'content', pattern: 'alpha', context_lines: 99 },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(true);
});
it('limit 截断结果数', async () => {
const r = (await tool.execute({ target: 'content', pattern: 'a', limit: 1 }, ctxFor(ws))) as {
count: number;
};
expect(r.count).toBeLessThanOrEqual(1);
});
it('无匹配 → 空结果且 success=true', async () => {
const r = (await tool.execute(
{ target: 'content', pattern: 'zzz-nothing-zzz' },
ctxFor(ws),
)) as {
count: number;
success: boolean;
};
expect(r.success).toBe(true);
expect(r.count).toBe(0);
});
it('search path 越界 → 拒绝(Path traversal', async () => {
const r = (await tool.execute(
{ target: 'content', pattern: 'x', path: '../outside-dir' },
ctxFor(ws),
)) as { success: boolean };
expect(r.success).toBe(false);
});
});
describe('delete_file — 根保护 / recursive 契约 / 正常删除', () => {
let ws: string;
beforeAll(() => {
ws = mkdtempSync(join(tmpdir(), 'metona-del-'));
writeFileSync(join(ws, 'gone.txt'), 'x');
mkdirSync(join(ws, 'full-dir'));
writeFileSync(join(ws, 'full-dir', 'child.txt'), 'y');
mkdirSync(join(ws, 'empty-dir'));
writeFileSync(join(ws, 'keep.md'), 'soul');
});
afterAll(() => rmSync(ws, { recursive: true, force: true }));
const tool = new DeleteFileTool();
const c = () => ctxFor(ws);
it('根目录不可删', async () => {
const r = (await tool.execute({ file_path: '.', recursive: true }, c())) as {
success: boolean;
error?: string;
};
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('Cannot delete workspace root');
});
it('非空目录必须显式 recursive=true', async () => {
const denied = (await tool.execute({ file_path: 'full-dir' }, c())) as {
success: boolean;
error?: string;
};
expect(denied.success).toBe(false);
expect(String((denied as { error?: string }).error)).toContain('recursive');
const ok = (await tool.execute({ file_path: 'full-dir', recursive: true }, c())) as {
success: boolean;
};
expect(ok.success).toBe(true);
expect(existsP(join(ws, 'full-dir'))).toBe(false);
});
it('普通文件删除成功后不存在', async () => {
const r = (await tool.execute({ file_path: 'gone.txt' }, c())) as { success: boolean };
expect(r.success).toBe(true);
expect(existsP(join(ws, 'gone.txt'))).toBe(false);
});
it('空目录无需 recursive 即可删除', async () => {
const r = (await tool.execute({ file_path: 'empty-dir' }, c())) as { success: boolean };
expect(r.success).toBe(true);
expect(existsP(join(ws, 'empty-dir'))).toBe(false);
});
it('删除文件返回 wasDirectory=false 标志', async () => {
writeFileSync(join(ws, 'flag.txt'), 'x');
const r = (await tool.execute({ file_path: 'flag.txt' }, c())) as {
success: boolean;
wasDirectory?: boolean;
};
expect(r.success).toBe(true);
expect(r.wasDirectory).toBe(false);
});
it('根 MEMORY.md 受 safeResolvePath 保护不可删', async () => {
const r = (await tool.execute({ file_path: 'MEMORY.md' }, c())) as {
success: boolean;
error?: string;
};
expect(r.success).toBe(false);
});
it('文件不存在 → File or directory not found', async () => {
const r = (await tool.execute({ file_path: 'not-here.txt' }, c())) as {
success: boolean;
error?: string;
};
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('not found');
});
it('指向工作空间外的符号链接 → 拒绝(realpath 逃逸校验)', async () => {
const outsideFile = join(ws, '..', `metona-ext-${Date.now()}.txt`);
writeFileSync(outsideFile, 'ext');
const link = join(ws, 'ext-link.txt');
try {
symlinkSync(outsideFile, link);
} catch {
rmSync(outsideFile, { force: true });
return;
}
const r = (await tool.execute({ file_path: 'ext-link.txt' }, c())) as { success: boolean };
expect(r.success).toBe(false);
expect(readText(outsideFile)).toBe('ext'); // 外部文件未被删除
rmSync(outsideFile, { force: true });
});
});
describe('file_move / file_info — 移动与元信息', () => {
let ws: string;
beforeAll(() => {
ws = mkdtempSync(join(tmpdir(), 'metona-mv-'));
writeFileSync(join(ws, 'from.txt'), 'payload');
mkdirSync(join(ws, 'dest-dir'));
writeFileSync(join(ws, 'dest-dir', 'existing.txt'), 'old');
writeFileSync(join(ws, 'png-like.bin'), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]));
mkdirSync(join(ws, 'dir-to-move'));
writeFileSync(join(ws, 'dir-to-move', 'inner.txt'), 'i');
writeFileSync(
join(ws, 'utf16-info.bin'),
Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('info', 'utf16le')]),
);
});
afterAll(() => rmSync(ws, { recursive: true, force: true }));
const move = new FileMoveTool();
const info = new FileInfoTool();
const c = () => ctxFor(ws);
it('跨工作空间移动被拒(destination 越界)', async () => {
const otherDrive =
process.platform === 'win32' ? 'D:\\elsewhere\\t.txt' : '/tmp/metona-outside-t.txt';
const r = (await move.execute(
{ source_path: 'from.txt', destination_path: otherDrive },
c(),
)) as { success: boolean };
expect(r.success).toBe(false);
});
it('覆盖移动:overwrite=true 时目标文件被替换', async () => {
const r = (await move.execute(
{ source_path: 'from.txt', destination_path: 'dest-dir/existing.txt', overwrite: true },
c(),
)) as { success: boolean; overwritten?: boolean };
expect(r.success).toBe(true);
expect(r.overwritten).toBe(true);
expect(readText(join(ws, 'dest-dir', 'existing.txt'))).toBe('payload');
expect(existsP(join(ws, 'from.txt'))).toBe(false);
});
it('目标已存在且 overwrite=false → 拒绝', async () => {
writeFileSync(join(ws, 'src-exists.txt'), 's');
const r = (await move.execute(
{ source_path: 'src-exists.txt', destination_path: 'dest-dir/existing.txt' },
c(),
)) as { success: boolean; error?: string };
expect(r.success).toBe(false);
expect(String((r as { error?: string }).error)).toContain('already exists');
});
it('工作空间根目录不可移动', async () => {
const r = (await move.execute({ source_path: '.', destination_path: 'sub' }, c())) as {
success: boolean;
};
expect(r.success).toBe(false);
});
it('目录移动 isDirectory=true(同工作空间内)', async () => {
const r = (await move.execute(
{ source_path: 'dir-to-move', destination_path: 'renamed-dir' },
c(),
)) as { success: boolean; isDirectory?: boolean };
expect(r.success).toBe(true);
expect(r.isDirectory).toBe(true);
expect(readText(join(ws, 'renamed-dir', 'inner.txt'))).toBe('i');
expect(existsP(join(ws, 'dir-to-move'))).toBe(false);
});
it('自动创建目标父目录', async () => {
writeFileSync(join(ws, 'leaf.txt'), 'l');
const r = (await move.execute(
{ source_path: 'leaf.txt', destination_path: 'deep/parent/leaf2.txt' },
c(),
)) as { success: boolean };
expect(r.success).toBe(true);
expect(readText(join(ws, 'deep', 'parent', 'leaf2.txt'))).toBe('l');
});
it('源不存在 → Source not found', async () => {
const r = (await move.execute(
{ source_path: 'ghost.txt', destination_path: 'out.txt' },
c(),
)) as { success: boolean };
expect(r.success).toBe(false);
});
it('缺参数(source/destination 任一缺失)→ 报错', async () => {
const missing = (await move.execute({ source_path: 'a.txt' }, c())) as { success: boolean };
expect(missing.success).toBe(false);
});
it('file_info 返回 size/类型探测字段(PNG magic → is_binary=false', async () => {
const r = (await info.execute({ file_path: 'png-like.bin' }, c())) as Record<string, unknown>;
expect(r.success).toBe(true);
expect(Number(r.size)).toBe(6);
expect(r.type).toBe('file');
expect(String(r.mode)).toMatch(/^\d+$/); // 八进制权限位
});
it('file_info 对 UTF-16 文件报告 is_binary=trueNUL 字节探测;无 encoding 字段)', async () => {
const r = (await info.execute({ file_path: 'utf16-info.bin' }, c())) as Record<string, unknown>;
expect(r.success).toBe(true);
expect(r.is_binary).toBe(true);
expect(r.encoding).toBeUndefined();
});
it('file_info 对二进制文件报告 is_binary=true', async () => {
writeFileSync(join(ws, 'true-bin.bin'), Buffer.from([0x00, 0x01, 0x02]));
const r = (await info.execute({ file_path: 'true-bin.bin' }, c())) as Record<string, unknown>;
expect(r.is_binary).toBe(true);
});
it('file_info 对目录返回 type=directory', async () => {
const r = (await info.execute({ file_path: 'dest-dir' }, c())) as Record<string, unknown>;
expect(r.success).toBe(true);
expect(r.type).toBe('directory');
});
it('file_info 文件不存在 → File not found', async () => {
const r = (await info.execute({ file_path: 'nope-info.txt' }, c())) as { success: boolean };
expect(r.success).toBe(false);
});
it('file_info 路径越界 → 拒绝', async () => {
const outside = process.platform === 'win32' ? 'C:\\Windows\\notepad.exe' : '/etc/passwd';
const r = (await info.execute({ file_path: outside }, c())) as { success: boolean };
expect(r.success).toBe(false);
});
it('file_info 普通文本文件 → encoding 存在且非空', async () => {
writeFileSync(join(ws, 'plain.txt'), 'hello');
const r = (await info.execute({ file_path: 'plain.txt' }, c())) as Record<string, unknown>;
expect(r.success).toBe(true);
expect(String(r.encoding)).toMatch(/utf-8/);
expect(r.is_binary).toBe(false);
});
});