dsh-remote-agent 0.9.7 → 0.9.8

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.
@@ -43,6 +43,17 @@ export const TREE_DEFAULT_DEPTH = 3
43
43
  export const TREE_MAX_ENTRIES = 2000
44
44
  /** ls 单页条目上限。 */
45
45
  export const LS_PAGE_MAX = 2000
46
+ /**
47
+ * 单次 write 的内容上限(2026-10-20)。
48
+ *
49
+ * 为什么要有:写操作是本插件第二个主动放宽边界的能力。没有上限的话,
50
+ * 一次 `remote_write` 就能把副机磁盘写满 —— 而副机是常驻进程,
51
+ * 磁盘满会让任务、日志、状态文件全部失败。
52
+ *
53
+ * 8MB 与 `READABLE_FILE_MAX` 同量级:能覆盖绝大多数配置文件/脚本/文档,
54
+ * 又不至于一次写爆磁盘。
55
+ */
56
+ export const WRITE_MAX_BYTES = 8 * 1024 * 1024
46
57
  /** 二进制嗅探窗口:前 64 字节出现 NUL 即视为二进制。 */
47
58
  const SNIFF_BYTES = 64
48
59
 
@@ -55,6 +66,99 @@ export class FsError extends Error {
55
66
  }
56
67
  }
57
68
 
69
+ /**
70
+ * 解析**待写入**的路径(2026-10-20,写操作专用)。
71
+ *
72
+ * 与 `resolveAny` 的关键区别:**目标可以不存在**。
73
+ *
74
+ * ── 为什么不能用 resolveAny ─────────────────────────────────────────────
75
+ *
76
+ * `resolveAny` 先 `realpathSync(requested)` —— 那要求路径已经存在。
77
+ * 只读工具这样做是对的("读一个不存在的文件没有借口"),但写操作的目标
78
+ * **本来就是要新建的**,用同一套解析会让 `remote_write` 100% 失败。
79
+ * (第一版就是这么写的,实测立刻暴露:`path not found: .../a.txt`。)
80
+ *
81
+ * ── 安全怎么做 ──────────────────────────────────────────────────────────
82
+ *
83
+ * 目标不存在 → 从它**向上找到第一个存在的祖先**,对那个祖先做 realpath,
84
+ * 再校验它在 roots 内。这样:
85
+ * · `D:\work\new.txt`(不存在)→ 校验 `D:\work` 在 roots 内 → 允许
86
+ * · `D:\work\link\to\outside.txt`(link 指向 roots 外)→ 祖先 realpath
87
+ * 解析到 roots 外 → 拒绝
88
+ * · `D:\etc\passwd` → 祖先 `D:\etc` 不在 roots 内 → 拒绝
89
+ *
90
+ * 目标**已存在** → 直接用 realpath(与只读工具同口径,防软链逃逸)。
91
+ *
92
+ * @param {string} requested 绝对路径
93
+ * @param {object} cfg { roots, caseInsensitive, denyPaths }
94
+ * @returns {string} 规范化后的绝对路径(可能是尚不存在的路径)
95
+ */
96
+ function resolveWritable(requested, cfg) {
97
+ const { roots, caseInsensitive } = cfg
98
+ if (typeof requested !== 'string' || requested === '') {
99
+ throw new FsError('path is required', ERROR_CODE.WORKSPACE_DENIED)
100
+ }
101
+ if (!path.isAbsolute(requested)) {
102
+ throw new FsError(`path must be absolute: ${requested}`, ERROR_CODE.WORKSPACE_DENIED)
103
+ }
104
+ // denyPaths 先查原始形态(防"通过不存在的路径绕过 denyPaths")
105
+ if (entryDenied(requested, cfg)) {
106
+ throw new FsError(`path denied: ${requested}`, ERROR_CODE.WORKSPACE_DENIED)
107
+ }
108
+
109
+ const real = (p) => (fs.realpathSync.native ? fs.realpathSync.native(p) : fs.realpathSync(p))
110
+
111
+ let checkPath
112
+ if (fs.existsSync(requested)) {
113
+ // 已存在:走 realpath(与只读同口径,能识破软链逃逸)
114
+ try {
115
+ checkPath = real(requested)
116
+ } catch {
117
+ throw new FsError(`cannot resolve path: ${requested}`, ERROR_CODE.WORKSPACE_DENIED)
118
+ }
119
+ } else {
120
+ // 不存在:向上找第一个存在的祖先,对它 realpath,再把剩余段接回去
121
+ let probe = path.dirname(requested)
122
+ const tail = [path.basename(requested)]
123
+ // 上限 64 层,防止异常深的路径拖慢
124
+ for (let i = 0; i < 64; i += 1) {
125
+ if (fs.existsSync(probe)) break
126
+ const parent = path.dirname(probe)
127
+ if (parent === probe) {
128
+ throw new FsError(`no existing ancestor for: ${requested}`, ERROR_CODE.WORKSPACE_DENIED)
129
+ }
130
+ tail.unshift(path.basename(probe))
131
+ probe = parent
132
+ }
133
+ let probeReal
134
+ try {
135
+ probeReal = real(probe)
136
+ } catch {
137
+ throw new FsError(`cannot resolve ancestor: ${probe}`, ERROR_CODE.WORKSPACE_DENIED)
138
+ }
139
+ checkPath = path.join(probeReal, ...tail)
140
+ }
141
+
142
+ const allowed = (roots ?? []).map((r) => {
143
+ let base = r
144
+ try {
145
+ base = real(r)
146
+ } catch {
147
+ // 与 resolveWorkspace 同口径:不可达的根保持原样,后面照样拒一切子路径
148
+ }
149
+ return normalizePath(base, { caseInsensitive })
150
+ })
151
+ const norm = normalizePath(checkPath, { caseInsensitive })
152
+ const ok = allowed.some((r) => norm === r || norm.startsWith(`${r}/`))
153
+ if (!ok) {
154
+ throw new FsError(
155
+ `path outside allowed roots: ${checkPath}`,
156
+ ERROR_CODE.WORKSPACE_DENIED,
157
+ )
158
+ }
159
+ return checkPath
160
+ }
161
+
58
162
  /**
59
163
  * 解析"可以是文件也可以是目录"的路径:realpath + roots 前缀 + denyPaths。
60
164
  * 与 resolveWorkspace 的差异只在两点:不要求是目录;不存在时直接拒
@@ -183,7 +287,7 @@ export function createFsService(cfg) {
183
287
  * 单次文本量 ≤ READ_TEXT_MAX;二进制模式:只回 sha256/size/头部预览。
184
288
  */
185
289
  read({ path: p, offset = 0, limit = 500 } = {}) {
186
- const file = resolveAny(p, conf)
290
+ const file = resolveWritable(p, conf)
187
291
  let st
188
292
  try {
189
293
  st = fs.statSync(file)
@@ -518,5 +622,176 @@ export function createFsService(cfg) {
518
622
  items,
519
623
  }
520
624
  },
625
+
626
+ /* ═══════════════════════════════════════════════════════════════════════
627
+ * 写操作(2026-10-20,用户要求"更多工具 = 文件写操作")
628
+ * ═══════════════════════════════════════════════════════════════════════
629
+ *
630
+ * ⚠⚠ **这是本插件第二个主动放宽边界的能力**(第一个是 SMB 共享)。
631
+ * 前四个 fs 工具全是只读;从这里开始会**改副机的文件**。
632
+ *
633
+ * ── 护栏(每一道都对应一种真实事故)────────────────────────────────
634
+ *
635
+ * 1. **路径必须在 roots 内** —— 复用 resolveAny(realpath 展开 8.3 短名
636
+ * 与符号链接,再比 roots 前缀)。写操作**额外**要求目标父目录也
637
+ * 在 roots 内(防"roots 内的路径经过软链写到外面")。
638
+ * 2. **默认不覆盖** —— write 遇到已存在的文件直接拒绝,要覆盖必须显式
639
+ * 传 `overwrite: true`。这条防"AI 手滑清空已有文件"。
640
+ * 3. **单次写入有大小上限** —— 防一次 write 把副机磁盘写满。
641
+ * 4. **delete 只删文件/空目录** —— 不做递归删除。递归删除太容易造成
642
+ * 不可逆损失,真有需要让用户用 exec 显式 rm。
643
+ * 5. **denyPaths 生效** —— 与只读工具同一条规则。
644
+ */
645
+
646
+ /** 写文件(默认不覆盖)。 */
647
+ write({ path: p, content, encoding = 'utf8', overwrite = false } = {}) {
648
+ if (typeof content !== 'string') {
649
+ throw new FsError('content must be a string', ERROR_CODE.WORKSPACE_DENIED)
650
+ }
651
+ const bytes = Buffer.byteLength(content, 'utf8')
652
+ if (bytes > WRITE_MAX_BYTES) {
653
+ throw new FsError(
654
+ `content too large: ${bytes} bytes (max ${WRITE_MAX_BYTES})`,
655
+ ERROR_CODE.ARTIFACT_TOO_LARGE,
656
+ )
657
+ }
658
+ const file = resolveWritable(p, conf)
659
+ // 目标**父目录**也必须在 roots 内:防"roots 内的路径经软链写到外面"
660
+ const dir = path.dirname(file)
661
+ let dirReal
662
+ try {
663
+ dirReal = fs.realpathSync.native ? fs.realpathSync.native(dir) : fs.realpathSync(dir)
664
+ } catch {
665
+ throw new FsError(`parent directory not found: ${dir}`, ERROR_CODE.ARTIFACT_MISSING)
666
+ }
667
+ if (entryDenied(dirReal, conf)) {
668
+ throw new FsError(`parent directory denied: ${dirReal}`, ERROR_CODE.WORKSPACE_DENIED)
669
+ }
670
+ const exists = fs.existsSync(file)
671
+ if (exists && overwrite !== true) {
672
+ throw new FsError(
673
+ `file already exists: ${file}(如需覆盖请显式传 overwrite: true)`,
674
+ ERROR_CODE.ARTIFACT_DENIED,
675
+ )
676
+ }
677
+ if (exists) {
678
+ let st
679
+ try {
680
+ st = fs.statSync(file)
681
+ } catch {
682
+ st = null
683
+ }
684
+ if (st && st.isDirectory()) {
685
+ throw new FsError(`target is a directory: ${file}`, ERROR_CODE.WORKSPACE_DENIED)
686
+ }
687
+ }
688
+ // 原子写:临时文件 + rename(与 templates.js 同款,避免半截文件)
689
+ const tmp = `${file}.dshra-tmp-${process.pid}-${Date.now().toString(36)}`
690
+ try {
691
+ fs.writeFileSync(tmp, content, { encoding, mode: 0o600 })
692
+ fs.renameSync(tmp, file)
693
+ } catch (err) {
694
+ try {
695
+ fs.rmSync(tmp, { force: true })
696
+ } catch {
697
+ /* 清理失败不掩盖原错误 */
698
+ }
699
+ throw new FsError(`write failed: ${String(err?.message ?? err)}`, ERROR_CODE.INTERNAL)
700
+ }
701
+ return { path: file, bytes, overwritten: exists }
702
+ },
703
+
704
+ /** 建目录(递归)。已存在视为成功(幂等)。 */
705
+ mkdir({ path: p } = {}) {
706
+ const dir = resolveWritable(p, conf)
707
+ const existed = fs.existsSync(dir)
708
+ try {
709
+ fs.mkdirSync(dir, { recursive: true })
710
+ } catch (err) {
711
+ throw new FsError(`mkdir failed: ${String(err?.message ?? err)}`, ERROR_CODE.INTERNAL)
712
+ }
713
+ return { path: dir, created: !existed }
714
+ },
715
+
716
+ /** 移动/重命名(同盘 rename;跨盘明确报错,不做静默复制)。 */
717
+ move({ from, to, overwrite = false } = {}) {
718
+ const src = resolveWritable(from, conf)
719
+ const dst = resolveWritable(to, conf)
720
+ const dstDir = path.dirname(dst)
721
+ let dstDirReal
722
+ try {
723
+ dstDirReal = fs.realpathSync.native ? fs.realpathSync.native(dstDir) : fs.realpathSync(dstDir)
724
+ } catch {
725
+ throw new FsError(`target directory not found: ${dstDir}`, ERROR_CODE.ARTIFACT_MISSING)
726
+ }
727
+ if (entryDenied(dstDirReal, conf)) {
728
+ throw new FsError(`target directory denied: ${dstDirReal}`, ERROR_CODE.WORKSPACE_DENIED)
729
+ }
730
+ if (!fs.existsSync(src)) {
731
+ throw new FsError(`source not found: ${src}`, ERROR_CODE.ARTIFACT_MISSING)
732
+ }
733
+ if (fs.existsSync(dst) && overwrite !== true) {
734
+ throw new FsError(
735
+ `target already exists: ${dst}(如需覆盖请显式传 overwrite: true)`,
736
+ ERROR_CODE.ARTIFACT_DENIED,
737
+ )
738
+ }
739
+ try {
740
+ fs.renameSync(src, dst)
741
+ } catch (err) {
742
+ // EXDEV = 跨设备。不做静默复制 —— 大文件会卡住且语义不同
743
+ if (err?.code === 'EXDEV') {
744
+ throw new FsError(
745
+ `cross-device move not supported: ${src} -> ${dst}(请改用 exec 的 cp/mv)`,
746
+ ERROR_CODE.INTERNAL,
747
+ )
748
+ }
749
+ throw new FsError(`move failed: ${String(err?.message ?? err)}`, ERROR_CODE.INTERNAL)
750
+ }
751
+ return { from: src, to: dst }
752
+ },
753
+
754
+ /**
755
+ * 删除**单个文件或空目录**。
756
+ *
757
+ * ⚠ 刻意**不做递归删除** —— 那太容易造成不可逆损失。
758
+ * 真有需要让用户用 exec 自己跑 rm -rf(那是显式动作,有命令可查)。
759
+ */
760
+ delete({ path: p } = {}) {
761
+ const target = resolveWritable(p, conf)
762
+ let st
763
+ try {
764
+ st = fs.lstatSync(target)
765
+ } catch {
766
+ throw new FsError(`path not found: ${target}`, ERROR_CODE.ARTIFACT_MISSING)
767
+ }
768
+ // 符号链接只删链接本身(lstat 不跟随)—— 防"删链接把目标删了"
769
+ if (st.isDirectory()) {
770
+ let entries
771
+ try {
772
+ entries = fs.readdirSync(target)
773
+ } catch (err) {
774
+ throw new FsError(`readdir failed: ${String(err?.message ?? err)}`, ERROR_CODE.INTERNAL)
775
+ }
776
+ if (entries.length > 0) {
777
+ throw new FsError(
778
+ `directory not empty: ${target}(只删空目录;非空目录请用 exec 显式删除)`,
779
+ ERROR_CODE.ARTIFACT_DENIED,
780
+ )
781
+ }
782
+ try {
783
+ fs.rmdirSync(target)
784
+ } catch (err) {
785
+ throw new FsError(`rmdir failed: ${String(err?.message ?? err)}`, ERROR_CODE.INTERNAL)
786
+ }
787
+ return { path: target, kind: 'dir' }
788
+ }
789
+ try {
790
+ fs.unlinkSync(target)
791
+ } catch (err) {
792
+ throw new FsError(`unlink failed: ${String(err?.message ?? err)}`, ERROR_CODE.INTERNAL)
793
+ }
794
+ return { path: target, kind: st.isSymbolicLink() ? 'link' : 'file' }
795
+ },
521
796
  }
522
797
  }
package/lib/node/index.js CHANGED
@@ -57,6 +57,8 @@ import { TaskStore } from './task-store.js'
57
57
  // 远程文件工具集(提案 #25):ls/read/grep/tree 只读实现,与任务链路共用
58
58
  // roots / denyPaths / caseInsensitive 三个配置(同一个安全边界,两处入口)。
59
59
  import { createFsService } from './fs-service.js'
60
+ // 进程与端口诊断(2026-10-20):ps/port 只读,kill 不可逆(自带三道护栏)。
61
+ import { createProcessService } from './process-service.js'
60
62
  // 工作区 SMB 共享(2026-10-17):只探测 + 生成命令,不执行(需管理员)。
61
63
  import {
62
64
  SHARE_ACCESS,
@@ -578,6 +580,18 @@ function startWorker(ctx, config) {
578
580
  // 安全边界,两处入口。只读实现,见 lib/node/fs-service.js 头注释。
579
581
  const fsService = createFsService({ roots, denyPaths, caseInsensitive })
580
582
 
583
+ /**
584
+ * 进程与端口诊断(2026-10-20)。
585
+ *
586
+ * 与 fsService 分开:进程操作**没有路径概念**,所以 roots/denyPaths
587
+ * 那套护栏不适用。它自己的护栏全在 `lib/node/process-service.js` 里
588
+ * (kill 的 confirm + 拒杀自身/父进程/系统关键进程)。
589
+ *
590
+ * 为什么不带 cfg:这里不需要任何用户配置 —— 少一个配置面就少一类
591
+ * 配置错误。若将来要加白名单,再引入 cfg。
592
+ */
593
+ const processService = createProcessService()
594
+
581
595
  /* 3) 任务仓库 ------------------------------------------------------- */
582
596
  let store
583
597
  try {
@@ -1003,7 +1017,17 @@ function startWorker(ctx, config) {
1003
1017
  */
1004
1018
  function handleFsReq(frame, d) {
1005
1019
  const op = typeof d.op === 'string' ? d.op : ''
1006
- const ops = { ls: 'ls', read: 'read', grep: 'grep', tree: 'tree' }
1020
+ // 只读四项 + 写操作四项(2026-10-20)。
1021
+ // ⚠ 写操作由 hub 工具层要求 `confirm: 'yes'`,副机侧只做路径与内容护栏。
1022
+ const ops = {
1023
+ ls: 'ls', read: 'read', grep: 'grep', tree: 'tree',
1024
+ write: 'write', mkdir: 'mkdir', move: 'move', delete: 'delete',
1025
+ // 进程与端口诊断(2026-10-20):只读的 list/port 与不可逆的 kill。
1026
+ // ⚠ 它们**不受 roots 约束**(进程没有"路径"概念),
1027
+ // 所以护栏全部落在 `lib/node/process-service.js` 内部
1028
+ // (kill 的 confirm + 拒杀自身/父进程/系统进程)。
1029
+ ps: 'ps', port: 'port', kill: 'kill',
1030
+ }
1007
1031
  const fn = ops[op]
1008
1032
  if (!fn) {
1009
1033
  send(NODE_TO_HUB.FS_RES, { replyTo: frame.id, ok: false, code: ERROR_CODE.WORKSPACE_DENIED, message: `unknown fs op: ${op || '(empty)'}` })
@@ -1011,7 +1035,10 @@ function startWorker(ctx, config) {
1011
1035
  }
1012
1036
  const started = Date.now()
1013
1037
  swallow(Promise.resolve().then(() => {
1014
- const result = fsService[fn](d)
1038
+ // 进程诊断走独立的 processService;文件操作走 fsService。
1039
+ // 分开是因为两者的安全模型完全不同(路径护栏 vs 进程护栏)。
1040
+ const svc = (op === 'ps' || op === 'port' || op === 'kill') ? processService : fsService
1041
+ const result = svc[fn](d)
1015
1042
  logger.debug('fs op done', { op, ms: Date.now() - started, nodeId })
1016
1043
  send(NODE_TO_HUB.FS_RES, { replyTo: frame.id, ok: true, result })
1017
1044
  }).catch((err) => {