dsh-remote-agent 0.4.3 → 0.5.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/CHANGELOG.md CHANGED
@@ -9,6 +9,38 @@
9
9
 
10
10
  ---
11
11
 
12
+ ## [0.5.0] - 2026-10-17
13
+
14
+ ### 功能:远程文件工具集(提案 #25)
15
+
16
+ 四个**只读**文件工具,装完即有 —— AI 可以直接读副机上的文件,**不用配
17
+ 网盘、不用挂载、不用 SMB**:
18
+
19
+ - `remote_ls`:列目录(单层分页,目录优先,条目带类型/大小/修改时间)
20
+ - `remote_read`:读文件 —— 文本 ≤8MB 按行分段(`offset`/`limit` 翻页,
21
+ 单次响应 ≤256KB,超长行截断);二进制只回 sha256 + 大小 + 头部 hex 预览
22
+ - `remote_grep`:副机本地全文搜索(纯 JS 逐文件扫,**不 spawn 外部 grep**,
23
+ Windows 没有);上限 2000 文件 / 单文件 2MB / 命中 ≤200 / 总时长 10s,
24
+ 达到上限如实报告 `truncated`
25
+ - `remote_tree`:目录树摘要(深度 1-6 默认 3,条目 ≤2000)—— 扫描在副机
26
+ 跑完只回结构,50GB 目录也不搬数据
27
+
28
+ 架构与安全:
29
+
30
+ - 独立协议帧 `fs/req`(hub→node)↔ `fs/res`(node→hub),带 `replyTo` 关联
31
+ 与 15s 超时;副机断连即清理全部等待者。**不进任务队列、不占并发槽** ——
32
+ 一条慢命令锁死队列的形态在这里结构性不存在
33
+ - 安全模型与任务链路同一条底线:路径 `realpathSync.native`(展开 8.3 短名
34
+ 与符号链接)后与 `roots` 前缀比对;`denyPaths` 在枚举与读取两层生效
35
+ (被禁条目连名字都不上报);符号链接只列条目不解引用;整个模块没有写动词
36
+ - `fs/req` 加入 E2E 必需加密帧集:relay 模式下文件路径与内容对中继不可见
37
+
38
+ 测试:`test/fs-service.test.js` 14 条单测 + hub⇄worker e2e 2 条(真实通道
39
+ 四工具贯通 + relay 窃听器验证文件内容/文件名不可见)+ 工具收口与 inject
40
+ 契约断言同步(8 → 12 个工具)。
41
+
42
+ ---
43
+
12
44
  ## [0.4.0] - 2026-10-17
13
45
 
14
46
  ### 验证:`agent` 链路两台真机确认通过
package/README.en.md CHANGED
@@ -235,6 +235,10 @@ Once installed you **don't need to memorize commands** — just talk:
235
235
  | "What artifacts are there?" | `remote_manifest` |
236
236
  | "Pull report.pdf over" | `remote_pull` |
237
237
  | "Cancel that task" | `remote_cancel` |
238
+ | "List what's in D:\BigData on the worker" | `remote_ls` |
239
+ | "Read that config.json on the worker" | `remote_read` |
240
+ | "Search the worker's 50GB folder for this term" | `remote_grep` |
241
+ | "What does that folder on the worker look like?" | `remote_tree` |
238
242
 
239
243
  There is also a Web panel (sidebar → "远程同事"): node list (with CPU / memory
240
244
  / load badges), task dispatch, task states, result summaries, workspace
@@ -265,6 +269,40 @@ changes, change diffs, final artifacts, log tail.
265
269
 
266
270
  </details>
267
271
 
272
+ ### Reading files on the worker (no network drive needed)
273
+
274
+ Four read-only file tools (`remote_ls` / `remote_read` / `remote_grep` /
275
+ `remote_tree`) work out of the box: the AI can list directories, read files,
276
+ run full-text search, and inspect folder structure — **no network share, no
277
+ mount, no SMB setup**. Scan-type operations (grep/tree) run locally on the
278
+ worker and only the results cross the wire — a 50GB folder never moves.
279
+
280
+ <details>
281
+ <summary>File tool parameters and boundaries</summary>
282
+
283
+ | Tool | Parameters | Meaning |
284
+ |---|---|---|
285
+ | `remote_ls` | `path` (required), `limit`, `offset` | List a directory (single level, paged, directories first) |
286
+ | `remote_read` | `path` (required), `offset`, `limit` | Text files ≤8MB are paged line by line (≤256KB per response); binaries return only sha256 + size + hex head preview |
287
+ | `remote_grep` | `path`, `pattern` (required), `glob`, `regex`, `ignoreCase`, `maxMatches` | Full-text search on the worker (pure JS, no external grep spawned); caps: 2000 files / 2MB per file / 200 matches / 10 s |
288
+ | `remote_tree` | `path` (required), `depth` | Directory-tree summary (depth 1-6, default 3; ≤2000 entries) |
289
+
290
+ **Boundaries (same bottom line as the task pipeline)**: read-only — the module
291
+ has no write verb; paths must be absolute and inside the node's `roots`,
292
+ anything else is rejected (`workspace-denied`); `denyPaths` applies too (denied
293
+ entries are not even reported by name); symbolic links are listed but never
294
+ followed. File operations travel on a dedicated `fs/req`↔`fs/res` channel —
295
+ **they never enter the task queue or occupy concurrency slots** — and under
296
+ relay mode they are **end-to-end encrypted** like task frames (the relay sees
297
+ ciphertext only).
298
+
299
+ **Combining with manual setups**: for "AI looks up / searches a file" use
300
+ these tools (zero config, works across NATs); for "browse huge media files
301
+ like a local drive" SMB/network drives remain the right tool — they
302
+ complement each other.
303
+
304
+ </details>
305
+
268
306
  ---
269
307
 
270
308
  ## Security
package/README.md CHANGED
@@ -208,6 +208,10 @@ ack 自动按明文兼容旧中继继续),安全边界与已知局限见
208
208
  | 「有哪些成品?」 | `remote_manifest` |
209
209
  | 「把 report.pdf 拉过来」 | `remote_pull` |
210
210
  | 「取消那个任务」 | `remote_cancel` |
211
+ | 「看看副机 D:\BigData 里有什么」 | `remote_ls` |
212
+ | 「读一下副机上那个 config.json」 | `remote_read` |
213
+ | 「在副机 50GB 目录里搜这个词」 | `remote_grep` |
214
+ | 「副机那个文件夹结构长什么样」 | `remote_tree` |
211
215
 
212
216
  另有一个 Web 面板(侧栏「远程同事」):节点列表(含 CPU/内存/负载与仓库清单)、
213
217
  派发任务、**待审批卡**(exec 任务默认挂起,批准才下发)、任务状态、结果摘要、
@@ -238,6 +242,34 @@ ack 自动按明文兼容旧中继继续),安全边界与已知局限见
238
242
 
239
243
  </details>
240
244
 
245
+ ### 读副机上的文件(不用配网盘)
246
+
247
+ 四个只读文件工具(`remote_ls` / `remote_read` / `remote_grep` / `remote_tree`)
248
+ 装完即有:AI 可以直接列目录、读文件、全文搜索、看目录结构 —— **不用配网盘、
249
+ 不用挂载、不用 SMB**。扫描类操作(grep/tree)在副机本地跑完只回结果,
250
+ 50GB 目录也不用搬数据。
251
+
252
+ <details>
253
+ <summary>文件工具的参数与边界</summary>
254
+
255
+ | 工具 | 参数 | 说明 |
256
+ |---|---|---|
257
+ | `remote_ls` | `path`(必填)、`limit`、`offset` | 列目录(单层分页,目录优先) |
258
+ | `remote_read` | `path`(必填)、`offset`、`limit` | 文本 ≤8MB 按行分段(单次 ≤256KB);二进制只回 sha256+大小+头部预览 |
259
+ | `remote_grep` | `path`、`pattern`(必填)、`glob`、`regex`、`ignoreCase`、`maxMatches` | 副机本地全文搜索(纯 JS,不 spawn 外部命令);上限 2000 文件 / 单文件 2MB / 命中 200 / 10 秒 |
260
+ | `remote_tree` | `path`(必填)、`depth` | 目录树摘要(深度 1-6,默认 3;条目 ≤2000) |
261
+
262
+ **边界(与任务链路同一条底线)**:只读,没有任何写操作;路径必须是绝对路径
263
+ 且落在该节点 `roots` 内,超出即拒(`workspace-denied`);`denyPaths` 同样生效
264
+ (被禁条目连名字都不上报);符号链接只列条目不解引用。文件操作走独立的
265
+ `fs/req`↔`fs/res` 帧,**不进任务队列、不占并发槽**,且在 relay 模式下与任务
266
+ 帧一样**全程端到端加密**(中继只见密文)。
267
+
268
+ **与手工方案的组合**:要"AI 点读/搜索"用本组工具(零配置、跨网可用);
269
+ 要"资源管理器像本地盘一样翻大文件",SMB/网盘仍是正解 —— 两者互补。
270
+
271
+ </details>
272
+
241
273
  ---
242
274
 
243
275
  ## 安全
package/SECURITY.md CHANGED
@@ -202,6 +202,30 @@ docs/verification-repos-routing-real.md §4③)。超长值 400 字符截断
202
202
  协议里**不带模型 API key**。副机用自己的 DSH 配置与凭证 ——
203
203
  主机只能派任务,拿不到副机的模型凭证。
204
204
 
205
+ ### 远程文件工具:只读、roots 边界、链接不跟随(提案 #25)
206
+
207
+ 四个文件工具(`remote_ls` / `remote_read` / `remote_grep` / `remote_tree`)
208
+ 让 AI 直接读副机文件 —— 它们的安全模型:
209
+
210
+ - **只读**。`lib/node/fs-service.js` 整个模块没有任何写动词;二进制文件
211
+ 只回 sha256 + 大小 + 64 字节头部预览,不回原始字节。
212
+ - **`roots` 是同一条边界**。每个入口路径先 `realpathSync.native`(展开
213
+ 8.3 短名与符号链接,与任务工作区解析同口径)再与 `roots` 前缀比对,
214
+ 越界即 `workspace-denied`;`denyPaths` 在**枚举层**就生效 —— 被禁
215
+ 条目连名字都不会上报给 hub。
216
+ - **符号链接不跟随**。tree/grep 遍历时链接只列条目本身:roots 内一个指向
217
+ `C:\Users` 的链接不是逃逸口。
218
+ - **带宽有界**。read 单响应 ≤256KB;grep 上限 2000 文件 / 单文件 2MB /
219
+ 命中 200 / 10 秒;tree 条目 ≤2000。副机不会被一次请求拖死。
220
+ - **独立通道 + 加密**。文件操作走 `fs/req`↔`fs/res` 专用帧(15s 超时,
221
+ 断连即失败),**不进任务队列、不占并发槽**;`fs/req` 在 relay 模式下
222
+ 属于必需加密帧集 —— 文件路径与内容对中继不可见。
223
+
224
+ 注意:本组工具与 `remote_task` 共享 `roots` 的**读取**面 —— 给了
225
+ `roots` 就意味着主机 AI 可以读那些内容(与派任务让副机 agent 读文件
226
+ 的既有能力等价,只是更快更直接)。不想让 AI 读某目录,把它加进
227
+ `denyPaths`。
228
+
205
229
  ### exec 两阶段审批:默认挂起,等不到人不跑(提案 #2)
206
230
 
207
231
  `mode:"exec"` 的任务**默认 `approval:"manual"`**:hub 收到派单后挂起,
@@ -84,6 +84,8 @@ class NodeRegistryCore {
84
84
  this.pendingApprovals = new Map()
85
85
  /** taskId → HubTask(全局索引,工具只拿得到 taskId) */
86
86
  this.tasks = new Map()
87
+ /** reqId → { resolve, timer }(提案 #25:远程文件请求的等待者表) */
88
+ this.fsPending = new Map()
87
89
  /** @type {import('./pull-manager.js').PullManager | undefined} */
88
90
  this.pulls = undefined
89
91
  /** @type {import('./relay-link.js').RelayLink | undefined} */
@@ -710,6 +712,72 @@ class NodeRegistryCore {
710
712
  return false
711
713
  }
712
714
 
715
+ /**
716
+ * 远程文件请求(提案 #25):发 fs/req 并等 fs/res,超时 fail-fast。
717
+ *
718
+ * 为什么不用既有工具的"发完就走"模式:ls/read/grep/tree 是**查询语义**,
719
+ * 调用方(AI 工具)必须拿到结果才有意义 —— 所以这里把"发帧 + 等 replyTo"
720
+ * 包成一个 promise。超时拒收而不是无限等:节点掉线 / 帧丢失时调用方拿到
721
+ * fs-timeout 而不是永久挂起。
722
+ *
723
+ * @returns {Promise<object>} node 回的 result(或 reject {code, message})
724
+ */
725
+ fsRequest(nodeId, op, params = {}, { timeoutMs = 15_000 } = {}) {
726
+ return new Promise((resolve, reject) => {
727
+ const rec = this.nodes.get(nodeId)
728
+ if (!rec?.online) {
729
+ reject(Object.assign(new Error(`node not online: ${nodeId}`), { code: ERROR_CODE.TASK_UNKNOWN }))
730
+ return
731
+ }
732
+ const env = envelope(HUB_TO_NODE.FS_REQ, { op, ...params })
733
+ const reqId = env.id
734
+ const timer = setTimeout(() => {
735
+ this.fsPending.delete(reqId)
736
+ this.log('warn', 'fs request timeout', { nodeId, op, reqId, timeoutMs })
737
+ reject(Object.assign(new Error(`fs ${op} timed out after ${timeoutMs}ms`), { code: ERROR_CODE.FS_TIMEOUT }))
738
+ }, timeoutMs)
739
+ if (typeof timer.unref === 'function') timer.unref()
740
+ this.fsPending.set(reqId, { resolve, reject, timer, op, nodeId })
741
+ const sent = this.send(nodeId, HUB_TO_NODE.FS_REQ, { op, ...params }, { id: reqId })
742
+ if (!sent) {
743
+ clearTimeout(timer)
744
+ this.fsPending.delete(reqId)
745
+ reject(Object.assign(new Error(`failed to send fs request to ${nodeId}`), { code: ERROR_CODE.TASK_UNKNOWN }))
746
+ }
747
+ })
748
+ }
749
+
750
+ /** fs/res 到达:按 replyTo 找等待者并唤醒(registry-ingest 的 FS_RES case 转发到这里)。 */
751
+ handleFsRes(nodeId, d) {
752
+ const reqId = typeof d.replyTo === 'string' ? d.replyTo : ''
753
+ const waiter = reqId !== '' ? this.fsPending.get(reqId) : undefined
754
+ if (!waiter) {
755
+ // 迟到的响应(超时后才回来):丢弃即可,等待者已经拿到 fs-timeout
756
+ this.log('info', 'late fs response dropped', { nodeId, reqId })
757
+ return
758
+ }
759
+ clearTimeout(waiter.timer)
760
+ this.fsPending.delete(reqId)
761
+ if (d.ok === true) {
762
+ waiter.resolve(d.result ?? {})
763
+ } else {
764
+ waiter.reject(Object.assign(
765
+ new Error(typeof d.message === 'string' ? d.message : 'fs request failed'),
766
+ { code: typeof d.code === 'string' ? d.code : ERROR_CODE.INTERNAL },
767
+ ))
768
+ }
769
+ }
770
+
771
+ /** 节点断连:清掉它名下还没等到 fs/res 的等待者(fail-fast,不挂调用方)。 */
772
+ fsCancelForNode(nodeId) {
773
+ for (const [reqId, w] of this.fsPending) {
774
+ if (w.nodeId !== nodeId) continue
775
+ clearTimeout(w.timer)
776
+ w.reject(Object.assign(new Error('node disconnected during fs request'), { code: ERROR_CODE.FS_TIMEOUT }))
777
+ this.fsPending.delete(reqId)
778
+ }
779
+ }
780
+
713
781
  /**
714
782
  * 发一个二进制帧给节点(header.to 由中继用来路由)。
715
783
  * E2E 会话存在时整体封密:外层只留 t/seq/iv + to。
@@ -813,6 +881,8 @@ class NodeRegistryCore {
813
881
  this.log('info', 'node offline', { nodeId, reason })
814
882
  // 断连时在途的产物分块必然残缺,直接失败掉,别让临时文件挂着
815
883
  this.pulls?.onNodeOffline?.(nodeId)
884
+ // 在途的远程文件请求同样 fail-fast(提案 #25):等不到 fs/res 就别挂调用方
885
+ this.fsCancelForNode(nodeId)
816
886
  }
817
887
 
818
888
  startHeartbeat() {
@@ -201,6 +201,12 @@ export function withIngest(Base) {
201
201
  case NODE_TO_HUB.PONG:
202
202
  return
203
203
 
204
+ case NODE_TO_HUB.FS_RES:
205
+ // 远程文件响应(提案 #25):按 replyTo 唤醒 hub 侧的 fsRequest 等待者。
206
+ // 加密帧 —— fs 结果(目录结构 / 文件内容)不许明文过中继。
207
+ this.handleFsRes(nodeId, d)
208
+ return
209
+
204
210
  case NODE_TO_HUB.ERROR:
205
211
  this.log('warn', 'node reported error', { nodeId, code: d.code, taskId: d.taskId })
206
212
  if (d.code === ERROR_CODE.E2E_REQUIRED) {
package/lib/hub/tools.js CHANGED
@@ -2,8 +2,9 @@
2
2
  * 主机侧 remote_* 工具集(零外部依赖)。
3
3
  *
4
4
  * 设计原则:**主电脑只消费 handoff,不消费副机的原始日志。**
5
- * 七个工具的职责边界刻意切得很窄:
5
+ * 工具的职责边界刻意切得很窄:
6
6
  *
7
+ * 任务链路:
7
8
  * remote_nodes 看有哪些副机在线(信息汇聚的入口)
8
9
  * remote_task 派一条任务(幂等 taskId,立即返回,不阻塞等结果)
9
10
  * remote_status 看一条任务现在到哪了(进度尾巴 + 日志尾巴)
@@ -11,6 +12,13 @@
11
12
  * remote_manifest 取产物清单(先看清单,再决定拉什么)
12
13
  * remote_pull 显式拉取成品(只有 role:final 能过河)
13
14
  * remote_cancel 打断一条任务
15
+ * remote_purge 清理 hub 侧任务视图(逃生舱)
16
+ *
17
+ * 远程文件(提案 #25,只读,走 fs/req↔fs/res 独立通道,不进任务队列):
18
+ * remote_ls 列副机目录(分页)
19
+ * remote_read 读文件(文本按行分段 / 二进制只回元数据)
20
+ * remote_grep 副机本地全文搜索(纯 JS,不 spawn)
21
+ * remote_tree 目录树摘要(扫描在副机跑完只回结构)
14
22
  *
15
23
  * 所有 execute 都返回普通 JSON(output.schema 描述它),错误以
16
24
  * RemoteAgentError 抛出,由框架统一materialize 成错误结果。
@@ -1016,6 +1024,228 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
1016
1024
  },
1017
1025
  })
1018
1026
 
1027
+ // ── remote_ls / remote_read / remote_grep / remote_tree(提案 #25)──
1028
+ //
1029
+ // 四个只读文件工具共用同一条 RPC 通道:registry.fsRequest(nodeId, op, params)
1030
+ // (fs/req ↔ fs/res,15s 超时,断连即失败)。与任务链路的本质区别:
1031
+ // **不进副机任务队列、不 spawn、不占并发槽** —— 一条 grep 卡住锁死队列
1032
+ // 的形态在这里结构性不存在(fs-service 自带 10s/2000 文件等扫描上限)。
1033
+ //
1034
+ // 副机 reject 的形态是 { code, message }(node/index.js handleFsReq 从
1035
+ // FsError.code 透传),这里转成 RemoteAgentError 让 DSH 框架统一物化,
1036
+ // 同时保留 code(workspace-denied / artifact-missing / artifact-too-large)
1037
+ // —— 调用方可以区分"越界""不存在""太大"三种最常见的失败。
1038
+ const fsOp = (name, description, parameters, output, op) => {
1039
+ reg({
1040
+ name,
1041
+ description,
1042
+ parameters,
1043
+ output,
1044
+ async execute(args) {
1045
+ // resolveNodeId 会抛 unknown node / node offline(RemoteAgentError)
1046
+ const nodeId = resolveNodeId(registry, args?.node)
1047
+ // 只有 node 是路由概念不透传;path 是副机 op 的正式参数(fs-service 签名)
1048
+ const params = { ...(args ?? {}) }
1049
+ delete params.node
1050
+ try {
1051
+ const result = await registry.fsRequest(nodeId, op, params)
1052
+ return { node: nodeId, ...(result && typeof result === 'object' ? result : {}) }
1053
+ } catch (err) {
1054
+ const code = typeof err?.code === 'string' ? err.code : ERROR_CODE.INTERNAL
1055
+ throw new RemoteAgentError(`remote ${op} on ${nodeId} failed: ${String(err?.message ?? err)}`, code)
1056
+ }
1057
+ },
1058
+ })
1059
+ }
1060
+
1061
+ // 通用参数块:path 必填(副机上的绝对路径);node 可选(自动路由)。
1062
+ const pathParam = { type: 'string', description: '副机上的绝对路径,必须落在该节点允许的 roots 之内(超出会被副机以 workspace-denied 拒绝)。' }
1063
+ const nodeParam = { type: 'string', description: '目标节点 id(或主机名)。省略时自动选在线节点。' }
1064
+ const fsReq = (props, required) => ({ type: 'object', properties: { node: nodeParam, ...props }, required: ['path', ...required], additionalProperties: false })
1065
+
1066
+ fsOp(
1067
+ 'remote_ls',
1068
+ '列出远程副机上的一个目录(单层、分页,按名称/大小/修改时间/类型)。用于"看看副机这个文件夹里有什么"。安全模型:只读;路径必须在该节点允许的 roots 内;denyPaths 生效;符号链接只列条目不解引用。',
1069
+ fsReq({
1070
+ path: pathParam,
1071
+ limit: { type: 'number', description: '单页条目数(1-2000,默认 500)。' },
1072
+ offset: { type: 'number', description: '起始偏移(配合 limit 翻页,默认 0)。' },
1073
+ }, []),
1074
+ {
1075
+ schema: {
1076
+ type: 'object',
1077
+ properties: {
1078
+ node: { type: 'string' },
1079
+ path: { type: 'string' },
1080
+ total: { type: 'number' },
1081
+ offset: { type: 'number' },
1082
+ truncated: { type: 'boolean' },
1083
+ entries: {
1084
+ type: 'array',
1085
+ items: {
1086
+ type: 'object',
1087
+ properties: {
1088
+ name: { type: 'string' },
1089
+ type: { type: 'string' },
1090
+ size: { type: 'number' },
1091
+ mtimeMs: { type: 'number' },
1092
+ },
1093
+ required: ['name', 'type'],
1094
+ },
1095
+ },
1096
+ },
1097
+ required: ['node', 'path', 'entries'],
1098
+ },
1099
+ render: renderText((_args, value) => {
1100
+ const entries = arr(value?.entries)
1101
+ if (entries.length === 0) return `${value?.path ?? '?'} 是空目录(或条目被 denyPaths 全部过滤)。`
1102
+ const lines = [`${value.path}(共 ${value.total} 项${value.truncated ? ',本页截断' : ''}):`, '']
1103
+ for (const e of entries.slice(0, 200)) {
1104
+ lines.push(`- ${e.type === 'dir' ? '[目录]' : e.type === 'link' ? '[链接]' : '[文件]'} ${e.name}${e.type === 'file' ? ` (${human(e.size)})` : ''}`)
1105
+ }
1106
+ if (value.truncated) lines.push('', '还有更多:用 offset/limit 翻页。')
1107
+ return lines.join('\n')
1108
+ }),
1109
+ },
1110
+ 'ls',
1111
+ )
1112
+
1113
+ fsOp(
1114
+ 'remote_read',
1115
+ '读取远程副机上的一个文件。文本文件(≤8MB)按行分段返回(offset+limit 翻页,单次响应 ≤256KB,超长行截断);二进制文件只回元数据(sha256、大小、头部 hex 预览),不回原始字节。用于"读一眼副机上的某个文件"。只读;路径必须在 roots 内。',
1116
+ fsReq({
1117
+ path: pathParam,
1118
+ offset: { type: 'number', description: '起始行号(0 起,默认 0)。' },
1119
+ limit: { type: 'number', description: '本次读取行数(1-5000,默认 500)。' },
1120
+ }, []),
1121
+ {
1122
+ schema: {
1123
+ type: 'object',
1124
+ properties: {
1125
+ node: { type: 'string' },
1126
+ path: { type: 'string' },
1127
+ binary: { type: 'boolean' },
1128
+ size: { type: 'number' },
1129
+ sha256: { type: 'string' },
1130
+ ext: { type: 'string' },
1131
+ head: { type: 'string' },
1132
+ totalLines: { type: 'number' },
1133
+ offset: { type: 'number' },
1134
+ truncated: { type: 'boolean' },
1135
+ lines: { type: 'array', items: { type: 'string' } },
1136
+ },
1137
+ required: ['node', 'path', 'binary'],
1138
+ },
1139
+ render: renderText((_args, value) => {
1140
+ if (value?.binary === true) {
1141
+ return [
1142
+ `二进制文件 ${value.path}(${human(value.size)})`,
1143
+ `sha256: ${value.sha256 ?? 'n/a'}`,
1144
+ value.head ? `头部: ${String(value.head).slice(0, 64)}…` : '',
1145
+ ].filter((l) => l !== '').join('\n')
1146
+ }
1147
+ const lines = arr(value?.lines)
1148
+ if (lines.length === 0) return `${value?.path ?? '?'} 无内容(空文件或翻页越界)。`
1149
+ const out = [`${value.path} 第 ${value.offset} 行起(全文 ${value.totalLines} 行${value.truncated ? ',截断' : ''}):`, '']
1150
+ for (const l of lines.slice(0, 400)) out.push(`│ ${l}`)
1151
+ if (value.truncated) out.push('', `还有更多:用 offset=${(value.offset ?? 0) + lines.length} 继续读。`)
1152
+ return out.join('\n')
1153
+ }),
1154
+ },
1155
+ 'read',
1156
+ )
1157
+
1158
+ fsOp(
1159
+ 'remote_grep',
1160
+ '在远程副机上做全文搜索:递归扫描目录下的文本文件(可按 glob 过滤文件名),返回命中的文件/行号/内容。扫描在副机本地完成,只回命中结果——50GB 目录也不搬数据。上限:2000 文件 / 单文件 2MB / 命中 ≤200 / 总时长 10s,达到上限如实报告 truncated。只读;路径必须在 roots 内。',
1161
+ fsReq({
1162
+ path: pathParam,
1163
+ pattern: { type: 'string', description: '搜索内容。默认按字面量匹配;regex:true 时按正则解释。' },
1164
+ regex: { type: 'boolean', description: '是否把 pattern 当正则(默认 false=字面量)。' },
1165
+ ignoreCase: { type: 'boolean', description: '忽略大小写(默认 true)。' },
1166
+ glob: { type: 'string', description: '文件名过滤(如 "*.txt"、"log*"),默认扫全部文本文件。' },
1167
+ maxMatches: { type: 'number', description: '命中上限(1-200,默认 200)。想快就调小。' },
1168
+ }, ['pattern']),
1169
+ {
1170
+ schema: {
1171
+ type: 'object',
1172
+ properties: {
1173
+ node: { type: 'string' },
1174
+ path: { type: 'string' },
1175
+ pattern: { type: 'string' },
1176
+ filesScanned: { type: 'number' },
1177
+ filesSkipped: { type: 'number' },
1178
+ matchCount: { type: 'number' },
1179
+ truncated: { type: 'boolean' },
1180
+ truncatedBy: { type: 'string' },
1181
+ matches: {
1182
+ type: 'array',
1183
+ items: {
1184
+ type: 'object',
1185
+ properties: {
1186
+ file: { type: 'string' },
1187
+ line: { type: 'number' },
1188
+ text: { type: 'string' },
1189
+ },
1190
+ required: ['file', 'line'],
1191
+ },
1192
+ },
1193
+ },
1194
+ required: ['node', 'path', 'matches'],
1195
+ },
1196
+ render: renderText((_args, value) => {
1197
+ const matches = arr(value?.matches)
1198
+ if (matches.length === 0) {
1199
+ return `无命中:pattern="${value?.pattern ?? ''}"(扫描 ${value?.filesScanned ?? 0} 个文件,跳过 ${value?.filesSkipped ?? 0} 个)。`
1200
+ }
1201
+ const lines = [
1202
+ `"${value.pattern}" 命中 ${value.matchCount} 处(扫描 ${value.filesScanned} 文件${value.truncated ? `,按${value.truncatedBy === 'time' ? '超时' : '命中上限'}截断` : ''}):`,
1203
+ '',
1204
+ ]
1205
+ for (const m of matches.slice(0, 100)) lines.push(`${m.file}:${m.line}: ${String(m.text ?? '').slice(0, 300)}`)
1206
+ if (value.truncated) lines.push('', '(结果被截断——缩小搜索范围或减小 maxMatches 可拿到更完整的结果)')
1207
+ return lines.join('\n')
1208
+ }),
1209
+ },
1210
+ 'grep',
1211
+ )
1212
+
1213
+ fsOp(
1214
+ 'remote_tree',
1215
+ '查看远程副机上一个目录树的结构摘要(深度与条目数可配,默认 3 层 / 最多 2000 条)。扫描在副机本地完成,只回结构不回内容——适合"50GB 文件夹里到底长什么样"这类问题。只读;路径必须在 roots 内;符号链接只列条目不深入。',
1216
+ fsReq({
1217
+ path: pathParam,
1218
+ depth: { type: 'number', description: '最大深度(1-6,默认 3)。' },
1219
+ }, []),
1220
+ {
1221
+ schema: {
1222
+ type: 'object',
1223
+ properties: {
1224
+ node: { type: 'string' },
1225
+ path: { type: 'string' },
1226
+ depth: { type: 'number' },
1227
+ entries: { type: 'number' },
1228
+ maxEntries: { type: 'number' },
1229
+ truncated: { type: 'boolean' },
1230
+ items: { type: 'array', items: { type: 'object' } },
1231
+ },
1232
+ required: ['node', 'path', 'items'],
1233
+ },
1234
+ render: renderText((_args, value) => {
1235
+ const renderItems = (items, prefix, sink) => {
1236
+ for (const it of arr(items).slice(0, 120)) {
1237
+ sink.push(`${prefix}${it.type === 'dir' ? '📁' : '·'} ${it.name}`)
1238
+ if (it.type === 'dir' && prefix.length <= 6) renderItems(it.children, `${prefix} `, sink)
1239
+ }
1240
+ }
1241
+ const lines = [`${value?.path ?? '?'}(深度 ${value?.depth},${value?.entries} 条目${value?.truncated ? ',截断' : ''})`, '']
1242
+ renderItems(value?.items, '', lines)
1243
+ return lines.join('\n')
1244
+ }),
1245
+ },
1246
+ 'tree',
1247
+ )
1248
+
1019
1249
  return disposers
1020
1250
  }
1021
1251
 
@@ -0,0 +1,504 @@
1
+ /**
2
+ * 远程文件工具集(提案 #25)的副机本地实现 —— 四个只读操作:
3
+ * ls / read / grep / tree
4
+ *
5
+ * 安全模型(与任务链路同一条底线,缺一不可):
6
+ * - **只读**。整个模块没有任何写路径 —— fs-service 拿不到"写"这个动词。
7
+ * - **roots 边界**:每个入口路径先 realpathSync.native(展开 8.3 短名与符号
8
+ * 链接,与 exec.js resolveWorkspace 同款口径),再与 roots 前缀比对。
9
+ * - **denyPaths 生效**:isDenied 与 artifacts.js / task-engine 同一条规则,
10
+ * 被禁路径在枚举与读取两层都被拦(枚举层拦是为了连名字都不上报)。
11
+ * - **符号链接不跟随**:tree/grep 遍历时 withFileTypes 发现的链接只列条目
12
+ * 本身、绝不深入 —— 否则 roots 内一个指向 C:\Users 的链接就是逃逸口。
13
+ *
14
+ * 上限(防御 50GB 级文件夹被一次请求拖死副机):
15
+ * - read:文本模式仅支持 ≤ READABLE_FILE_MAX (8MB) 的文件按行分段;二进制
16
+ * 只回 sha256/size/头部预览;单次响应文本量 ≤ READ_TEXT_MAX (256KB)。
17
+ * - grep:文件数 ≤ GREP_MAX_FILES (2000)、单文件 ≤ GREP_FILE_MAX (2MB)、
18
+ * 命中 ≤ maxMatches (200)、总时长 ≤ GREP_TIME_MS (10s)。
19
+ * - tree:深度 ≤ depth、总条目 ≤ maxEntries (2000)。
20
+ */
21
+
22
+ import fs from 'node:fs'
23
+ import path from 'node:path'
24
+ import { createHash } from 'node:crypto'
25
+ import { isDenied } from './exec.js'
26
+ import { normalizePath, extname } from '../glob.js'
27
+ import { ERROR_CODE } from '../protocol.js'
28
+
29
+ /** 文本模式 read 的文件大小上限:超过就拒绝(建议 grep 定位后再读片段)。 */
30
+ export const READABLE_FILE_MAX = 8 * 1024 * 1024
31
+ /** 单次 read 响应携带的文本量上限(字节)。 */
32
+ export const READ_TEXT_MAX = 256 * 1024
33
+ /** grep 递归扫描的文件数上限。 */
34
+ export const GREP_MAX_FILES = 2000
35
+ /** grep 单文件大小上限:更大的文件直接跳过(防止二进制/巨型日志拖死)。 */
36
+ export const GREP_FILE_MAX = 2 * 1024 * 1024
37
+ /** grep 默认总时长上限(ms):超时停下并如实报告 truncated。 */
38
+ export const GREP_TIME_MS = 10 * 1000
39
+ /** grep 默认命中上限。 */
40
+ export const GREP_MAX_MATCHES = 200
41
+ /** tree 默认最大深度与条目数。 */
42
+ export const TREE_DEFAULT_DEPTH = 3
43
+ export const TREE_MAX_ENTRIES = 2000
44
+ /** ls 单页条目上限。 */
45
+ export const LS_PAGE_MAX = 2000
46
+ /** 二进制嗅探窗口:前 64 字节出现 NUL 即视为二进制。 */
47
+ const SNIFF_BYTES = 64
48
+
49
+ /** 复用任务链路的错误形态(code 会被 hub 工具层原样透出)。 */
50
+ export class FsError extends Error {
51
+ constructor(message, code) {
52
+ super(message)
53
+ this.name = 'FsError'
54
+ this.code = code
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 解析"可以是文件也可以是目录"的路径:realpath + roots 前缀 + denyPaths。
60
+ * 与 resolveWorkspace 的差异只在两点:不要求是目录;不存在时直接拒
61
+ * (读一个不存在的文件没有"任务会创建它"的借口)。
62
+ */
63
+ function resolveAny(requested, cfg) {
64
+ const { roots, caseInsensitive } = cfg
65
+ if (typeof requested !== 'string' || requested === '') {
66
+ throw new FsError('path is required', ERROR_CODE.WORKSPACE_DENIED)
67
+ }
68
+ if (!path.isAbsolute(requested)) {
69
+ throw new FsError(`path must be absolute: ${requested}`, ERROR_CODE.WORKSPACE_DENIED)
70
+ }
71
+ let resolved
72
+ try {
73
+ resolved = fs.realpathSync.native ? fs.realpathSync.native(requested) : fs.realpathSync(requested)
74
+ } catch {
75
+ throw new FsError(`path not found: ${requested}`, ERROR_CODE.ARTIFACT_MISSING)
76
+ }
77
+ const allowed = (roots ?? []).map((r) => {
78
+ let base = r
79
+ try {
80
+ base = fs.realpathSync.native ? fs.realpathSync.native(r) : fs.realpathSync(r)
81
+ } catch {
82
+ // 与 resolveWorkspace 同口径:不可达的根保持原样,后面照样拒一切子路径
83
+ }
84
+ return normalizePath(base, { caseInsensitive })
85
+ })
86
+ const norm = normalizePath(resolved, { caseInsensitive })
87
+ const ok = allowed.some((r) => norm === r || norm.startsWith(`${r}/`))
88
+ if (!ok) {
89
+ throw new FsError(
90
+ `path outside allowed roots: ${resolved}`,
91
+ ERROR_CODE.WORKSPACE_DENIED,
92
+ )
93
+ }
94
+ if (isDenied(resolved, cfg.denyPaths ?? [], { caseInsensitive })) {
95
+ throw new FsError(`path denied by denyPaths: ${resolved}`, ERROR_CODE.WORKSPACE_DENIED)
96
+ }
97
+ return resolved
98
+ }
99
+
100
+ /** 枚举层拒绝:条目级 denyPaths(连名字都不上报给 hub)。 */
101
+ function entryDenied(absPath, cfg) {
102
+ return isDenied(absPath, cfg.denyPaths ?? [], { caseInsensitive: cfg.caseInsensitive })
103
+ }
104
+
105
+ /** 目录条目排序:目录优先,同级按名字(locale 感知,大小写不敏感)。 */
106
+ function sortEntries(list) {
107
+ return list.sort((a, b) => {
108
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
109
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true })
110
+ })
111
+ }
112
+
113
+ /** 文件是否疑似二进制:前 SNIFF_BYTES 字节出现 NUL。 */
114
+ function looksBinary(fd) {
115
+ const buf = Buffer.alloc(SNIFF_BYTES)
116
+ // ⚠ fs.readSync 直接返回 bytesRead 数字(不是 {bytesRead, buffer} 对象 ——
117
+ // 那是 fs.read 的 promise 版)。解构会得到 undefined,subarray(0, undefined)
118
+ // 变成整个缓冲,而 alloc 未写区域全零 → **任何文件都被误判二进制**。
119
+ const bytesRead = fs.readSync(fd, buf, 0, SNIFF_BYTES, 0)
120
+ return buf.subarray(0, bytesRead).includes(0)
121
+ }
122
+
123
+ /**
124
+ * 建一个 fs-service 实例。roots/denyPaths 来自副机配置(与任务链路同源),
125
+ * caseInsensitive 跟随 Windows 语义。
126
+ */
127
+ export function createFsService(cfg) {
128
+ const { roots, caseInsensitive = false } = cfg
129
+ const conf = { roots, caseInsensitive, denyPaths: cfg.denyPaths ?? [] }
130
+
131
+ return {
132
+ /** 列目录(单层,分页)。 */
133
+ ls({ path: p, limit = 500, offset = 0 } = {}) {
134
+ const dir = resolveAny(p, conf)
135
+ let st
136
+ try {
137
+ st = fs.statSync(dir)
138
+ } catch {
139
+ throw new FsError(`path not found: ${dir}`, ERROR_CODE.ARTIFACT_MISSING)
140
+ }
141
+ if (!st.isDirectory()) {
142
+ throw new FsError(`not a directory: ${dir}`, ERROR_CODE.WORKSPACE_DENIED)
143
+ }
144
+ const limitN = Math.min(Math.max(Number(limit) || 500, 1), LS_PAGE_MAX)
145
+ const offsetN = Math.max(Number(offset) || 0, 0)
146
+ const all = []
147
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
148
+ const abs = path.join(dir, ent.name)
149
+ if (entryDenied(abs, conf)) continue
150
+ let type = 'file'
151
+ let size = 0
152
+ let mtimeMs = 0
153
+ if (ent.isSymbolicLink()) {
154
+ // 链接只报条目本身,不深入也不解引用 —— 防止 roots 内链接逃逸
155
+ type = 'link'
156
+ } else if (ent.isDirectory()) {
157
+ type = 'dir'
158
+ } else if (ent.isFile()) {
159
+ try {
160
+ const s = fs.statSync(abs)
161
+ size = s.size
162
+ mtimeMs = s.mtimeMs
163
+ } catch {
164
+ // 竞态(文件刚被删):仍上报名字,大小/时间留空
165
+ }
166
+ } else {
167
+ continue // socket/fifo 等非常规条目不上报
168
+ }
169
+ all.push({ name: ent.name, type, size, mtimeMs })
170
+ }
171
+ sortEntries(all)
172
+ return {
173
+ path: dir,
174
+ total: all.length,
175
+ offset: offsetN,
176
+ truncated: offsetN + limitN < all.length,
177
+ entries: all.slice(offsetN, offsetN + limitN),
178
+ }
179
+ },
180
+
181
+ /**
182
+ * 读文件。文本模式:≤ READABLE_FILE_MAX 的文件按行分段(offset/limit),
183
+ * 单次文本量 ≤ READ_TEXT_MAX;二进制模式:只回 sha256/size/头部预览。
184
+ */
185
+ read({ path: p, offset = 0, limit = 500 } = {}) {
186
+ const file = resolveAny(p, conf)
187
+ let st
188
+ try {
189
+ st = fs.statSync(file)
190
+ } catch {
191
+ throw new FsError(`path not found: ${file}`, ERROR_CODE.ARTIFACT_MISSING)
192
+ }
193
+ if (!st.isFile()) {
194
+ throw new FsError(`not a regular file: ${file}`, ERROR_CODE.WORKSPACE_DENIED)
195
+ }
196
+ let fd
197
+ try {
198
+ fd = fs.openSync(file, 'r')
199
+ if (looksBinary(fd)) {
200
+ // 二进制:hash 全文件 + 头部预览(hex),绝不回原始字节
201
+ const buf = Buffer.alloc(Math.min(st.size, SNIFF_BYTES))
202
+ fs.readSync(fd, buf, 0, buf.length, 0)
203
+ const hash = createHash('sha256')
204
+ const chunk = Buffer.alloc(1024 * 1024)
205
+ for (;;) {
206
+ const n = fs.readSync(fd, chunk, 0, chunk.length, null)
207
+ if (n <= 0) break
208
+ hash.update(chunk.subarray(0, n))
209
+ }
210
+ return {
211
+ path: file,
212
+ binary: true,
213
+ size: st.size,
214
+ sha256: hash.digest('hex'),
215
+ ext: extname(normalizePath(file, { caseInsensitive })),
216
+ head: buf.toString('hex'),
217
+ }
218
+ }
219
+ if (st.size > READABLE_FILE_MAX) {
220
+ throw new FsError(
221
+ `file too large for text read (${st.size} > ${READABLE_FILE_MAX}); use grep to locate then read a narrower path`,
222
+ ERROR_CODE.ARTIFACT_TOO_LARGE,
223
+ )
224
+ }
225
+ const raw = fs.readFileSync(file)
226
+ const text = raw.toString('utf8')
227
+ const lines = text.split('\n')
228
+ // 尾部空行是 split 的伪影:文件以 \n 结尾时多出一个空串
229
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
230
+ const offsetN = Math.max(Number(offset) || 0, 0)
231
+ const limitN = Math.min(Math.max(Number(limit) || 500, 1), 5000)
232
+ const slice = []
233
+ let bytes = 0
234
+ let truncated = false
235
+ for (let i = offsetN; i < lines.length; i++) {
236
+ const line = lines[i]
237
+ // 单行超长截断:一行 1MB 的压缩 JSON 会独自顶爆响应
238
+ const clipped = line.length > 2000 ? `${line.slice(0, 2000)}…[line truncated]` : line
239
+ const cost = Buffer.byteLength(clipped, 'utf8') + 1
240
+ if (bytes + cost > READ_TEXT_MAX) {
241
+ truncated = true
242
+ break
243
+ }
244
+ slice.push(clipped)
245
+ bytes += cost
246
+ if (slice.length >= limitN) {
247
+ truncated = i < lines.length - 1
248
+ break
249
+ }
250
+ }
251
+ return {
252
+ path: file,
253
+ binary: false,
254
+ size: st.size,
255
+ totalLines: lines.length,
256
+ offset: offsetN,
257
+ truncated: truncated || offsetN + slice.length < lines.length,
258
+ lines: slice,
259
+ }
260
+ } finally {
261
+ try {
262
+ if (fd !== undefined) fs.closeSync(fd)
263
+ } catch {
264
+ // 关闭失败不影响结果
265
+ }
266
+ }
267
+ },
268
+
269
+ /**
270
+ * 副机本地全文搜索:递归扫 roots 内目录,文本文件逐行匹配。
271
+ * 纯 JS(Windows 没有外部 grep 可 spawn);上限见模块头注释。
272
+ */
273
+ grep({ path: p, pattern, regex = false, ignoreCase = true, glob = '', maxMatches = GREP_MAX_MATCHES } = {}) {
274
+ if (typeof pattern !== 'string' || pattern === '') {
275
+ throw new FsError('pattern is required', ERROR_CODE.WORKSPACE_DENIED)
276
+ }
277
+ let re
278
+ try {
279
+ re = regex
280
+ ? new RegExp(pattern, ignoreCase ? 'i' : '')
281
+ : new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ignoreCase ? 'i' : '')
282
+ } catch (err) {
283
+ throw new FsError(`invalid pattern: ${err.message}`, ERROR_CODE.WORKSPACE_DENIED)
284
+ }
285
+ const dir = resolveAny(p, conf)
286
+ let st
287
+ try {
288
+ st = fs.statSync(dir)
289
+ } catch {
290
+ throw new FsError(`path not found: ${dir}`, ERROR_CODE.ARTIFACT_MISSING)
291
+ }
292
+ const maxN = Math.min(Math.max(Number(maxMatches) || GREP_MAX_MATCHES, 1), GREP_MAX_MATCHES)
293
+ const matches = []
294
+ let filesScanned = 0
295
+ let filesSkipped = 0
296
+ let truncatedByTime = false
297
+ let truncatedByLimit = false
298
+ const started = Date.now()
299
+
300
+ const isGlobMatch = (name) => {
301
+ if (!glob) return true
302
+ // 极简 glob:* 任意(不含 /)、? 单字符 —— 覆盖 *.txt / src* 之类
303
+ const esc = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')
304
+ return new RegExp(`^${esc}$`, caseInsensitive ? 'i' : '').test(name)
305
+ }
306
+
307
+ const scan = (dirAbs) => {
308
+ if (matches.length >= maxN) {
309
+ truncatedByLimit = true
310
+ return
311
+ }
312
+ if (Date.now() - started > GREP_TIME_MS) {
313
+ truncatedByTime = true
314
+ return
315
+ }
316
+ let ents
317
+ try {
318
+ ents = fs.readdirSync(dirAbs, { withFileTypes: true })
319
+ } catch {
320
+ filesSkipped++
321
+ return
322
+ }
323
+ for (const ent of ents) {
324
+ if (matches.length >= maxN) {
325
+ truncatedByLimit = true
326
+ return
327
+ }
328
+ if (Date.now() - started > GREP_TIME_MS) {
329
+ truncatedByTime = true
330
+ return
331
+ }
332
+ const abs = path.join(dirAbs, ent.name)
333
+ if (entryDenied(abs, conf)) continue
334
+ if (ent.isDirectory()) {
335
+ scan(abs)
336
+ continue
337
+ }
338
+ // 链接与非常规文件跳过:不跟随、不读
339
+ if (!ent.isFile()) {
340
+ filesSkipped++
341
+ continue
342
+ }
343
+ if (!isGlobMatch(ent.name)) continue
344
+ let fst
345
+ try {
346
+ fst = fs.statSync(abs)
347
+ } catch {
348
+ filesSkipped++
349
+ continue
350
+ }
351
+ if (fst.size > GREP_FILE_MAX || fst.size === 0) {
352
+ filesSkipped++
353
+ continue
354
+ }
355
+ let fd
356
+ try {
357
+ fd = fs.openSync(abs, 'r')
358
+ if (looksBinary(fd)) {
359
+ filesSkipped++
360
+ continue
361
+ }
362
+ const content = fs.readFileSync(abs)
363
+ filesScanned++
364
+ const text = content.toString('utf8')
365
+ const lines = text.split('\n')
366
+ for (let i = 0; i < lines.length && matches.length < maxN; i++) {
367
+ if (re.test(lines[i])) {
368
+ matches.push({
369
+ file: abs,
370
+ line: i + 1,
371
+ text: lines[i].length > 500 ? `${lines[i].slice(0, 500)}…` : lines[i],
372
+ })
373
+ // 达到全局上限即如实报告截断 —— 即使恰好在最后一个文件结束,
374
+ // 未报出的命中大概率还存在(用户视角"还有更多"才是事实)
375
+ if (matches.length >= maxN) truncatedByLimit = true
376
+ }
377
+ }
378
+ } catch {
379
+ filesSkipped++
380
+ } finally {
381
+ try {
382
+ if (fd !== undefined) fs.closeSync(fd)
383
+ } catch {
384
+ // 忽略关闭失败
385
+ }
386
+ }
387
+ }
388
+ }
389
+
390
+ if (st.isDirectory()) {
391
+ scan(dir)
392
+ } else if (st.isFile()) {
393
+ // 单文件 grep:直接匹配该文件
394
+ let fd
395
+ try {
396
+ fd = fs.openSync(dir, 'r')
397
+ if (!looksBinary(fd)) {
398
+ filesScanned++
399
+ const lines = fs.readFileSync(dir).toString('utf8').split('\n')
400
+ for (let i = 0; i < lines.length && matches.length < maxN; i++) {
401
+ if (re.test(lines[i])) {
402
+ matches.push({ file: dir, line: i + 1, text: lines[i].slice(0, 500) })
403
+ }
404
+ }
405
+ }
406
+ } catch {
407
+ filesSkipped++
408
+ } finally {
409
+ try {
410
+ if (fd !== undefined) fs.closeSync(fd)
411
+ } catch {
412
+ // 忽略
413
+ }
414
+ }
415
+ } else {
416
+ throw new FsError(`not a directory or file: ${dir}`, ERROR_CODE.WORKSPACE_DENIED)
417
+ }
418
+
419
+ return {
420
+ path: dir,
421
+ pattern,
422
+ filesScanned,
423
+ filesSkipped,
424
+ matches,
425
+ matchCount: matches.length,
426
+ truncated: truncatedByTime || truncatedByLimit,
427
+ truncatedBy: truncatedByTime ? 'time' : truncatedByLimit ? 'maxMatches' : undefined,
428
+ }
429
+ },
430
+
431
+ /** 目录树摘要:扫描在副机跑完只回结构(50GB 也不搬家)。 */
432
+ tree({ path: p, depth = TREE_DEFAULT_DEPTH, maxEntries = TREE_MAX_ENTRIES } = {}) {
433
+ const rootDir = resolveAny(p, conf)
434
+ let st
435
+ try {
436
+ st = fs.statSync(rootDir)
437
+ } catch {
438
+ throw new FsError(`path not found: ${rootDir}`, ERROR_CODE.ARTIFACT_MISSING)
439
+ }
440
+ if (!st.isDirectory()) {
441
+ throw new FsError(`not a directory: ${rootDir}`, ERROR_CODE.WORKSPACE_DENIED)
442
+ }
443
+ const depthN = Math.min(Math.max(Number(depth) || TREE_DEFAULT_DEPTH, 1), 6)
444
+ const maxN = Math.min(Math.max(Number(maxEntries) || TREE_MAX_ENTRIES, 1), TREE_MAX_ENTRIES)
445
+ let used = 0
446
+ let truncated = false
447
+
448
+ const build = (dirAbs, level) => {
449
+ if (level > depthN || used >= maxN) {
450
+ if (used >= maxN) truncated = true
451
+ return []
452
+ }
453
+ let ents
454
+ try {
455
+ ents = fs.readdirSync(dirAbs, { withFileTypes: true })
456
+ } catch {
457
+ return [{ name: '(unreadable)', type: 'error' }]
458
+ }
459
+ const items = []
460
+ const dirs = []
461
+ const files = []
462
+ for (const ent of ents) {
463
+ const abs = path.join(dirAbs, ent.name)
464
+ if (entryDenied(abs, conf)) continue
465
+ if (ent.isDirectory()) dirs.push(ent.name)
466
+ else if (ent.isFile()) files.push(ent.name)
467
+ // 链接只报名字标注 link,不深入(与 ls 同口径)
468
+ else if (ent.isSymbolicLink()) files.push(ent.name)
469
+ }
470
+ const collator = (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true })
471
+ dirs.sort(collator)
472
+ files.sort(collator)
473
+ for (const name of dirs) {
474
+ if (used >= maxN) {
475
+ truncated = true
476
+ break
477
+ }
478
+ used++
479
+ const node = { name, type: 'dir', children: build(path.join(dirAbs, name), level + 1) }
480
+ items.push(node)
481
+ }
482
+ for (const name of files) {
483
+ if (used >= maxN) {
484
+ truncated = true
485
+ break
486
+ }
487
+ used++
488
+ items.push({ name, type: 'file' })
489
+ }
490
+ return items
491
+ }
492
+
493
+ const items = build(rootDir, 1)
494
+ return {
495
+ path: rootDir,
496
+ depth: depthN,
497
+ entries: used,
498
+ maxEntries: maxN,
499
+ truncated: truncated || used >= maxN,
500
+ items,
501
+ }
502
+ },
503
+ }
504
+ }
package/lib/node/index.js CHANGED
@@ -51,6 +51,9 @@ import { friendlyConnectError } from '../setup.js'
51
51
  import { scanRepos } from './repos.js'
52
52
  import { TaskEngine } from './task-engine.js'
53
53
  import { TaskStore } from './task-store.js'
54
+ // 远程文件工具集(提案 #25):ls/read/grep/tree 只读实现,与任务链路共用
55
+ // roots / denyPaths / caseInsensitive 三个配置(同一个安全边界,两处入口)。
56
+ import { createFsService } from './fs-service.js'
54
57
  import { NodeTransport, TRANSPORT_MODE } from './transport.js'
55
58
 
56
59
  const PLUGIN_NAME = 'dsh-remote-agent'
@@ -452,6 +455,10 @@ function startWorker(ctx, config) {
452
455
  stateDir,
453
456
  ]
454
457
 
458
+ // 远程文件工具集(提案 #25):与任务链路同源的 roots/denyPaths —— 同一个
459
+ // 安全边界,两处入口。只读实现,见 lib/node/fs-service.js 头注释。
460
+ const fsService = createFsService({ roots, denyPaths, caseInsensitive })
461
+
455
462
  /* 3) 任务仓库 ------------------------------------------------------- */
456
463
  let store
457
464
  try {
@@ -782,6 +789,11 @@ function startWorker(ctx, config) {
782
789
  // transport 自己已回 pong;这里保留一条兜底,防止上层换实现后 ping 悬空
783
790
  send(NODE_TO_HUB.PONG, { echo: frame.id, nodeId, time: Date.now() }, { replyTo: frame.id })
784
791
  return
792
+ case HUB_TO_NODE.FS_REQ:
793
+ // 远程文件工具集(提案 #25):异步执行,不挡后续帧;结果回 fs/res,
794
+ // reqId 就是请求信封 id(hub 按 replyTo 对 pending 表)。
795
+ handleFsReq(frame, d)
796
+ return
785
797
  case HUB_TO_NODE.ERROR:
786
798
  logger.warn('hub reported error', { code: d.code, message: d.message, replyTo: d.replyTo })
787
799
  return
@@ -790,6 +802,31 @@ function startWorker(ctx, config) {
790
802
  }
791
803
  }
792
804
 
805
+ /**
806
+ * 执行一条远程文件请求(提案 #25)。所有错误——包括被 FsError 表达的
807
+ * 业务拒绝(越界 / denyPaths / 超限)——都折进 fs/res 的 ok:false 载荷,
808
+ * 让 hub 工具层拿到结构化的 code 而不是一条 ERROR 帧。
809
+ */
810
+ function handleFsReq(frame, d) {
811
+ const op = typeof d.op === 'string' ? d.op : ''
812
+ const ops = { ls: 'ls', read: 'read', grep: 'grep', tree: 'tree' }
813
+ const fn = ops[op]
814
+ if (!fn) {
815
+ send(NODE_TO_HUB.FS_RES, { replyTo: frame.id, ok: false, code: ERROR_CODE.WORKSPACE_DENIED, message: `unknown fs op: ${op || '(empty)'}` })
816
+ return
817
+ }
818
+ const started = Date.now()
819
+ swallow(Promise.resolve().then(() => {
820
+ const result = fsService[fn](d)
821
+ logger.debug('fs op done', { op, ms: Date.now() - started, nodeId })
822
+ send(NODE_TO_HUB.FS_RES, { replyTo: frame.id, ok: true, result })
823
+ }).catch((err) => {
824
+ const code = typeof err?.code === 'string' ? err.code : ERROR_CODE.INTERNAL
825
+ logger.warn('fs op failed', { op, ms: Date.now() - started, code, error: msgOf(err) })
826
+ send(NODE_TO_HUB.FS_RES, { replyTo: frame.id, ok: false, code, message: msgOf(err) })
827
+ }))
828
+ }
829
+
793
830
  /* 9) 事件接线 ------------------------------------------------------- */
794
831
  const listenerDisposers = []
795
832
  const on = (evt, fn) => {
@@ -66,6 +66,8 @@ const E2E_REQUIRED_TYPES = new Set([
66
66
  HUB_TO_NODE.ARTIFACT_LIST,
67
67
  HUB_TO_NODE.ARTIFACT_PULL,
68
68
  HUB_TO_NODE.ARTIFACT_CANCEL,
69
+ // 远程文件请求(提案 #25):目录结构与文件内容是敏感信息,必须加密。
70
+ HUB_TO_NODE.FS_REQ,
69
71
  ])
70
72
 
71
73
  /** 副机侧永远明文的帧类型(握手/心跳/交换本身)。 */
package/lib/protocol.js CHANGED
@@ -25,6 +25,12 @@ export const HUB_TO_NODE = Object.freeze({
25
25
  * ed25519 身份签出(绑定双方公钥 + 连接 nonce)。中继只转发,无法伪造。
26
26
  */
27
27
  E2E_ACK: 'hub/e2e',
28
+ /**
29
+ * 远程文件请求(提案 #25):hub → node 的只读文件操作(ls/read/grep/tree)。
30
+ * 载荷 { op, ...params };reqId 用信封 id 本身,node 回 fs/res 时 replyTo 对上。
31
+ * 必须加密(E2E_REQUIRED_TYPES):目录结构 / 文件内容都是敏感信息。
32
+ */
33
+ FS_REQ: 'fs/req',
28
34
  TASK_START: 'task/start',
29
35
  TASK_CANCEL: 'task/cancel',
30
36
  TASK_QUERY: 'task/query',
@@ -60,6 +66,11 @@ export const NODE_TO_HUB = Object.freeze({
60
66
  * 旧 hub 按未知帧忽略 → 协商不成就继续明文(混跑安全)。
61
67
  */
62
68
  E2E_OFFER: 'node/e2e',
69
+ /**
70
+ * 远程文件响应(提案 #25):node → hub 的 fs/req 结果。载荷
71
+ * { replyTo, ok, result?, code?, message? }。加密发送(node→hub 默认封密)。
72
+ */
73
+ FS_RES: 'fs/res',
63
74
  PONG: 'pong',
64
75
  ERROR: 'error',
65
76
  })
@@ -137,6 +148,8 @@ export const ERROR_CODE = Object.freeze({
137
148
  ARTIFACT_MISSING: 'artifact-missing',
138
149
  ARTIFACT_TOO_LARGE: 'artifact-too-large',
139
150
  HASH_MISMATCH: 'hash-mismatch',
151
+ /** 远程文件请求超时(提案 #25):node 未在时限内回 fs/res。 */
152
+ FS_TIMEOUT: 'fs-timeout',
140
153
  /** hub 身份验证失败:指纹不匹配 / 签名无效 / nonce 对不上 / 时间戳超窗。 */
141
154
  HUB_IDENTITY_MISMATCH: 'hub-identity-mismatch',
142
155
  /** relay 模式下 hub 身份帧尚未到达(宽限窗口内),任务暂不执行。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-agent",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "把另一台电脑变成你 DSH 的远程 Agent 同事:副机本地持久跑任务、独立 Agent 循环,只回传 handoff 摘要与产物清单,成品按需拉取。",
6
6
  "license": "MIT",
@@ -417,9 +417,24 @@ for (const [i, [file, why]] of MAINT_FILES.entries()) {
417
417
  /五分钟上手/.test(rootReadme) && /--doctor/.test(rootReadme) && /dsh-remote-setup/.test(rootReadme),
418
418
  'README.md 五分钟上手一节')
419
419
  check('37', 'package.json 声明了 dsh-remote-setup 的 bin 入口且文件存在',
420
- pkg.bin?.['dsh-remote-setup'] === './bin/dsh-remote-setup.js'
421
- && fs.existsSync(path.join(ROOT, 'bin', 'dsh-remote-setup.js')),
422
- 'package.json bin + bin/dsh-remote-setup.js')
420
+ // ⚠ 路径写法:**规范形态是不带 `./` 前缀**。
421
+ //
422
+ // npm 对 `bin` 里的 `./bin/x.js` 会给出警告并在发布时**自动移除前缀**:
423
+ // npm warn publish "bin[dsh-remote-setup]" script name bin/dsh-remote-setup.js
424
+ // was invalid and removed
425
+ // 那条 warn 的措辞容易误读成"这条 bin 被删了",实际是"前缀被修正了" ——
426
+ // 但它说明**写 `./` 是非规范形态**,应当避免。
427
+ //
428
+ // 这条断言原本写死 `'./bin/dsh-remote-setup.js'`,等于在保护一个
429
+ // npm 会改写掉的写法。现在接受规范形态(无前缀),并容忍带前缀的历史
430
+ // 配置 —— `npm pkg fix` 会把带前缀的改成规范形态。
431
+ (() => {
432
+ const v = pkg.bin?.['dsh-remote-setup']
433
+ if (typeof v !== 'string') return false
434
+ return v.replace(/^\.\//, '') === 'bin/dsh-remote-setup.js'
435
+ && fs.existsSync(path.join(ROOT, 'bin', 'dsh-remote-setup.js'))
436
+ })(),
437
+ 'package.json bin(规范形态:不带 ./ 前缀)+ bin/dsh-remote-setup.js')
423
438
  check('38', '生成器替用户填好 inject: [](worker 片段必须自带)',
424
439
  /inject:\s*\[\]/.test(setupLib),
425
440
  'lib/setup.js buildWorkerYaml')
@@ -185,14 +185,14 @@ try {
185
185
  }
186
186
 
187
187
  if (applyHub) {
188
- // 4a. inject 齐全 → 不抛,且 8 个工具都在
188
+ // 4a. inject 齐全 → 不抛,且 12 个工具都在(8 任务 + 4 文件,提案 #25)
189
189
  try {
190
190
  const ctx = fakeCtx(declared)
191
191
  applyHub(ctx, { role: 'hub', nodeToken: 'nt_diag' })
192
192
  check('inject 齐全时 applyHub 不抛', true)
193
193
  check(
194
- 'inject 齐全时注册 8 个 remote_* 工具',
195
- ctx.registered.tools.length === 8,
194
+ 'inject 齐全时注册 12 个 remote_* 工具',
195
+ ctx.registered.tools.length === 12,
196
196
  `实际 ${ctx.registered.tools.length} 个:${ctx.registered.tools.join(', ') || '(无)'}`,
197
197
  )
198
198
  check('inject 齐全时注册了路由', ctx.registered.routes.length > 0, `${ctx.registered.routes.length} 条`)