Files
metona-ai-desktop/electron/harness/tools/built-in/__tests__/filesystem-tools.test.ts
T
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
2026-09-05 20:06:26 +08:00

882 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 文件正常读取(v0.8.0 P1-3.3 BOM 检测根治后契约)', async () => {
// v0.8.0 P1-3.3 根治: isBinaryFile 此前的 NUL 字节启发式拒绝一切 UTF-16
// 文件,decodeBufferWithDetection 的 UTF-16 分支不可达(原缺陷被本测试
// 锁定留档)。现 UTF-16 BOMFF FE / FE FF)视为文本,编码探测生效。
const r = (await tool.execute({ file_path: 'utf16le.txt' }, ctxFor(ws))) as {
success: boolean;
content?: string;
encoding?: string;
};
expect(r.success).toBe(true);
expect(r.encoding).toBe('utf-16le');
});
it('UTF-16BE 文件正常读取(v0.8.0 P1-3.3', async () => {
const r = (await tool.execute({ file_path: 'utf16be.txt' }, ctxFor(ws))) as {
success: boolean;
content?: string;
};
expect(r.success).toBe(true);
});
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);
});
});