mcp-read-file-server 1.9.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -50
- package/SKILL.md +8 -4
- package/lib/cleanup.js +34 -0
- package/lib/encryption.js +53 -12
- package/lib/files.js +85 -58
- package/lib/locks.js +88 -0
- package/lib/patterns.js +99 -14
- package/lib/regex-worker.js +3 -1
- package/lib/regex.js +23 -9
- package/lib/server.js +25 -17
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,11 +4,26 @@
|
|
|
4
4
|
|
|
5
5
|
加密环境文件操作工具。当 Node.js 是加密软件白名单进程时,通过 fs 模块自动解密读写文件明文,替代 AI Agent 内置文件工具,解决加密环境下读到密文的问题。适用于任何支持 MCP 协议的 AI Agent。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## 最新版本:2.1.0(相对 1.0)
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
本节以仓库 `v1.0` 标签(包版本 `1.0.0`)为基线,汇总当前版本的变化,不再逐条保留中间版本的更新说明。
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
| 方面 | 1.0 | 2.1.0 |
|
|
12
|
+
|------|-----|-------|
|
|
13
|
+
| 文件写入 | 由 Node 直接读改写,依赖本机透明加密行为 | 统一暂存、备份、提交及终验;失败回滚,恢复失败保留 `recoveryPath` |
|
|
14
|
+
| 加密策略 | 没有按原文件状态选择写入策略 | `auto` 比较原文件的 Node 与外部读取视图:原明文继续验证明文,原受保护文件保留受控写入;新建文件不凭 Node 可读就自动加密 |
|
|
15
|
+
| 工具范围 | 8 个基础读写、搜索及目录工具 | 18 个工具,增加分页、列目录、文件查找、复制/移动/删除和 4 个策略诊断/管理工具 |
|
|
16
|
+
| 编辑准确性 | 单次字符串或正则替换 | 批量原子编辑、CRLF/LF 适配、BOM 保留、预览、预期匹配数和 hash 冲突保护 |
|
|
17
|
+
| 读取与性能 | 同步文件操作和基础搜索 | 异步 I/O、流式分页、有界输出;正则与字面量计算在可终止 worker 中执行;glob 累计预算并让出主线程 |
|
|
18
|
+
| 并发与异常 | 基础异常返回 | 跨实例路径/子树锁;目录逐文件校验;部分完成、源保留、回滚和清理错误分别报告 |
|
|
19
|
+
| 边界保护 | 主要依赖进程文件权限 | 可配置允许根、只读和禁删;识别真实路径/链接及受保护根;拒绝目录双向祖先重叠 |
|
|
20
|
+
| 分发与验证 | 本地脚本启动,无仓库测试套件 | npm CLI 入口、模块化源码、结构化响应、回归/真实 stdio/Windows 适配测试及 CI;最低 Node.js 20 |
|
|
21
|
+
|
|
22
|
+
**本次重点修复**:原始明文文件经 MCP 编辑后出现加密内容,而 Node 读回正常、IDEA 却显示密文。修复基于每个文件的写入前状态,适用于所有扩展名、未知后缀、无扩展名和点文件;不对 SCSS 做特殊判断。文本工具仍只接受有效 UTF-8,二进制文件通过复制/移动使用相同的提交保护。
|
|
23
|
+
|
|
24
|
+
**累计修复与优化**:拒绝非法 UTF-8、UTF-16 和 NUL 文本的破坏性编辑;修复分页边界、换行匹配、复制移动重叠、并发追加及部分失败状态;安全中转失败不再回退直写;锁和临时文件清理失败不会掩盖主操作结果;工作线程和扫描预算防止复杂表达式阻塞服务。依赖锁定与 overrides 用于复现已验证的依赖树。
|
|
25
|
+
|
|
26
|
+
**升级行为变化**:显式 `writePolicy` 优先,其次是持久人工策略,再由 `auto` 观察文件状态。Windows 的 `auto` 缺少可用外部读取器时返回 `DISK_UNVERIFIED`,不会猜测后继续写入;需要新建受保护文件时明确指定 `preserve` 或配置人工 `protected`。更新后重启全部 MCP 实例,并用 `check_status` 确认运行版本为 `2.1.0`。本地修改不会自动发布到 npm。
|
|
12
27
|
|
|
13
28
|
## 适用场景
|
|
14
29
|
|
|
@@ -40,45 +55,50 @@ AI Agent --(MCP/stdio)--> Node.js MCP Server(index.js) --(lib/ 模块)--> fs
|
|
|
40
55
|
- `index.js` 仅负责 stdio 启动与正则 worker 回收
|
|
41
56
|
- `lib/encryption.js` 目录级加密探测、写入策略决策、safeWrite 组合与独立指纹校验
|
|
42
57
|
- `lib/files.js` 路径边界、跨实例锁、可回滚提交(暂存→备份→rename→终验)、目录逐文件复制/移动
|
|
58
|
+
- `lib/locks.js` 登记路径及子树占用;`lib/cleanup.js` 保留主操作结果并收集清理错误
|
|
43
59
|
- `lib/text.js` 严格 UTF-8 增量解码与流式分页
|
|
44
60
|
- `lib/patterns.js` 无回溯 glob 与保留原索引的字符串替换
|
|
45
|
-
- `lib/regex.js` / `lib/regex-worker.js`
|
|
61
|
+
- `lib/regex.js` / `lib/regex-worker.js` 用户正则与字面量编辑在可终止 worker 中执行(单次计算默认最多 1 秒,并受请求总预算约束)
|
|
46
62
|
- `lib/server.js` 注册全部 18 个 MCP 工具,统一 structuredContent 与超时/只读包装
|
|
47
63
|
|
|
48
|
-
##
|
|
64
|
+
## 环境自适应
|
|
49
65
|
|
|
50
66
|
### 解决的问题
|
|
51
67
|
|
|
52
|
-
|
|
68
|
+
加密行为可能随目录、文件类型和进程变化。Node 能读到明文,并不表示 IDEA 或其他编辑器也能解密。新建探测样本的分类只描述进程观察,不能替代原文件的状态:
|
|
53
69
|
|
|
54
|
-
|
|
|
55
|
-
|
|
56
|
-
| **safe** |
|
|
57
|
-
| **protected** |
|
|
58
|
-
| **unsafe** |
|
|
70
|
+
| 探测分类 | 实际观察 | 新建文件的 auto 策略 |
|
|
71
|
+
|----------|----------|----------------------|
|
|
72
|
+
| **safe** | Node 和外部读取视图均与探测载荷一致 | 允许先写暂存文件,暂存及最终路径仍须通过明文校验 |
|
|
73
|
+
| **protected** | Node 读回正确,外部读取视图不同 | 不推断其他编辑器可解密,使用安全中转并验证明文 |
|
|
74
|
+
| **unsafe** | Node 读回已经与探测载荷不同 | 使用安全中转并验证明文 |
|
|
75
|
+
| **unknown** | 无法取得外部读取结果 | 不能宣称安全;有读取器时只允许通过严格明文校验后提交 |
|
|
76
|
+
|
|
77
|
+
已有文件的状态不缓存、不按后缀共享:每次 `auto` 都比较该文件的 Node 指纹与外部进程指纹。两者一致时要求明文提交;两者不同时走受控写入,并检查外部视图没有意外变成预期明文。复制/移动覆盖已有目标时参考目标状态,新目标参考源文件状态;目录内逐文件执行。
|
|
59
78
|
|
|
60
79
|
### safeWrite 原理
|
|
61
80
|
|
|
62
|
-
|
|
81
|
+
明文暂存写入出现内容不一致、目录探测不适合直接写入,或明确要求安全中转时,流程切换为:
|
|
63
82
|
|
|
64
83
|
```
|
|
65
|
-
1.
|
|
66
|
-
2.
|
|
67
|
-
|
|
68
|
-
3. 用独立读取器(PowerShell 流式 SHA256+size)校验目标文件磁盘指纹为明文
|
|
84
|
+
1. 写入目标目录下 .mcp-safe-<uuid><候选扩展名> 的随机临时文件,并验证其内容
|
|
85
|
+
2. 用可用外部进程(powershell/pwsh/cmd/robocopy/cscript)复制到 .mcp-stage-<uuid><目标扩展名>
|
|
86
|
+
3. 用Node和外部读取器(PowerShell 流式 SHA256+size)验证暂存文件与预期载荷一致
|
|
69
87
|
4. 清理临时文件
|
|
70
88
|
5. 失败则遍历全部「安全扩展名 × 可用进程」组合重试;
|
|
71
|
-
|
|
89
|
+
全部失败直接报错(SAFE_WRITE_FAILED),不再回退未经验证的写入
|
|
72
90
|
```
|
|
73
91
|
|
|
92
|
+
暂存成功后仍需 rename 提交及最终路径校验,最终校验失败会回滚。外部进程是否会加密、是否会自动解密均不能仅凭进程名称判断;上述校验是进程可见字节对照,不是绕过驱动读取原始磁盘。
|
|
93
|
+
|
|
74
94
|
### 探测与缓存
|
|
75
95
|
|
|
76
|
-
-
|
|
77
|
-
-
|
|
78
|
-
- **自动缓存位置**:`~/.mcp-encryption-profile.json`(结构 v3),按 machineId(hostname+username
|
|
96
|
+
- **首次需要自动策略(或缓存失效)时探测**:在系统临时目录用候选扩展名写入样本,通过 Node 与外部进程指纹对照分类,并探测可用复制进程
|
|
97
|
+
- **目录级探测**:无原文件或复制源状态可参考的新建路径,按「目标目录 × 扩展名」创建随机样本(`.mcp-probe-<uuid><ext>`,写完即删),观察缓存在内存 scopes 中。已有文件优先逐文件观察,不受旧目录分类覆盖
|
|
98
|
+
- **自动缓存位置**:`~/.mcp-encryption-profile.json`(结构 v3),按 machineId(hostname+username 哈希)绑定,换电脑/换用户重新探测;有效期 30 天。兼容有效的 v2/v3 缓存,丢弃旧 scopes;v2 人工 protected 一次性迁移到独立策略目录
|
|
79
99
|
- **人工策略独立存储**:`~/.mcp-file-policies/` 每个扩展名一个文件,刷新探测、TTL 过期、服务重启都不会删除人工标注
|
|
80
100
|
- **查看/刷新**:用 `encryption_profile` 工具查看当前探测结果与人工策略;加密策略变更后用 `refresh_profile` 强制重探(只刷新自动探测,不动人工策略);`inspect_write_strategy` 可预览某个目标路径将采用的写入策略而不修改目标文件
|
|
81
|
-
-
|
|
101
|
+
- **无外部读取器时**:Windows 的 auto 返回 `DISK_UNVERIFIED`;非 Windows 且没有该后缀的加密观察时保留 Node 内容校验,返回 unknown 并告警。显式 plaintext 在所有平台都必须有可用外部读取器。配置过的读取器临时失败时不得降级为成功
|
|
82
102
|
|
|
83
103
|
### 写入策略(writePolicy)
|
|
84
104
|
|
|
@@ -86,21 +106,23 @@ write_file / edit_file / copy_path / move_path 均支持 `writePolicy` 参数:
|
|
|
86
106
|
|
|
87
107
|
| 值 | 语义 |
|
|
88
108
|
|----|------|
|
|
89
|
-
| `auto`(默认) |
|
|
90
|
-
| `preserve` |
|
|
91
|
-
| `plaintext` |
|
|
109
|
+
| `auto`(默认) | 人工标注优先;否则原明文要求明文终验,原受保护文件保留受控写入;新文件要求明文,不自动沿用探测样本的加密状态 |
|
|
110
|
+
| `preserve` | 显式受控写入,经暂存替换并校验 Node 内容;不保证原来的明文状态,也不保证其他编辑器能解密 |
|
|
111
|
+
| `plaintext` | 显式安全中转,必须验证暂存及最终路径的外部指纹与预期明文一致;失败中止或回滚 |
|
|
112
|
+
|
|
113
|
+
返回的 `strategy.basis` 区分 `explicit`(本次显式策略)、`override`(人工标注)、`target`(原目标)、`source`(新复制目标的源)、`new_file`(全新文件)和 `unverified`。`originalState` 记录自动决策依据;`category` 描述观察结果,不是编辑器兼容性认证。`user_unsafe` 在显式 plaintext 下也会返回,不表示新增了永久标注。
|
|
92
114
|
|
|
93
115
|
### mark_extension 手动标注
|
|
94
116
|
|
|
95
|
-
|
|
117
|
+
当需要固定某类文件的写入方式时手动标注(本次显式 writePolicy 优先,其次人工标注,再次自动状态判断;标注持久化,重启/刷新不丢失):
|
|
96
118
|
|
|
97
119
|
| 场景 | 调用 | 效果 |
|
|
98
120
|
|------|------|------|
|
|
99
|
-
| `.java`
|
|
100
|
-
| `.
|
|
121
|
+
| `.java` 需要受控写入 | `mark_extension(".java", "protected")` | auto 采用 preserve;仍需确认实际使用的编辑器能正常读取 |
|
|
122
|
+
| `.custom` 需要固定明文写入 | `mark_extension(".custom", "unsafe")` | auto 强制使用安全中转及明文校验 |
|
|
101
123
|
| 恢复自动分类 | `mark_extension(".java", "clear")` | 写入墓碑清除标注,恢复实时探测 |
|
|
102
124
|
|
|
103
|
-
##
|
|
125
|
+
## 写入保证
|
|
104
126
|
|
|
105
127
|
所有文本修改(write_file / edit_file)与文件复制/移动都经过统一的可回滚提交流程:
|
|
106
128
|
|
|
@@ -109,16 +131,22 @@ write_file / edit_file / copy_path / move_path 均支持 `writePolicy` 参数:
|
|
|
109
131
|
→ 同目录随机独占暂存 .mcp-stage-<uuid><ext>(外部进程中转时经安全扩展名)
|
|
110
132
|
→ fsync 刷盘 → 再次比对改前指纹(防并发改动)
|
|
111
133
|
→ 原文件 rename 为 .mcp-backup-<uuid><ext> → 暂存 rename 到位
|
|
112
|
-
→
|
|
134
|
+
→ 指纹终验(SHA256+size;自动状态策略同时检查外部读取视图)
|
|
113
135
|
→ 成功删除备份;任一步失败自动回滚,回滚失败返回 recoveryPath(备份不得删除)
|
|
114
136
|
```
|
|
115
137
|
|
|
116
138
|
- **完整载荷**:追加模式先在内存合成「原内容+新增」完整内容再走事务,纠正/重写不会丢原文与 BOM
|
|
117
139
|
- **safeWrite 失败即中止**:不再回退直写破坏原文(SAFE_WRITE_FAILED,changed=false)
|
|
118
|
-
-
|
|
140
|
+
- **跨实例锁**:使用同一 `MCP_PROFILE_DIR` 的实例经 `.mcp-file-locks/` 登记整组路径,同路径及祖先/后代相互排斥,无关路径可并行(等待 5 秒超时 FILE_BUSY);`expectedHash` 可检测其他编辑器造成的版本变化(CONFLICT)。升级时应重启全部 MCP 实例,避免旧进程继续执行旧的锁和写入策略
|
|
141
|
+
- **清理状态**:提交或锁清理失败会附带 `cleanupErrors`;主操作已成功时保留成功结果和真实 `changed`,错误时保留原错误及 `recoveryPath`。不要因清理告警重复追加内容
|
|
142
|
+
- **递归删除**:逐项执行,失败时返回 `changed`、`partial`(最多100项)、`removedCount`、`partialTruncated`、`failedPath`;受一万项和128层预算限制,不是整树事务
|
|
119
143
|
- **断电/强杀残留**:两次 rename 之间的极端崩溃可能留下 `.mcp-backup-*` 与 `.mcp-stage-*`,先核对内容与时间再人工恢复,禁止直接批量清理
|
|
120
144
|
- **复制/移动目录**:逐文件执行相同策略;移动先复制并二次比对指纹后再删除已验证的源文件;失败返回 `partial` 与 `sourceRetained`,不静默回退;符号链接明确拒绝;源和最终目标存在任一方向的祖先关系时拒绝,相同路径保持不变。回滚失败时 `changed=true`,`partial` 包含当前失败目标,原备份通过 `recoveryPath` 返回
|
|
121
|
-
-
|
|
145
|
+
- **移动失败的源变化**:`sourceRetained` 表示本次是否尚未删除任何源文件或源目录;`removedSourceCount`、`removedSourcePaths`(最多100项)、`removedSourcePathsTruncated` 报告已经删除的源项。失败仍保留已完成子项的 `cleanupErrors`
|
|
146
|
+
- **策略探测互斥**:`inspect_write_strategy` 持有目标父目录锁,创建和清理探测样本完成后才允许该目录被复制、移动或删除
|
|
147
|
+
- **glob总预算**:生产搜索与查找使用异步匹配,合并重复模式和分支;一次请求的展开后累计长度最多10万,编译和全部路径匹配共用5000万工作单元预算。约每16384工作单元让出事件循环,检查取消与截止时间;超过限额返回 `GLOB_LIMIT`,超时返回 `TIMEOUT`
|
|
148
|
+
- **diskState 三态**:`plaintext`(Node 与外部视图均匹配预期明文)、`preserved`(Node 内容通过受控写入校验,不保证其他编辑器可解密)、`unknown`(仅内容校验,不能声称已验证明文)。自动保留原保护状态还返回 `protectionObserved:true`,表示外部视图仍不同,并非密钥或加密完整性认证
|
|
149
|
+
- **校验边界**:如果外部读取器也被透明解密,两种视图一致仍不能证明原始磁盘未加密。上线前用真实驱动及目标编辑器验收;已经损坏或已经加密的异常文件不会因升级而自动修复
|
|
122
150
|
|
|
123
151
|
## 文件结构
|
|
124
152
|
|
|
@@ -381,23 +409,23 @@ find_files 之外的文件名查找也优先用 MCP 工具。
|
|
|
381
409
|
- 建目录/查信息 → create_directory / file_info
|
|
382
410
|
|
|
383
411
|
## 使用规则
|
|
384
|
-
1. 会话开始先调 check_status
|
|
412
|
+
1. 会话开始先调 check_status 确认服务版本与运行状态;它不自动证明解密正常。环境不明时调
|
|
385
413
|
encryption_profile 查看本机扩展名分类与人工策略;
|
|
386
414
|
写入前可用 inspect_write_strategy 预览目标路径的写入策略。
|
|
387
|
-
2.
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
415
|
+
2. write_file/edit_file/copy_path/move_path 默认 writePolicy=auto:
|
|
416
|
+
原明文文件保持明文校验,原受保护文件保持受控写入,新文件默认要求明文。
|
|
417
|
+
明确需要受控写入用 preserve,明确需要明文用 plaintext。
|
|
418
|
+
preserved 不证明 IDEA 等其他程序能解密;Windows 缺少外部读取器时 auto 拒绝写入。
|
|
391
419
|
3. 需要长期保持加密/明文的扩展名:用 mark_extension(".java", "protected")
|
|
392
|
-
或 mark_extension(".
|
|
420
|
+
或 mark_extension(".custom", "unsafe") 标注一次永久生效(独立存储,
|
|
393
421
|
重启与刷新探测不丢失);mark_extension(".ext", "clear") 恢复自动。
|
|
394
422
|
4. edit_file 前必须先 read_file 拿原文,oldString 从原文原样复制
|
|
395
423
|
(含空格与缩进;CRLF/LF 换行差异会自动兼容,无需手工处理);
|
|
396
424
|
重要修改先 dryRun=true 预览;可用 expectedHash 防止覆盖他人改动。
|
|
397
425
|
5. 路径一律使用绝对路径。
|
|
398
426
|
6. 写工具返回 isError 时先看 structuredContent 的 code:
|
|
399
|
-
SAFE_WRITE_FAILED/DISK_MISMATCH
|
|
400
|
-
|
|
427
|
+
SAFE_WRITE_FAILED/DISK_MISMATCH 表示写入校验失败;检查 changed 和恢复信息,
|
|
428
|
+
不要改用普通 shell 覆盖。出现 recoveryPath 说明回滚
|
|
401
429
|
也失败,保留该备份并报告用户,禁止盲目重试或删除备份。
|
|
402
430
|
7. edit_file 匹配失败时,按返回的「可能相关的行」诊断修正 oldString,
|
|
403
431
|
不要盲目重试。
|
|
@@ -489,15 +517,15 @@ npm test # 仓库内回归/协议/适配测试(Node 内置 test runner
|
|
|
489
517
|
npm audit --omit=dev
|
|
490
518
|
```
|
|
491
519
|
|
|
492
|
-
测试位于 `test
|
|
520
|
+
测试位于 `test/`,包含基础回归、真实 stdio、Windows 适配器、边界/并发修复以及 `write-policy.test.js` 通用写入状态回归;模拟读取视图与真实驱动验收分开,不触碰真实 profile。可通过 `MCP_TEST_ROOT` 指定独立测试目录。CI 配置覆盖 Windows/Linux × Node 20/22/24。运行依赖:MCP SDK 1.30.0、Zod 4.4.3,间接依赖 fast-uri/qs/Hono 通过 overrides 限定修复版本。
|
|
521
|
+
|
|
522
|
+
本次自动化验收计划见 `test/write-policy-plan.md`。真实加密电脑需分别检查原始明文样本及原受保护样本;先确认基线,再用 auto 编辑并从磁盘重新打开,核对实际编辑器结果。模拟测试通过不等于真实驱动全部兼容。
|
|
493
523
|
|
|
494
524
|
## 故障排查
|
|
495
525
|
|
|
496
526
|
### 读取到的仍是密文
|
|
497
527
|
|
|
498
|
-
|
|
499
|
-
- 联系加密软件管理员,将 `node.exe` 加入白名单
|
|
500
|
-
- 确认加密软件的受信任进程列表中包含 Node.js
|
|
528
|
+
可能是 Node.js 未被授权解密,也可能是该文件类型、路径或原文件状态不满足解密条件。先保留原文件,确认运行入口与实际 `node.exe` 路径,再核对加密软件配置;不能只凭“程序在白名单”就认定所有文件均可解密。
|
|
501
529
|
|
|
502
530
|
### MCP Server 无法启动
|
|
503
531
|
|
|
@@ -510,21 +538,26 @@ cd mcp-read-file-server && npm ci
|
|
|
510
538
|
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' | node index.js
|
|
511
539
|
```
|
|
512
540
|
|
|
513
|
-
###
|
|
541
|
+
### MCP 返回成功,但编辑器打开显示密文
|
|
514
542
|
|
|
515
|
-
|
|
543
|
+
Node 可读不等于其他编辑器可读,不能只凭锁图标判断状态。当前版本对所有文件类型统一处理原明文状态,不需要为 SCSS 等后缀写特例。若仍出现异常:
|
|
516
544
|
|
|
517
|
-
1.
|
|
518
|
-
2.
|
|
519
|
-
3.
|
|
545
|
+
1. 用 `check_status` 确认实际服务为 2.1.0,保留异常文件和当次完整响应,不直接覆盖修复。
|
|
546
|
+
2. 查看 `strategy.basis/originalState`、`diskState/diskVerified` 和 warnings,确认是否有显式 preserve 或人工 protected 覆盖自动决策。
|
|
547
|
+
3. 使用独立副本验证所需策略,并检查外部读取器是否同样被透明解密。已经异常的受保护文件不会被 auto 自动解密;需要恢复时先核对原始备份。
|
|
520
548
|
|
|
521
549
|
### 写工具返回 SAFE_WRITE_FAILED / DISK_MISMATCH
|
|
522
550
|
|
|
523
|
-
|
|
551
|
+
safeWrite 全部组合失败返回 `SAFE_WRITE_FAILED`;最终路径校验不一致返回 `DISK_MISMATCH` 并尝试回滚。不回退未经验证的写入,是否恢复成功以 `changed/recoveryPath/rollbackError` 为准。检查目标目录权限、外部进程可用性和实际读取视图;环境已发生变化时再考虑 `refresh_profile` 重探。
|
|
552
|
+
|
|
553
|
+
### 写工具返回 DISK_UNVERIFIED / PROTECTION_MISMATCH
|
|
554
|
+
|
|
555
|
+
- `DISK_UNVERIFIED`:无法取得所需外部读取视图。Windows auto 不能据此猜测原文件状态;检查读取器可用性,不要为了通过测试盲目切到 preserve。
|
|
556
|
+
- `PROTECTION_MISMATCH`:自动保留受保护文件时,暂存或最终目标的外部视图变成了预期明文;为避免静默改变保护状态而中止或回滚。确实需要明文时应明确指定 plaintext,并用独立样本验证。
|
|
524
557
|
|
|
525
558
|
### 写工具返回 FILE_BUSY / CONFLICT
|
|
526
559
|
|
|
527
|
-
- `FILE_BUSY`:另一个 MCP
|
|
560
|
+
- `FILE_BUSY`:另一个 MCP 实例正在操作同一路径、父目录或子路径(锁位于 `~/.mcp-file-locks/`)。确认对方进程结束后重试;异常退出时仅在核对记录中的 PID 后处理残留 `.lock` 或 `.registry-guard`。`LOCK_CLEANUP_FAILED` 和 `cleanupErrors` 会给出未清理路径,不应直接清空整个锁目录
|
|
528
561
|
- `CONFLICT`:传入的 `expectedHash` 与文件当前指纹不一致——文件在您读取后被其他进程改过。重新 read_file 后再编辑
|
|
529
562
|
|
|
530
563
|
### 回滚失败返回 recoveryPath
|
package/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: 在Node.js为加密软件白名单进程的环境中,使用文件
|
|
|
5
5
|
|
|
6
6
|
# 加密环境文件操作
|
|
7
7
|
|
|
8
|
-
使用前确认Node.js受信任,并配置read-file-server。版本1.
|
|
8
|
+
使用前确认Node.js受信任,并配置read-file-server。版本2.1.0提供18个工具,最低Node20。
|
|
9
9
|
|
|
10
10
|
## 工具选择
|
|
11
11
|
|
|
@@ -20,8 +20,8 @@ description: 在Node.js为加密软件白名单进程的环境中,使用文件
|
|
|
20
20
|
## 必须理解的结果
|
|
21
21
|
|
|
22
22
|
1. 优先读取structuredContent的ok、code、changed、data和warnings,不能只看人类文本。
|
|
23
|
-
2. isError=true时可能存在目录操作部分目标;查看partial/sourceRetained。单文件回滚失败时查看recoveryPath并保留备份。
|
|
24
|
-
3. contentVerified表示Node可见内容一致;diskVerified
|
|
23
|
+
2. isError=true时可能存在目录操作部分目标;查看changed/partial/sourceRetained。递归删除还需查看removedCount/partialTruncated/failedPath。单文件回滚失败时查看recoveryPath并保留备份。cleanupErrors只是清理诊断,成功修改后不得因清理告警重复追加。
|
|
24
|
+
3. contentVerified表示Node可见内容一致;diskVerified表示外部进程可见字节也与预期载荷一致,不是绕过透明解密读取原始磁盘。preserved不保证IDEA等其他程序能解密,unknown不能声称已验证明文。
|
|
25
25
|
4. check_status基础调用不探测环境。文件可读取也不能直接推断解密正常,可信expectedHash匹配才提供明确内容对照。
|
|
26
26
|
5. 文本工具只支持有效UTF8;UTF16、GBK、非法字节或NUL被拒绝时,必须先明确转换编码,不能强制按UTF8写回。
|
|
27
27
|
|
|
@@ -29,7 +29,11 @@ description: 在Node.js为加密软件白名单进程的环境中,使用文件
|
|
|
29
29
|
|
|
30
30
|
- mark_extension(category=protected)永久记录保持受控写入,等价于默认使用preserve;unsafe要求验证磁盘明文;clear可清除两者。
|
|
31
31
|
- refresh_profile只更新自动观察,不删除手工策略。
|
|
32
|
-
- writePolicy=auto
|
|
32
|
+
- writePolicy=auto在没有人工覆盖时逐文件观察原状态:原明文保持明文校验,原受保护文件保持受控写入;新文件默认要求明文,不凭探测样本的protected分类自动加密。所有扩展名及无后缀文件使用同一规则。
|
|
33
|
+
- 复制/移动覆盖已有目标参考目标原状态;新目标参考源状态。目录逐文件判断,不能拿一个后缀的探测结果代替所有文件的基线。
|
|
34
|
+
- 显式writePolicy优先于人工标注;preserve只校验Node内容并告警,不保证原明文状态或编辑器可读。plaintext要求外部明文验证,失败中止或回滚。
|
|
35
|
+
- strategy.basis说明explicit/override/target/source/new_file/unverified;originalState记录auto观察到的基线,文件状态不缓存。user_unsafe也可能只是当次显式plaintext,不代表新增永久标注。
|
|
36
|
+
- Windows auto缺少外部读取器时返回DISK_UNVERIFIED;已配置读取器但本次读取失败也中止。PROTECTION_MISMATCH表示原受保护文件将意外变为明文;不得绕过错误继续写入。
|
|
33
37
|
- 旧v2人工unsafe和自动encrypted记录无法区分;升级后需要永久强制明文的后缀应重新标注unsafe。protected会迁移。
|
|
34
38
|
- 使用绝对路径;相对路径以MCP_BASE_DIR或服务启动目录为基准。
|
|
35
39
|
|
package/lib/cleanup.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** 清理只收集自身错误,不覆盖主操作结果或恢复信息。 */
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const { READ_LIMIT, safeEnd } = require('./text');
|
|
4
|
+
|
|
5
|
+
/** 尝试删除全部指定临时文件,并返回有界的清理诊断。 */
|
|
6
|
+
async function cleanupFiles(paths) {
|
|
7
|
+
const errors = [];
|
|
8
|
+
for (const file of paths) {
|
|
9
|
+
try { await fs.rm(file, { force: true }); }
|
|
10
|
+
catch (error) { errors.push({ path: file, code: error.code || 'CLEANUP_FAILED', message: error.message }); }
|
|
11
|
+
}
|
|
12
|
+
return errors;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 失败保留主异常,成功则附加清理告警;兼容文件结果与MCP响应。 */
|
|
16
|
+
function finishCleanup(value, failure, errors) {
|
|
17
|
+
if (failure) {
|
|
18
|
+
if (errors.length) failure.cleanupErrors = [...(failure.cleanupErrors || []), ...errors].slice(0, 100);
|
|
19
|
+
throw failure;
|
|
20
|
+
}
|
|
21
|
+
if (!errors.length) return value;
|
|
22
|
+
const response = value.structuredContent;
|
|
23
|
+
const data = response ? response.data : value;
|
|
24
|
+
data.cleanupErrors = [...(data.cleanupErrors || []), ...errors].slice(0, 100);
|
|
25
|
+
const warning = '操作已完成,但有临时文件或锁未能清理;请查看cleanupErrors,不要重复提交已完成的修改';
|
|
26
|
+
if (response) {
|
|
27
|
+
response.warnings = [...response.warnings, warning];
|
|
28
|
+
const body = value.content.find(item => item.type === 'text');
|
|
29
|
+
if (body) body.text = body.text.slice(0, safeEnd(body.text, READ_LIMIT)) + '\n注意:' + warning;
|
|
30
|
+
} else value.warnings = [...(value.warnings || []), warning];
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { cleanupFiles, finishCleanup };
|
package/lib/encryption.js
CHANGED
|
@@ -48,7 +48,7 @@ function execute(exe, args, options = {}) {
|
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/**
|
|
51
|
+
/** 流式计算外部进程可见字节指纹;普通文件读取仍可能受透明解密影响。 */
|
|
52
52
|
async function inspectDisk(proc, file, options = {}) {
|
|
53
53
|
if (!proc) return null;
|
|
54
54
|
const code = "$ErrorActionPreference='Stop';$f=[IO.File]::OpenRead(" + quote(file) + ');try{' +
|
|
@@ -235,25 +235,60 @@ function createEncryption(options = {}) {
|
|
|
235
235
|
return fresh;
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
/**
|
|
239
|
-
async function
|
|
238
|
+
/** 比较同一文件的Node与外部读取视图,并拒绝读取期间发生的内容变化。 */
|
|
239
|
+
async function observe(file, expected, profile, context) {
|
|
240
|
+
const external = await reader(profile.byteReader, file, context);
|
|
241
|
+
checkBudget(context.signal, context.deadline);
|
|
242
|
+
if (!external) throw fault('DISK_UNVERIFIED', '无法确认写入前的文件状态,未提交修改');
|
|
243
|
+
const current = await fingerprint(file, context);
|
|
244
|
+
if (current.hash !== expected.hash || current.size !== expected.size) throw fault('CONFLICT', '文件在状态检查期间已改变,未提交修改');
|
|
245
|
+
return external.hash === expected.hash && external.size === expected.size ? 'plaintext' : 'protected';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 显式策略优先;auto按原文件状态决策,新复制目标参考源文件,新建文件要求明文校验。 */
|
|
249
|
+
async function strategy(file, requested = 'auto', context = {}, reference = {}) {
|
|
240
250
|
const ext = path.extname(file).toLowerCase();
|
|
241
251
|
const override = await getOverride(ext);
|
|
242
|
-
|
|
243
|
-
if (requested === '
|
|
252
|
+
const basis = requested === 'auto' ? 'override' : 'explicit';
|
|
253
|
+
if (requested === 'preserve' || (requested === 'auto' && override === 'protected')) return { mode: 'preserve', category: 'user_protected', extension: ext, basis };
|
|
254
|
+
if (requested === 'plaintext' || (requested === 'auto' && override === 'unsafe')) return { mode: 'plaintext', category: 'user_unsafe', extension: ext, basis };
|
|
244
255
|
const p = await getProfile(context);
|
|
245
256
|
const dir = await fs.realpath(path.dirname(file));
|
|
257
|
+
if (!p.byteReader) {
|
|
258
|
+
// Windows透明加密环境中,缺少外部视图时不能猜测原文件状态并继续写入。
|
|
259
|
+
if (process.platform === 'win32' || p.unsafeExtensions.includes(ext) || p.encryptedExtensions.includes(ext) || p.protectedExtensions.includes(ext)) {
|
|
260
|
+
throw fault('DISK_UNVERIFIED', 'auto需要可用的外部读取器判断文件状态;请检查环境或明确指定writePolicy');
|
|
261
|
+
}
|
|
262
|
+
return { mode: 'auto', category: 'unknown', extension: ext, scope: dir, basis: 'unverified', originalState: 'unknown' };
|
|
263
|
+
}
|
|
264
|
+
let original = reference.target;
|
|
265
|
+
if (original === undefined) {
|
|
266
|
+
try {
|
|
267
|
+
if (!(await fs.stat(file)).isFile()) throw fault('NOT_FILE', '目标不是普通文件');
|
|
268
|
+
original = await fingerprint(file, context);
|
|
269
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; original = null; }
|
|
270
|
+
}
|
|
271
|
+
const source = !original && reference.source;
|
|
272
|
+
const expected = original || source?.fingerprint;
|
|
273
|
+
if (expected) {
|
|
274
|
+
const originalState = await observe(source ? source.file : file, expected, p, context);
|
|
275
|
+
return { mode: originalState === 'plaintext' ? 'plaintext' : 'preserve',
|
|
276
|
+
verifyMode: originalState === 'plaintext' ? 'plaintext' : 'protected',
|
|
277
|
+
allowDirect: true, category: originalState === 'plaintext' ? 'safe' : 'protected',
|
|
278
|
+
extension: ext, scope: dir, basis: source ? 'source' : 'target', originalState };
|
|
279
|
+
}
|
|
246
280
|
const key = dir + '|' + ext;
|
|
247
281
|
let category = p.scopes[key];
|
|
248
282
|
if (!category) {
|
|
249
|
-
category = await classify(ext
|
|
283
|
+
category = await classify(ext, dir, p, context);
|
|
250
284
|
if (category === 'unknown' && (p.unsafeExtensions.includes(ext) || p.encryptedExtensions.includes(ext))) category = 'unsafe';
|
|
251
285
|
p.scopes[key] = category;
|
|
252
286
|
}
|
|
253
|
-
|
|
287
|
+
// 新建样本中的Node可读不代表IDEA等其他程序也可读,不能据此自动选择preserve。
|
|
288
|
+
return { mode: 'plaintext', allowDirect: category === 'safe', category, extension: ext, scope: dir, basis: 'new_file', originalState: 'missing' };
|
|
254
289
|
}
|
|
255
290
|
|
|
256
|
-
/**
|
|
291
|
+
/** 校验进程可见内容;自动保留受保护状态时还要求外部视图保持不同,不能证明其他编辑器可解密。 */
|
|
257
292
|
async function verify(file, expected, mode, context = {}) {
|
|
258
293
|
const own = await fingerprint(file, context);
|
|
259
294
|
if (own.hash !== expected.hash || own.size !== expected.size) throw fault('CONTENT_MISMATCH', '写入内容校验不一致');
|
|
@@ -261,19 +296,25 @@ function createEncryption(options = {}) {
|
|
|
261
296
|
const p = await getProfile(context);
|
|
262
297
|
const raw = await reader(p.byteReader, file, context);
|
|
263
298
|
if (!raw) {
|
|
264
|
-
if (mode === 'plaintext' || p.byteReader) throw fault('DISK_UNVERIFIED', '
|
|
299
|
+
if (mode === 'plaintext' || mode === 'protected' || p.byteReader) throw fault('DISK_UNVERIFIED', '无法验证外部读取视图,保留原文件');
|
|
265
300
|
return { contentVerified: true, diskState: 'unknown', diskVerified: false };
|
|
266
301
|
}
|
|
302
|
+
if (mode === 'protected') {
|
|
303
|
+
if (raw.hash === expected.hash && raw.size === expected.size) throw fault('PROTECTION_MISMATCH', '原受保护文件的外部读取视图变为明文,已拒绝提交');
|
|
304
|
+
return { contentVerified: true, diskState: 'preserved', diskVerified: false, protectionObserved: true };
|
|
305
|
+
}
|
|
267
306
|
if (raw.hash !== expected.hash || raw.size !== expected.size) throw fault('DISK_MISMATCH', '磁盘字节与预期明文不一致');
|
|
268
307
|
return { contentVerified: true, diskState: 'plaintext', diskVerified: true };
|
|
269
308
|
}
|
|
270
309
|
|
|
271
310
|
/** 为目标生成可校验的暂存文件;所有候选失败即中止,禁止回退破坏原文。 */
|
|
272
311
|
async function prepare(stage, write, expected, decision, context = {}) {
|
|
273
|
-
if (decision.mode !== 'plaintext') {
|
|
312
|
+
if (decision.mode !== 'plaintext' || decision.allowDirect) {
|
|
274
313
|
await write(stage);
|
|
275
|
-
try { return await verify(stage, expected, decision.mode, context); }
|
|
276
|
-
catch (error) {
|
|
314
|
+
try { return await verify(stage, expected, decision.verifyMode || decision.mode, context); }
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (decision.mode === 'preserve' || !['DISK_MISMATCH', 'CONTENT_MISMATCH'].includes(error.code)) throw error;
|
|
317
|
+
}
|
|
277
318
|
await fs.rm(stage, { force: true });
|
|
278
319
|
}
|
|
279
320
|
const p = await getProfile(context);
|
package/lib/files.js
CHANGED
|
@@ -4,18 +4,9 @@ const nativeFs = require('node:fs');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const crypto = require('node:crypto');
|
|
6
6
|
const { pipeline } = require('node:stream/promises');
|
|
7
|
-
const { setTimeout: delay } = require('node:timers/promises');
|
|
8
|
-
const { performance } = require('node:perf_hooks');
|
|
9
7
|
const { fault, fingerprint, payloadFingerprint, checkBudget } = require('./text');
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
function pathKey(file) { return process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file); }
|
|
13
|
-
|
|
14
|
-
/** 判定 candidate 是否等于 base 或位于其子树,避免字符串前缀误判。 */
|
|
15
|
-
function inside(base, candidate) {
|
|
16
|
-
const rel = path.relative(pathKey(base), pathKey(candidate));
|
|
17
|
-
return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
|
|
18
|
-
}
|
|
8
|
+
const { createLocker, pathKey, inside } = require('./locks');
|
|
9
|
+
const { cleanupFiles, finishCleanup } = require('./cleanup');
|
|
19
10
|
|
|
20
11
|
/** 从最近存在的祖先解析真实路径,识别目录符号链接和 junction 越界。 */
|
|
21
12
|
async function canonical(file) {
|
|
@@ -41,6 +32,8 @@ function createFiles(encryption, options = {}) {
|
|
|
41
32
|
const configuredRoots = options.allowedRoots ?? (process.env.MCP_ALLOWED_ROOTS ? JSON.parse(process.env.MCP_ALLOWED_ROOTS) : []);
|
|
42
33
|
if (!Array.isArray(configuredRoots) || !configuredRoots.every(x => typeof x === 'string' && path.isAbsolute(x))) throw fault('INVALID_CONFIG', 'MCP_ALLOWED_ROOTS 必须是绝对路径 JSON 数组');
|
|
43
34
|
const rootsPromise = Promise.all(configuredRoots.map(canonical));
|
|
35
|
+
const locked = createLocker(encryption.stateDir);
|
|
36
|
+
let realBasePromise;
|
|
44
37
|
|
|
45
38
|
/** 在操作前解析路径并执行服务端权限范围检查,删除链接时只解析其父目录。 */
|
|
46
39
|
async function resolve(value, { write = false, leaf = false, destructive = false } = {}) {
|
|
@@ -50,40 +43,16 @@ function createFiles(encryption, options = {}) {
|
|
|
50
43
|
const real = leaf ? path.join(await canonical(path.dirname(absolute)), path.basename(absolute)) : await canonical(absolute);
|
|
51
44
|
const roots = await rootsPromise;
|
|
52
45
|
if (roots.length && !roots.some(root => inside(root, real))) throw fault('PATH_OUTSIDE_ROOTS', '路径不在允许的根目录内');
|
|
53
|
-
if (destructive
|
|
54
|
-
|
|
46
|
+
if (destructive) {
|
|
47
|
+
// 同时保护配置别名和真实工作根;其他链接仍按leaf语义只删除链接本身。
|
|
48
|
+
const realBase = await (realBasePromise ||= canonical(baseDir));
|
|
49
|
+
if ([path.parse(real).root, baseDir, realBase, ...roots].some(root => inside(real, root))) {
|
|
50
|
+
throw fault('PROTECTED_ROOT', '不能删除或移动盘符根、工作目录、配置根目录或包含这些根的祖先目录');
|
|
51
|
+
}
|
|
55
52
|
}
|
|
56
53
|
return real;
|
|
57
54
|
}
|
|
58
55
|
|
|
59
|
-
/** 为多个路径按稳定顺序获取独占锁,避免多实例覆盖和死锁。 */
|
|
60
|
-
async function locked(files, action, context = {}) {
|
|
61
|
-
const dir = path.join(encryption.stateDir, '.mcp-file-locks');
|
|
62
|
-
await fs.mkdir(dir, { recursive: true });
|
|
63
|
-
const held = [];
|
|
64
|
-
const deadline = Math.min(context.deadline ?? Infinity, performance.now() + 5000);
|
|
65
|
-
try {
|
|
66
|
-
for (const file of [...new Set(files.map(pathKey))].sort()) {
|
|
67
|
-
const lock = path.join(dir, crypto.createHash('sha256').update(file).digest('hex') + '.lock');
|
|
68
|
-
while (true) {
|
|
69
|
-
checkBudget(context.signal, context.deadline);
|
|
70
|
-
try {
|
|
71
|
-
const handle = await fs.open(lock, 'wx', 0o600);
|
|
72
|
-
held.push(lock);
|
|
73
|
-
try { await handle.writeFile(JSON.stringify({ pid: process.pid, file, createdAt: new Date().toISOString() })); }
|
|
74
|
-
finally { await handle.close(); }
|
|
75
|
-
break;
|
|
76
|
-
} catch (error) {
|
|
77
|
-
if (error.code !== 'EEXIST') throw error;
|
|
78
|
-
if (performance.now() > deadline) throw fault('FILE_BUSY', '文件正被其他操作占用;若进程异常退出,请核对锁文件: ' + lock);
|
|
79
|
-
await delay(30);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return await action();
|
|
84
|
-
} finally { for (const lock of held.reverse()) await fs.rm(lock, { force: true }); }
|
|
85
|
-
}
|
|
86
|
-
|
|
87
56
|
/** 取得文件修改前指纹,不把目录或特殊设备当作普通文件写入。 */
|
|
88
57
|
async function previous(file, context) {
|
|
89
58
|
const stat = await statMaybe(file);
|
|
@@ -98,17 +67,25 @@ function createFiles(encryption, options = {}) {
|
|
|
98
67
|
if (current?.hash !== before?.hash) throw fault('CONFLICT', '文件在操作期间已改变,未覆盖新内容');
|
|
99
68
|
}
|
|
100
69
|
|
|
70
|
+
/** 将校验范围明确告知调用者,Node可读不能代替其他编辑器的解密验收。 */
|
|
71
|
+
function verificationWarnings(verified) {
|
|
72
|
+
if (verified.diskState === 'unknown') return ['内容已校验,但没有外部读取器确认文件存储状态'];
|
|
73
|
+
if (verified.diskState === 'preserved') return ['仅确认Node可见内容;preserved不保证IDEA等其他程序能够解密'];
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
|
|
101
77
|
/** 暂存、校验、备份、提交、再校验;异常时恢复原目标或保留可恢复备份。 */
|
|
102
78
|
async function commit(file, writer, expected, options = {}) {
|
|
103
79
|
const before = await previous(file, options);
|
|
104
80
|
if (options.expectedHash !== undefined && options.expectedHash !== (before?.hash ?? null)) throw fault('CONFLICT', 'expectedHash 与当前文件不一致');
|
|
105
81
|
if (before && options.overwrite === false) throw fault('ALREADY_EXISTS', '目标已存在,overwrite=false');
|
|
106
82
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
107
|
-
const decision = await encryption.strategy(file, options.writePolicy, options);
|
|
83
|
+
const decision = await encryption.strategy(file, options.writePolicy, options, { target: before, source: options.sourceSnapshot });
|
|
84
|
+
const verifyMode = decision.verifyMode || decision.mode;
|
|
108
85
|
if (before?.hash === expected.hash) {
|
|
109
86
|
try {
|
|
110
|
-
const verified = await encryption.verify(file, expected,
|
|
111
|
-
return { changed: false, hash: expected.hash, ...verified };
|
|
87
|
+
const verified = await encryption.verify(file, expected, verifyMode, options);
|
|
88
|
+
return { changed: false, hash: expected.hash, ...verified, strategy: decision, warnings: verificationWarnings(verified) };
|
|
112
89
|
} catch (error) { if (error.code !== 'DISK_MISMATCH') throw error; }
|
|
113
90
|
}
|
|
114
91
|
const suffix = path.extname(file);
|
|
@@ -118,6 +95,8 @@ function createFiles(encryption, options = {}) {
|
|
|
118
95
|
let backedUp = false;
|
|
119
96
|
let installed = false;
|
|
120
97
|
let retainBackup = false;
|
|
98
|
+
let outcome;
|
|
99
|
+
let failure;
|
|
121
100
|
try {
|
|
122
101
|
const prepared = await encryption.prepare(stage, writer, expected, decision, options);
|
|
123
102
|
// Windows FlushFileBuffers需要可写句柄,刷盘后再恢复原权限。
|
|
@@ -129,9 +108,9 @@ function createFiles(encryption, options = {}) {
|
|
|
129
108
|
if (before) { await fs.rename(file, backup); backedUp = true; }
|
|
130
109
|
await fs.rename(stage, file);
|
|
131
110
|
installed = true;
|
|
132
|
-
const verified = await encryption.verify(file, expected, prepared.via ? 'plaintext' :
|
|
111
|
+
const verified = await encryption.verify(file, expected, prepared.via ? 'plaintext' : verifyMode, options);
|
|
133
112
|
if (backedUp) { await fs.rm(backup); backedUp = false; }
|
|
134
|
-
|
|
113
|
+
outcome = { changed: true, hash: expected.hash, size: expected.size, ...verified, strategy: decision, ...(prepared.via ? { via: prepared.via } : {}), warnings: verificationWarnings(verified) };
|
|
135
114
|
} catch (error) {
|
|
136
115
|
// 回滚不受已取消的请求预算影响,优先恢复原始文件。
|
|
137
116
|
try {
|
|
@@ -145,11 +124,10 @@ function createFiles(encryption, options = {}) {
|
|
|
145
124
|
error.recoveryPath = backedUp ? backup : null;
|
|
146
125
|
error.rollbackError = rollbackError.message;
|
|
147
126
|
}
|
|
148
|
-
|
|
149
|
-
} finally {
|
|
150
|
-
await fs.rm(stage, { force: true });
|
|
151
|
-
if (!retainBackup && backedUp) await fs.rm(backup, { force: true });
|
|
127
|
+
failure = error;
|
|
152
128
|
}
|
|
129
|
+
const cleanupErrors = await cleanupFiles([stage, ...(!retainBackup && backedUp ? [backup] : [])]);
|
|
130
|
+
return finishCleanup(outcome, failure, cleanupErrors);
|
|
153
131
|
}
|
|
154
132
|
|
|
155
133
|
/** 提交文本载荷,写入者使用独占创建防止临时名冲突。 */
|
|
@@ -165,7 +143,7 @@ function createFiles(encryption, options = {}) {
|
|
|
165
143
|
const targetStat = await statMaybe(destination);
|
|
166
144
|
if (pathKey(source) === pathKey(destination) || (targetStat && sourceStat.dev === targetStat.dev && sourceStat.ino === targetStat.ino)) return { changed: false, sameFile: true };
|
|
167
145
|
const expected = await fingerprint(source, settings);
|
|
168
|
-
const result = await commit(destination, target => pipeline(nativeFs.createReadStream(source), nativeFs.createWriteStream(target, { flags: 'wx', mode: sourceStat.mode }), { signal: settings.signal }), expected, settings);
|
|
146
|
+
const result = await commit(destination, target => pipeline(nativeFs.createReadStream(source), nativeFs.createWriteStream(target, { flags: 'wx', mode: sourceStat.mode }), { signal: settings.signal }), expected, { ...settings, sourceSnapshot: { file: source, fingerprint: expected } });
|
|
169
147
|
return { ...result, sourceHash: expected.hash };
|
|
170
148
|
}
|
|
171
149
|
|
|
@@ -185,6 +163,12 @@ function createFiles(encryption, options = {}) {
|
|
|
185
163
|
const createdDirectories = [];
|
|
186
164
|
const sourceDirectories = [];
|
|
187
165
|
let removedSources = 0;
|
|
166
|
+
const removedSourcePaths = [];
|
|
167
|
+
/** 文件和目录仅在真实删除成功后计数,保留有界的源变化清单。 */
|
|
168
|
+
function recordSourceRemoval(file) {
|
|
169
|
+
removedSources++;
|
|
170
|
+
if (removedSourcePaths.length < 100) removedSourcePaths.push(file);
|
|
171
|
+
}
|
|
188
172
|
/** 逐项遍历,不跟随符号链接,避免跨根和意外递归。 */
|
|
189
173
|
async function visit(src, dst) {
|
|
190
174
|
checkBudget(settings.signal, settings.deadline);
|
|
@@ -223,30 +207,73 @@ function createFiles(encryption, options = {}) {
|
|
|
223
207
|
for (const item of completed) {
|
|
224
208
|
if ((await fingerprint(item.source, settings)).hash !== item.sourceHash) throw fault('CONFLICT', '源文件在删源前被修改,已保留');
|
|
225
209
|
await fs.unlink(item.source);
|
|
226
|
-
|
|
210
|
+
recordSourceRemoval(item.source);
|
|
211
|
+
}
|
|
212
|
+
for (const dir of sourceDirectories.reverse()) {
|
|
213
|
+
checkBudget(settings.signal, settings.deadline);
|
|
214
|
+
await fs.rmdir(dir);
|
|
215
|
+
recordSourceRemoval(dir);
|
|
227
216
|
}
|
|
228
|
-
for (const dir of sourceDirectories.reverse()) await fs.rmdir(dir);
|
|
229
217
|
}
|
|
230
|
-
|
|
218
|
+
const cleanupErrors = completed.flatMap(x => x.cleanupErrors || []).slice(0, 100);
|
|
219
|
+
return { changed: move || completed.some(x => x.changed) || createdDirectories.length > 0, source, destination, files: completed.length, moved: move, ...(cleanupErrors.length ? { cleanupErrors } : {}), warnings: completed.flatMap(x => x.warnings || []).slice(0, 10) };
|
|
231
220
|
} catch (error) {
|
|
232
221
|
error.changed = !!error.changed || removedSources > 0 || completed.some(x => x.changed) || createdDirectories.length > 0;
|
|
233
222
|
error.partial = [...new Set([...(error.partial || []), ...completed.map(x => x.destination)])].slice(0, 100);
|
|
234
223
|
error.sourceRetained = removedSources === 0;
|
|
224
|
+
if (move) {
|
|
225
|
+
error.removedSourceCount = removedSources;
|
|
226
|
+
error.removedSourcePaths = removedSourcePaths;
|
|
227
|
+
error.removedSourcePathsTruncated = removedSources > removedSourcePaths.length;
|
|
228
|
+
}
|
|
229
|
+
const cleanupErrors = [...completed.flatMap(x => x.cleanupErrors || []), ...(error.cleanupErrors || [])].slice(0, 100);
|
|
230
|
+
if (cleanupErrors.length) error.cleanupErrors = cleanupErrors;
|
|
235
231
|
throw error;
|
|
236
232
|
}
|
|
237
233
|
}, settings);
|
|
238
234
|
}
|
|
239
235
|
|
|
240
|
-
/**
|
|
236
|
+
/** 逐项删除并记录实际完成项,部分失败不再报告为未修改。 */
|
|
241
237
|
async function remove(value, settings = {}) {
|
|
242
238
|
if (!allowDelete) throw fault('DELETE_DISABLED', '服务已禁用删除工具');
|
|
243
239
|
const file = await resolve(value, { write: !settings.dryRun, leaf: true, destructive: true });
|
|
244
240
|
return locked([file], async () => {
|
|
245
241
|
const st = await fs.lstat(file);
|
|
246
242
|
if (settings.dryRun) return { changed: false, dryRun: true, path: file, type: st.isDirectory() ? 'directory' : st.isSymbolicLink() ? 'symlink' : 'file' };
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
243
|
+
const removed = [];
|
|
244
|
+
let removedCount = 0;
|
|
245
|
+
let visited = 0;
|
|
246
|
+
let failedPath = file;
|
|
247
|
+
/** 不跟随链接,逐层检查预算;只在系统删除成功后计数。 */
|
|
248
|
+
async function erase(target, depth = 0) {
|
|
249
|
+
failedPath = target;
|
|
250
|
+
checkBudget(settings.signal, settings.deadline);
|
|
251
|
+
if (++visited > 10000) throw fault('ITEM_LIMIT', '删除超过一万项预算');
|
|
252
|
+
if (depth > 128) throw fault('DEPTH_LIMIT', '删除目录深度超过128层');
|
|
253
|
+
await resolve(target, { write: true, leaf: true, destructive: true });
|
|
254
|
+
const current = await fs.lstat(target);
|
|
255
|
+
if (current.isDirectory()) {
|
|
256
|
+
if (settings.recursive !== false) {
|
|
257
|
+
const handle = await fs.opendir(target);
|
|
258
|
+
for await (const entry of handle) await erase(path.join(target, entry.name), depth + 1);
|
|
259
|
+
}
|
|
260
|
+
failedPath = target;
|
|
261
|
+
checkBudget(settings.signal, settings.deadline);
|
|
262
|
+
await fs.rmdir(target);
|
|
263
|
+
} else await fs.unlink(target);
|
|
264
|
+
removedCount++;
|
|
265
|
+
if (removed.length < 100) removed.push(target);
|
|
266
|
+
}
|
|
267
|
+
try { await erase(file); }
|
|
268
|
+
catch (error) {
|
|
269
|
+
error.changed = removedCount > 0;
|
|
270
|
+
error.partial = removed;
|
|
271
|
+
error.removedCount = removedCount;
|
|
272
|
+
error.partialTruncated = removedCount > removed.length;
|
|
273
|
+
error.failedPath = failedPath;
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
return { changed: true, path: file, removedCount };
|
|
250
277
|
}, settings);
|
|
251
278
|
}
|
|
252
279
|
|
package/lib/locks.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** 跨进程登记路径占用;祖先与后代互斥,无关路径可并行。 */
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const { setTimeout: delay } = require('node:timers/promises');
|
|
6
|
+
const { performance } = require('node:perf_hooks');
|
|
7
|
+
const { fault, checkBudget } = require('./text');
|
|
8
|
+
const { cleanupFiles, finishCleanup } = require('./cleanup');
|
|
9
|
+
|
|
10
|
+
/** 对Windows路径统一大小写,供路径关系和占用记录比较。 */
|
|
11
|
+
function pathKey(file) { return process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file); }
|
|
12
|
+
|
|
13
|
+
/** 按路径段判断同路径或后代,避免相似前缀误判。 */
|
|
14
|
+
function inside(base, candidate) {
|
|
15
|
+
const rel = path.relative(pathKey(base), pathKey(candidate));
|
|
16
|
+
return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 创建使用同一状态目录的跨实例锁管理器。 */
|
|
20
|
+
function createLocker(stateDir) {
|
|
21
|
+
const dir = path.join(stateDir, '.mcp-file-locks');
|
|
22
|
+
const guard = path.join(dir, '.registry-guard');
|
|
23
|
+
|
|
24
|
+
/** 在短临界区内检查并原子登记整组路径,等待期间不持有其他路径锁。 */
|
|
25
|
+
async function locked(files, action, context = {}) {
|
|
26
|
+
await fs.mkdir(dir, { recursive: true });
|
|
27
|
+
const wanted = [...new Set(files.map(pathKey))].sort();
|
|
28
|
+
const deadline = Math.min(context.deadline ?? Infinity, performance.now() + 5000);
|
|
29
|
+
let lease = null;
|
|
30
|
+
let value;
|
|
31
|
+
let failure;
|
|
32
|
+
try {
|
|
33
|
+
while (!lease) {
|
|
34
|
+
checkBudget(context.signal, context.deadline);
|
|
35
|
+
if (performance.now() > deadline) throw fault('FILE_BUSY', '路径或其子树正被其他操作占用;异常退出后请核对锁目录: ' + dir);
|
|
36
|
+
let guarding = false;
|
|
37
|
+
let attemptError;
|
|
38
|
+
try {
|
|
39
|
+
const handle = await fs.open(guard, 'wx', 0o600);
|
|
40
|
+
guarding = true;
|
|
41
|
+
try { await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })); }
|
|
42
|
+
finally { await handle.close(); }
|
|
43
|
+
let conflict = false;
|
|
44
|
+
for (const entry of await fs.readdir(dir)) {
|
|
45
|
+
checkBudget(context.signal, context.deadline);
|
|
46
|
+
if (!entry.endsWith('.lock')) continue;
|
|
47
|
+
let record;
|
|
48
|
+
try { record = JSON.parse(await fs.readFile(path.join(dir, entry), 'utf8')); }
|
|
49
|
+
catch (error) {
|
|
50
|
+
// 持有者可在临界区外释放记录;已删除的记录不再冲突。
|
|
51
|
+
if (error.code === 'ENOENT') continue;
|
|
52
|
+
throw fault('FILE_BUSY', '锁记录不可读取,请核对后处理: ' + path.join(dir, entry));
|
|
53
|
+
}
|
|
54
|
+
const occupied = record?.files || (record?.file ? [record.file] : null);
|
|
55
|
+
if (!Array.isArray(occupied) || !occupied.length || !occupied.every(x => typeof x === 'string' && path.isAbsolute(x))) throw fault('FILE_BUSY', '锁记录无效,请核对: ' + path.join(dir, entry));
|
|
56
|
+
if (wanted.some(a => occupied.some(b => inside(a, b) || inside(b, a)))) { conflict = true; break; }
|
|
57
|
+
}
|
|
58
|
+
if (!conflict) {
|
|
59
|
+
const candidate = path.join(dir, crypto.randomUUID() + '.lock');
|
|
60
|
+
const handle = await fs.open(candidate, 'wx', 0o600);
|
|
61
|
+
lease = candidate;
|
|
62
|
+
try { await handle.writeFile(JSON.stringify({ pid: process.pid, files: wanted, createdAt: new Date().toISOString() })); }
|
|
63
|
+
finally { await handle.close(); }
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (guarding || error.code !== 'EEXIST') attemptError = error;
|
|
67
|
+
} finally {
|
|
68
|
+
if (guarding) {
|
|
69
|
+
try { await fs.rm(guard, { force: true }); }
|
|
70
|
+
catch (error) {
|
|
71
|
+
const diagnostic = { path: guard, code: error.code, message: error.message };
|
|
72
|
+
if (!attemptError) attemptError = fault('LOCK_CLEANUP_FAILED', '锁登记临界区无法释放,请核对: ' + guard);
|
|
73
|
+
attemptError.cleanupErrors = [...(attemptError.cleanupErrors || []), diagnostic];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (attemptError) throw attemptError;
|
|
78
|
+
if (!lease) await delay(30);
|
|
79
|
+
}
|
|
80
|
+
checkBudget(context.signal, context.deadline);
|
|
81
|
+
value = await action();
|
|
82
|
+
} catch (error) { failure = error; }
|
|
83
|
+
return finishCleanup(value, failure, await cleanupFiles(lease ? [lease] : []));
|
|
84
|
+
}
|
|
85
|
+
return locked;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { createLocker, pathKey, inside };
|
package/lib/patterns.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** glob 解析及保持原始位置的字符串编辑。 */
|
|
2
|
-
const {
|
|
2
|
+
const { setImmediate: yieldLoop } = require('node:timers/promises');
|
|
3
|
+
const { fault, normalizeEol, detectEol, EDIT_BYTES, checkBudget } = require('./text');
|
|
4
|
+
const GLOB_COMPILE_LIMIT = 100000;
|
|
5
|
+
const GLOB_WORK_LIMIT = 50000000;
|
|
6
|
+
const GLOB_YIELD_INTERVAL = 16384;
|
|
3
7
|
|
|
4
8
|
/** 按顶层逗号分隔旧版模式列表,保留花括号内的逗号。 */
|
|
5
9
|
function splitPatterns(value) {
|
|
@@ -65,6 +69,59 @@ function globMatcher(patterns) {
|
|
|
65
69
|
return relative => !matchers.length || matchers.some(m => m.alternatives.some(tokens => matchTokens(tokens, m.basename ? relative.split('/').at(-1) : relative)));
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
/** 为一次完整扫描累计编译和匹配工作量,定期让出主线程处理心跳/取消。 */
|
|
73
|
+
function globBudget(options) {
|
|
74
|
+
let work = 0;
|
|
75
|
+
let sinceYield = 0;
|
|
76
|
+
/** 先扣减总预算再执行对应计算,返回是否需要让出事件循环。 */
|
|
77
|
+
function spend(units) {
|
|
78
|
+
checkBudget(options.signal, options.deadline);
|
|
79
|
+
work += units;
|
|
80
|
+
sinceYield += units;
|
|
81
|
+
if (work > GLOB_WORK_LIMIT) throw fault('GLOB_LIMIT', '本次扫描的glob累计计算超过预算,请缩小模式或扫描范围');
|
|
82
|
+
return sinceYield >= GLOB_YIELD_INTERVAL;
|
|
83
|
+
}
|
|
84
|
+
/** 允许其他MCP请求及取消事件运行,恢复后再次检查绝对截止时间。 */
|
|
85
|
+
async function pause() {
|
|
86
|
+
await yieldLoop();
|
|
87
|
+
sinceYield = 0;
|
|
88
|
+
checkBudget(options.signal, options.deadline);
|
|
89
|
+
}
|
|
90
|
+
return { spend, pause };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 生产扫描使用异步glob;预算贯穿编译、全部分支及所有路径,不随文件重置。 */
|
|
94
|
+
async function globMatcherAsync(patterns, options = {}) {
|
|
95
|
+
const budget = globBudget(options);
|
|
96
|
+
const matchers = [];
|
|
97
|
+
let compiled = 0;
|
|
98
|
+
budget.spend(0);
|
|
99
|
+
for (const pattern of new Set(splitPatterns(patterns))) {
|
|
100
|
+
if (pattern.length > 1024) throw fault('INVALID_GLOB', 'glob模式过长');
|
|
101
|
+
const normalized = pattern.replace(/\\/g, '/');
|
|
102
|
+
if (budget.spend(normalized.length)) await budget.pause();
|
|
103
|
+
const alternatives = [];
|
|
104
|
+
for (const expanded of new Set(expandBraces(normalized))) {
|
|
105
|
+
compiled += expanded.length;
|
|
106
|
+
if (compiled > GLOB_COMPILE_LIMIT) throw fault('GLOB_LIMIT', 'glob展开后的累计长度超过10万,请减少模式与分支');
|
|
107
|
+
if (budget.spend(expanded.length)) await budget.pause();
|
|
108
|
+
alternatives.push(tokenizeGlob(expanded));
|
|
109
|
+
}
|
|
110
|
+
matchers.push({ basename: !normalized.includes('/'), alternatives });
|
|
111
|
+
}
|
|
112
|
+
/** 每次匹配继续消耗同一扫描的预算,并保持已有basename/相对路径语义。 */
|
|
113
|
+
return async function matches(relative) {
|
|
114
|
+
budget.spend(0);
|
|
115
|
+
const basename = relative.split('/').at(-1);
|
|
116
|
+
for (const matcher of matchers) {
|
|
117
|
+
for (const tokens of matcher.alternatives) {
|
|
118
|
+
if (await matchTokensAsync(tokens, matcher.basename ? basename : relative, budget)) return true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return !matchers.length;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
68
125
|
/** 有上限地展开花括号;不将用户模式交给可能回溯的JS正则。 */
|
|
69
126
|
function expandBraces(pattern, depth = 0) {
|
|
70
127
|
if (depth > 8) throw fault('INVALID_GLOB', 'glob嵌套超过上限');
|
|
@@ -99,20 +156,35 @@ function tokenizeGlob(pattern) {
|
|
|
99
156
|
return tokens;
|
|
100
157
|
}
|
|
101
158
|
|
|
102
|
-
/**
|
|
159
|
+
/** 计算一个token的动态规划行,供同步对照和异步生产匹配共用。 */
|
|
160
|
+
function advanceToken(previous, token, value) {
|
|
161
|
+
const current = new Uint8Array(value.length + 1);
|
|
162
|
+
let reachable = false;
|
|
163
|
+
for (let j = 0; j <= value.length; j++) {
|
|
164
|
+
if (token.type === 'star' || token.type === 'all') current[j] = previous[j] || (j > 0 && current[j - 1] && (token.type === 'all' || value[j - 1] !== '/')) ? 1 : 0;
|
|
165
|
+
else if (token.type === 'dirs') { current[j] = previous[j] || (j > 0 && value[j - 1] === '/' && reachable) ? 1 : 0; reachable ||= !!previous[j]; }
|
|
166
|
+
else if (j > 0) current[j] = previous[j - 1] && (token.type === 'one' ? value[j - 1] !== '/' : value[j - 1] === token.value) ? 1 : 0;
|
|
167
|
+
}
|
|
168
|
+
return current;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 同步匹配保留给兼容调用与语义对照,生产工具使用异步入口。 */
|
|
103
172
|
function matchTokens(tokens, value) {
|
|
173
|
+
if (tokens.length * value.length > 1000000) throw fault('GLOB_LIMIT', 'glob匹配超过计算预算');
|
|
174
|
+
let previous = new Uint8Array(value.length + 1);
|
|
175
|
+
previous[0] = 1;
|
|
176
|
+
for (const token of tokens) previous = advanceToken(previous, token, value);
|
|
177
|
+
return !!previous[value.length];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 逐行计算并共享请求总预算;一次同步片段最多一行加一个让出间隔。 */
|
|
181
|
+
async function matchTokensAsync(tokens, value, budget) {
|
|
104
182
|
if (tokens.length * value.length > 1000000) throw fault('GLOB_LIMIT', 'glob匹配超过计算预算');
|
|
105
183
|
let previous = new Uint8Array(value.length + 1);
|
|
106
184
|
previous[0] = 1;
|
|
107
185
|
for (const token of tokens) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
for (let j = 0; j <= value.length; j++) {
|
|
111
|
-
if (token.type === 'star' || token.type === 'all') current[j] = previous[j] || (j > 0 && current[j - 1] && (token.type === 'all' || value[j - 1] !== '/')) ? 1 : 0;
|
|
112
|
-
else if (token.type === 'dirs') { current[j] = previous[j] || (j > 0 && value[j - 1] === '/' && reachable) ? 1 : 0; reachable ||= !!previous[j]; }
|
|
113
|
-
else if (j > 0) current[j] = previous[j - 1] && (token.type === 'one' ? value[j - 1] !== '/' : value[j - 1] === token.value) ? 1 : 0;
|
|
114
|
-
}
|
|
115
|
-
previous = current;
|
|
186
|
+
if (budget.spend(value.length + 1)) await budget.pause();
|
|
187
|
+
previous = advanceToken(previous, token, value);
|
|
116
188
|
}
|
|
117
189
|
return !!previous[value.length];
|
|
118
190
|
}
|
|
@@ -163,13 +235,26 @@ function applyLiteral(original, edit, ignoreCase = false) {
|
|
|
163
235
|
if (!matches.count) throw fault('NO_MATCH', '未找到匹配内容;请检查空格、缩进和原文版本');
|
|
164
236
|
if (edit.expectedMatches !== undefined && matches.count !== edit.expectedMatches) throw fault('MATCH_COUNT_MISMATCH', '实际匹配数与 expectedMatches 不一致', { matched: matches.count });
|
|
165
237
|
const chosen = matches.selected;
|
|
166
|
-
|
|
167
|
-
|
|
238
|
+
const spans = [];
|
|
239
|
+
let bytes = Buffer.byteLength(original);
|
|
240
|
+
const replacementBytes = Buffer.byteLength(replacement);
|
|
241
|
+
for (const match of chosen) {
|
|
168
242
|
const start = map ? map(match.index) : match.index;
|
|
169
243
|
const end = map ? map(match.index + match[0].length) : match.index + match[0].length;
|
|
170
|
-
|
|
244
|
+
bytes += replacementBytes - Buffer.byteLength(original.slice(start, end));
|
|
245
|
+
spans.push({ start, end });
|
|
246
|
+
}
|
|
247
|
+
if (bytes > EDIT_BYTES) throw fault('FILE_TOO_LARGE', '编辑结果超过16MB预算');
|
|
248
|
+
// 预先核对输出体量,再按原文位置一次拼接,避免逐匹配复制整个文件。
|
|
249
|
+
const parts = [];
|
|
250
|
+
let cursor = 0;
|
|
251
|
+
for (const { start, end } of spans) {
|
|
252
|
+
parts.push(original.slice(cursor, start), replacement);
|
|
253
|
+
cursor = end;
|
|
171
254
|
}
|
|
255
|
+
parts.push(original.slice(cursor));
|
|
256
|
+
const updated = parts.join('');
|
|
172
257
|
return { updated, matched: matches.count, replaced: chosen.length, eolAdapted: !!map };
|
|
173
258
|
}
|
|
174
259
|
|
|
175
|
-
module.exports = { splitPatterns, escapeRegex, globToRegex, globMatcher, applyLiteral };
|
|
260
|
+
module.exports = { splitPatterns, escapeRegex, globToRegex, globMatcher, globMatcherAsync, applyLiteral };
|
package/lib/regex-worker.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/** 将用户正则隔离在可终止线程,主线程保持 MCP 心跳响应。 */
|
|
2
2
|
const { parentPort } = require('node:worker_threads');
|
|
3
|
+
const { applyLiteral } = require('./patterns');
|
|
3
4
|
|
|
4
5
|
/** 执行有限输出的匹配或编辑,超时由父线程终止整个 worker。 */
|
|
5
6
|
function execute(task) {
|
|
7
|
+
if (task.operation === 'literal_edit') return applyLiteral(task.text, task.edit, task.ignoreCase);
|
|
6
8
|
const regex = new RegExp(task.pattern, 'gm' + (task.ignoreCase ? 'i' : ''));
|
|
7
9
|
if (task.operation === 'edit') {
|
|
8
10
|
let count = 0;
|
|
@@ -39,5 +41,5 @@ function execute(task) {
|
|
|
39
41
|
|
|
40
42
|
parentPort.on('message', message => {
|
|
41
43
|
try { parentPort.postMessage({ id: message.id, result: execute(message.task) }); }
|
|
42
|
-
catch (error) { parentPort.postMessage({ id: message.id, error: error.message }); }
|
|
44
|
+
catch (error) { parentPort.postMessage({ id: message.id, error: { code: error.code || (message.task.operation === 'literal_edit' ? 'EDIT_FAILED' : 'INVALID_REGEX'), message: error.message } }); }
|
|
43
45
|
});
|
package/lib/regex.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** 正则与字面量编辑共用有界计算线程;主线程负责取消和截止时间。 */
|
|
2
2
|
const { Worker } = require('node:worker_threads');
|
|
3
3
|
const path = require('node:path');
|
|
4
|
-
const {
|
|
4
|
+
const { performance } = require('node:perf_hooks');
|
|
5
|
+
const { fault, checkBudget } = require('./text');
|
|
5
6
|
let worker = null;
|
|
6
7
|
let serial = 0;
|
|
7
8
|
const pending = new Map();
|
|
@@ -26,7 +27,8 @@ function ensureWorker() {
|
|
|
26
27
|
pending.delete(message.id);
|
|
27
28
|
clearTimeout(job.timer);
|
|
28
29
|
job.cleanup();
|
|
29
|
-
if (
|
|
30
|
+
if (performance.now() > job.deadline) job.reject(fault('TIMEOUT', '计算结果到达时已超过操作预算'));
|
|
31
|
+
else if (message.error) job.reject(fault(message.error.code, message.error.message));
|
|
30
32
|
else job.resolve(message.result);
|
|
31
33
|
if (!pending.size) current.unref();
|
|
32
34
|
});
|
|
@@ -35,21 +37,33 @@ function ensureWorker() {
|
|
|
35
37
|
return current;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
/**
|
|
39
|
-
function
|
|
40
|
-
if (task.pattern.length > 4096) return Promise.reject(fault('INVALID_REGEX', '正则超过 4096 字符'));
|
|
40
|
+
/** 分派有硬超时和队列上限的计算任务,连同排队时间一起计入预算。 */
|
|
41
|
+
function runTask(task, options = {}) {
|
|
41
42
|
if (pending.size >= 16) return Promise.reject(fault('BUSY', '正则队列已满,请缩小搜索范围'));
|
|
42
43
|
if (options.signal?.aborted) return Promise.reject(fault('CANCELLED', '操作已取消'));
|
|
43
44
|
return new Promise((resolve, reject) => {
|
|
45
|
+
checkBudget(options.signal, options.deadline);
|
|
44
46
|
const current = ensureWorker();
|
|
45
47
|
current.ref();
|
|
46
48
|
const id = ++serial;
|
|
47
49
|
const abort = () => stop(fault('CANCELLED', '正则任务已取消'));
|
|
48
|
-
const
|
|
50
|
+
const deadline = Math.min(options.deadline ?? Infinity, performance.now() + (options.timeoutMs ?? 1000));
|
|
51
|
+
const timer = setTimeout(() => stop(task.operation === 'literal_edit' ? fault('TIMEOUT', '字面量编辑超过时间预算') : fault('REGEX_TIMEOUT', '正则计算超过预算;请简化正则或使用字面量模式')), Math.max(1, deadline - performance.now()));
|
|
49
52
|
options.signal?.addEventListener('abort', abort, { once: true });
|
|
50
|
-
pending.set(id, { resolve, reject, timer, cleanup: () => options.signal?.removeEventListener('abort', abort) });
|
|
53
|
+
pending.set(id, { resolve, reject, timer, deadline, cleanup: () => options.signal?.removeEventListener('abort', abort) });
|
|
51
54
|
current.postMessage({ id, task });
|
|
52
55
|
});
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
|
|
58
|
+
/** 限制用户正则长度后交给工作线程执行。 */
|
|
59
|
+
function runRegex(task, options = {}) {
|
|
60
|
+
if (task.pattern.length > 4096) return Promise.reject(fault('INVALID_REGEX', '正则超过 4096 字符'));
|
|
61
|
+
return runTask(task, options);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 字面量保留原始索引、换行和替换语义,同样具备线程取消能力。 */
|
|
65
|
+
function runLiteral(text, edit, ignoreCase, options = {}) {
|
|
66
|
+
return runTask({ operation: 'literal_edit', text, edit, ignoreCase }, options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { runRegex, runLiteral, stop };
|
package/lib/server.js
CHANGED
|
@@ -6,8 +6,8 @@ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
|
6
6
|
const { z } = require('zod');
|
|
7
7
|
const pkg = require('../package.json');
|
|
8
8
|
const text = require('./text');
|
|
9
|
-
const {
|
|
10
|
-
const { runRegex } = require('./regex');
|
|
9
|
+
const { globMatcherAsync, escapeRegex } = require('./patterns');
|
|
10
|
+
const { runRegex, runLiteral } = require('./regex');
|
|
11
11
|
const { createEncryption } = require('./encryption');
|
|
12
12
|
const { createFiles, statMaybe } = require('./files');
|
|
13
13
|
const IGNORE = new Set(['node_modules', '.git', 'target', 'build', 'dist', '.idea', '.vscode', '.svn', 'bin', 'obj', 'out', 'vendor']);
|
|
@@ -52,9 +52,8 @@ function createServer(options = {}) {
|
|
|
52
52
|
return await action(args, context);
|
|
53
53
|
} catch (error) {
|
|
54
54
|
return result('操作失败:' + error.message, { changed: !!error.changed,
|
|
55
|
-
...(
|
|
56
|
-
|
|
57
|
-
}, { ok: false, code: error.code || 'INTERNAL_ERROR' });
|
|
55
|
+
...Object.fromEntries(['recoveryPath', 'rollbackError', 'partial', 'sourceRetained', 'removedSourceCount', 'removedSourcePaths', 'removedSourcePathsTruncated', 'removedCount', 'partialTruncated', 'failedPath', 'cleanupErrors'].filter(key => error[key] !== undefined).map(key => [key, error[key]])),
|
|
56
|
+
}, { ok: false, code: error.code || 'INTERNAL_ERROR', warnings: error.cleanupErrors?.length ? ['部分临时文件未能清理,详见cleanupErrors'] : [] });
|
|
58
57
|
}
|
|
59
58
|
};
|
|
60
59
|
handlers[name] = handler;
|
|
@@ -127,7 +126,9 @@ function createServer(options = {}) {
|
|
|
127
126
|
let matched = 0;
|
|
128
127
|
for (const item of args.edits || [args]) {
|
|
129
128
|
text.checkBudget(context.signal, context.deadline);
|
|
130
|
-
const
|
|
129
|
+
const computeBudget = { ...context, timeoutMs: Math.max(1, Math.min(1000, context.deadline - performance.now())) };
|
|
130
|
+
const change = args.useRegex ? await runRegex({ operation: 'edit', text: updated, pattern: item.oldString, replacement: text.normalizeEol(item.newString, text.detectEol(updated)), replaceAll: item.replaceAll === true, ignoreCase: args.ignoreCase }, computeBudget) : await runLiteral(updated, item, args.ignoreCase, computeBudget);
|
|
131
|
+
text.checkBudget(context.signal, context.deadline);
|
|
131
132
|
if (!change.matched) throw text.fault('NO_MATCH', '未找到匹配内容,文件未修改');
|
|
132
133
|
if (item.expectedMatches !== undefined && item.expectedMatches !== change.matched) throw text.fault('MATCH_COUNT_MISMATCH', '实际匹配数与expectedMatches不一致');
|
|
133
134
|
updated = change.updated;
|
|
@@ -137,6 +138,7 @@ function createServer(options = {}) {
|
|
|
137
138
|
}
|
|
138
139
|
const payload = (original.hasBom ? '\uFEFF' : '') + updated;
|
|
139
140
|
const details = { matched, replaced, originalHash: before.hash, proposedHash: text.payloadFingerprint(payload).hash, preview: preview(original.content, updated) };
|
|
141
|
+
text.checkBudget(context.signal, context.deadline);
|
|
140
142
|
if (args.dryRun) return result('编辑预览完成,文件未修改', { changed: false, dryRun: true, ...details });
|
|
141
143
|
const data = await files.write(file, payload, { ...context, expectedHash: before.hash, writePolicy: args.writePolicy });
|
|
142
144
|
return result('编辑成功: ' + file + '\n实际替换 ' + replaced + ' 处,共匹配 ' + matched + ' 处', { ...data, ...details }, { warnings: data.warnings || [] });
|
|
@@ -176,7 +178,7 @@ function createServer(options = {}) {
|
|
|
176
178
|
...scanShape, pattern: z.string().max(4096), include: patternsSchema, ignoreCase: z.boolean().optional(), onlyMatching: z.boolean().optional(), mode: z.enum(['regex', 'literal']).optional(), contextLines: z.number().int().min(0).max(10).optional(),
|
|
177
179
|
}, true, async (args, context) => {
|
|
178
180
|
const root = await files.resolve(args.path);
|
|
179
|
-
const include =
|
|
181
|
+
const include = await globMatcherAsync(args.include, context);
|
|
180
182
|
const errors = [];
|
|
181
183
|
const skipped = { binary: 0, large: 0, unreadable: 0 };
|
|
182
184
|
const results = [];
|
|
@@ -186,7 +188,7 @@ function createServer(options = {}) {
|
|
|
186
188
|
let truncated = false;
|
|
187
189
|
const exclude = Array.isArray(args.exclude) ? args.exclude : (args.exclude || '').split(',').filter(Boolean);
|
|
188
190
|
for await (const entry of walk(root, { ...args, exclude }, errors, context)) {
|
|
189
|
-
if (entry.type !== 'file' || !include(entry.relative)) continue;
|
|
191
|
+
if (entry.type !== 'file' || !(await include(entry.relative))) continue;
|
|
190
192
|
if (results.length >= limit || budget <= 0) { truncated = true; break; }
|
|
191
193
|
let content;
|
|
192
194
|
try {
|
|
@@ -211,23 +213,25 @@ function createServer(options = {}) {
|
|
|
211
213
|
}
|
|
212
214
|
if (found.truncated || truncated) { truncated = true; break; }
|
|
213
215
|
}
|
|
216
|
+
text.checkBudget(context.signal, context.deadline);
|
|
214
217
|
const warnings = errors.length ? ['部分文件/目录未能扫描,详见errors和skipped;无匹配不代表这些文件没有内容'] : [];
|
|
215
218
|
return result('找到 ' + results.length + ' 处匹配(扫描 ' + scanned + ' 个文件)\n' + results.map(hit => hit.path + ':' + hit.line + ':' + hit.text).join('\n') + (truncated ? '\n结果已截断,请缩小范围' : ''), { results, scanned, skipped, errors, truncated }, { warnings, ok: scanned > 0 || !errors.length, code: scanned === 0 && errors.length ? 'SCAN_INCOMPLETE' : 'OK' });
|
|
216
219
|
});
|
|
217
220
|
register('find_files', '按glob递归查找,支持花括号、字面括号路径、隐藏项及默认忽略开关。', { ...scanShape, pattern: z.string().min(1).max(1024) }, true, async (args, context) => {
|
|
218
221
|
const root = await files.resolve(args.path);
|
|
219
|
-
const matches =
|
|
222
|
+
const matches = await globMatcherAsync([args.pattern], context);
|
|
220
223
|
const entries = [];
|
|
221
224
|
const errors = [];
|
|
222
225
|
let truncated = false;
|
|
223
226
|
let budget = text.READ_LIMIT;
|
|
224
227
|
const exclude = Array.isArray(args.exclude) ? args.exclude : (args.exclude || '').split(',').filter(Boolean);
|
|
225
228
|
for await (const entry of walk(root, { ...args, exclude }, errors, context)) {
|
|
226
|
-
if (!matches(entry.relative)) continue;
|
|
229
|
+
if (!(await matches(entry.relative))) continue;
|
|
227
230
|
budget -= entry.file.length + entry.relative.length + 64;
|
|
228
231
|
if (entries.length >= (args.maxResults ?? 500) || budget < 0) { truncated = true; break; }
|
|
229
232
|
entries.push(entry);
|
|
230
233
|
}
|
|
234
|
+
text.checkBudget(context.signal, context.deadline);
|
|
231
235
|
return result(entries.map(entry => entry.type.toUpperCase() + ' ' + entry.file).join('\n') || '未找到匹配项', { entries, errors, truncated }, { warnings: errors.length ? ['部分目录未能扫描'] : [], ok: !!entries.length || !errors.length, code: !entries.length && errors.length ? 'SCAN_INCOMPLETE' : 'OK' });
|
|
232
236
|
});
|
|
233
237
|
register('list_directory', '列目录的文件/目录/链接类型与元数据,支持分页。', { path: pathSchema, showHidden: z.boolean().optional(), offset: z.number().int().min(0).max(1000000).optional(), maxResults: z.number().int().min(1).max(2000).optional() }, true, async (args, context) => {
|
|
@@ -257,11 +261,13 @@ function createServer(options = {}) {
|
|
|
257
261
|
if (st.isFile() && args.calculateHash !== false) { const fp = await text.fingerprint(file, context); data.sizeReadable = fp.size; data.sizePlaintext = fp.size; data.hash = fp.hash; }
|
|
258
262
|
return result(JSON.stringify(data, null, 2), data);
|
|
259
263
|
});
|
|
260
|
-
register('create_directory', '递归创建目录,受只读模式与allowedRoots约束。', { path: pathSchema }, false, async args => {
|
|
264
|
+
register('create_directory', '递归创建目录,受只读模式与allowedRoots约束。', { path: pathSchema }, false, async (args, context) => {
|
|
261
265
|
const dir = await files.resolve(args.path, { write: true });
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
266
|
+
return files.locked([dir], async () => {
|
|
267
|
+
const existed = !!(await statMaybe(dir));
|
|
268
|
+
await fs.mkdir(dir, { recursive: true });
|
|
269
|
+
return result('目录已创建: ' + dir, { path: dir, changed: !existed });
|
|
270
|
+
}, context);
|
|
265
271
|
});
|
|
266
272
|
const transferShape = { source: pathSchema, destination: pathSchema, overwrite: z.boolean().optional(), writePolicy: policySchema, timeoutMs: timeoutSchema };
|
|
267
273
|
register('copy_path', '逐文件复制和验证。目标已有目录时放入其下;失败返回部分目标并保留源,不自动跟随链接。', transferShape, false, async (args, context) => {
|
|
@@ -304,10 +310,12 @@ function createServer(options = {}) {
|
|
|
304
310
|
const data = await encryption.mark(args.extension, args.category);
|
|
305
311
|
return result('策略已更新: ' + data.extension + ' = ' + data.category, { ...data, changed: true });
|
|
306
312
|
});
|
|
307
|
-
register('inspect_write_strategy', '
|
|
313
|
+
register('inspect_write_strategy', '检查写入策略:已有文件比较Node与外部读取视图,新建路径可在父目录创建探测样本;不修改目标文件。', { path: pathSchema, writePolicy: policySchema, timeoutMs: timeoutSchema }, false, async (args, context) => {
|
|
308
314
|
const file = await files.resolve(args.path);
|
|
309
|
-
|
|
310
|
-
|
|
315
|
+
return files.locked([path.dirname(file)], async () => {
|
|
316
|
+
const data = await encryption.strategy(file, args.writePolicy, context);
|
|
317
|
+
return result(JSON.stringify(data, null, 2), data);
|
|
318
|
+
}, context);
|
|
311
319
|
});
|
|
312
320
|
return { server, handlers, encryption, files };
|
|
313
321
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-read-file-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "加密环境文件操作 MCP 工具。当 Node.js 是加密软件白名单进程时,通过 fs 模块自动解密读写文件明文,替代 AI Agent 内置文件工具,适用于任何支持 MCP 协议的 Agent。",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "index.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
18
|
"start": "node index.js",
|
|
19
|
-
"test": "node --test --test-reporter=tap --test-concurrency=1 test/regression.test.js test/protocol.test.js test/adapters.test.js test/review-fixes.test.js",
|
|
19
|
+
"test": "node --test --test-reporter=tap --test-concurrency=1 test/regression.test.js test/protocol.test.js test/adapters.test.js test/review-fixes.test.js test/resilience.test.js test/followup-fixes.test.js test/write-policy.test.js",
|
|
20
20
|
"check": "node scripts/check.js"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|