dsh-remote-agent 0.8.0 → 0.9.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 +370 -0
- package/README.en.md +95 -4
- package/README.md +147 -40
- package/cordis.patch.yml +48 -1
- package/docs/workspace-share.md +141 -0
- package/lib/client.js +392 -13
- package/lib/env-check.js +244 -0
- package/lib/hub/index.js +89 -1
- package/lib/hub/node-registry.js +96 -2
- package/lib/hub/registry-ingest.js +15 -0
- package/lib/hub/tools.js +26 -1
- package/lib/hub/ui-routes.js +157 -1
- package/lib/index.js +56 -7
- package/lib/node/fs-service.js +20 -2
- package/lib/node/index.js +86 -1
- package/lib/node/runner.js +23 -1
- package/lib/node/share-service.js +213 -0
- package/lib/node/transport.js +54 -1
- package/lib/protocol.js +18 -0
- package/lib/relay.js +18 -1
- package/lib/setup-routes.js +51 -5
- package/package.json +1 -1
- package/scripts/check-doc-drift.mjs +169 -3
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工作区 SMB 共享(2026-10-17)。
|
|
3
|
+
*
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* 这个模块做什么
|
|
6
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
*
|
|
8
|
+
* 在**副机**上把一个工作区目录通过 SMB 共享出去,让主机可以:
|
|
9
|
+
*
|
|
10
|
+
* net use X: \\<副机IP>\dsh-work
|
|
11
|
+
*
|
|
12
|
+
* 像本地盘一样浏览/编辑 —— 这是"用本地 IDE 打开副机项目"的唯一现实路径。
|
|
13
|
+
*
|
|
14
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
* ⚠ 安全模型变化(**必须让用户知情**,这是本模块存在的最大风险)
|
|
16
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
17
|
+
*
|
|
18
|
+
* 开启共享后,**插件的安全边界被完全绕过**:
|
|
19
|
+
*
|
|
20
|
+
* · `roots` 不再生效 —— Windows 内核直接读写,插件无法拦截
|
|
21
|
+
* · `denyPaths` 不再生效 —— 同上
|
|
22
|
+
* · 「清单先于字节」失效 —— 中间产物、日志、`.env`、`.git` 里的密钥
|
|
23
|
+
* 全部对主机可见(不再只有 `role:final` 能过河)
|
|
24
|
+
* · **新增凭证面** —— 主机需要副机的 Windows 账号(或副机建一个共享账号),
|
|
25
|
+
* 这比模型 key 更敏感(能读写整个共享目录)
|
|
26
|
+
*
|
|
27
|
+
* 所以本模块的每个入口都要求**显式二次确认**(`confirm: 'yes'`),
|
|
28
|
+
* 且面板上必须显示完整风险说明。这是产品决策:**让用户知情地选择,
|
|
29
|
+
* 而不是偷偷放宽边界**。
|
|
30
|
+
*
|
|
31
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
32
|
+
* 权限模型:只读 / 读写
|
|
33
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
34
|
+
*
|
|
35
|
+
* `read` → `New-SmbShare -ReadAccess` 只读:主机能看、不能改
|
|
36
|
+
* `write` → `New-SmbShare -ChangeAccess` 读写:能看能改(不给 FullAccess ——
|
|
37
|
+
* 那会连共享的 ACL 一起给出,主机能改权限)
|
|
38
|
+
*
|
|
39
|
+
* **默认只读**:绝大多数"用 IDE 打开看代码"的需求,只读就够;写权限要额外
|
|
40
|
+
* 一次确认。这是最小权限原则的落点。
|
|
41
|
+
*
|
|
42
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
43
|
+
* 需要管理员权限(**这是硬约束**)
|
|
44
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
45
|
+
*
|
|
46
|
+
* `New-SmbShare` / `Remove-SmbShare` 都需要管理员。而 DSH 通常以普通用户
|
|
47
|
+
* 运行。所以本模块**不自己提权**(在 Node 里弹 UAC 不可靠且会被杀软拦),
|
|
48
|
+
* 而是:
|
|
49
|
+
*
|
|
50
|
+
* · 探测当前是否管理员 → 不是就返回一条**可复制的提权命令**
|
|
51
|
+
* · 用户自己开管理员 PowerShell 跑那一条 → 再回来点「已开启」确认
|
|
52
|
+
*
|
|
53
|
+
* 这比"假装能一键"诚实:一次 UAC 提权失败的表现(静默失败、被拦、
|
|
54
|
+
* 权限半生效)比让用户手动跑一条命令难排查得多。
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { spawnSync } from 'node:child_process'
|
|
58
|
+
|
|
59
|
+
/** 共享名前缀 —— 固定前缀便于识别与清理(不会误删用户的其它共享)。 */
|
|
60
|
+
export const SHARE_NAME_PREFIX = 'dsh-work'
|
|
61
|
+
|
|
62
|
+
/** 共享名上限(Windows 限制 80 字符,留余量)。 */
|
|
63
|
+
const SHARE_NAME_MAX = 64
|
|
64
|
+
|
|
65
|
+
/** 访问级别。 */
|
|
66
|
+
export const SHARE_ACCESS = Object.freeze({
|
|
67
|
+
READ: 'read',
|
|
68
|
+
WRITE: 'write',
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 由工作区路径生成一个**稳定**的共享名。
|
|
73
|
+
*
|
|
74
|
+
* 为什么要稳定:用户可能对同一个目录反复开关共享,共享名必须一致,
|
|
75
|
+
* 否则每次都会留下一个孤儿共享。用路径的短哈希做后缀。
|
|
76
|
+
*
|
|
77
|
+
* @param {string} dir 工作区绝对路径
|
|
78
|
+
* @returns {string} 例如 `dsh-work-a1b2c3`
|
|
79
|
+
*/
|
|
80
|
+
export function shareNameFor(dir) {
|
|
81
|
+
const base = SHARE_NAME_PREFIX
|
|
82
|
+
// 简单稳定哈希(FNV-1a 32bit)—— 不需要密码学强度,只要同一路径同名
|
|
83
|
+
let h = 0x811c9dc5
|
|
84
|
+
const s = String(dir)
|
|
85
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
86
|
+
h ^= s.charCodeAt(i)
|
|
87
|
+
h = Math.imul(h, 0x01000193) >>> 0
|
|
88
|
+
}
|
|
89
|
+
return `${base}-${h.toString(16).padStart(8, '0')}`.slice(0, SHARE_NAME_MAX)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 判断当前进程是否管理员(Windows)。
|
|
94
|
+
*
|
|
95
|
+
* 非 Windows 恒 false —— 本功能是 Windows 专属(SMB 共享)。
|
|
96
|
+
*/
|
|
97
|
+
export function isAdmin() {
|
|
98
|
+
if (process.platform !== 'win32') return false
|
|
99
|
+
try {
|
|
100
|
+
// `net session` 需要管理员,成功即管理员。比读 token 简单可靠。
|
|
101
|
+
const r = spawnSync('net', ['session'], { stdio: 'ignore', windowsHide: true, timeout: 5000 })
|
|
102
|
+
return r.status === 0
|
|
103
|
+
} catch {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 列出本机所有 SMB 共享。
|
|
110
|
+
*
|
|
111
|
+
* `net share` 不需要管理员(只读查询),所以即使非管理员也能用于**展示**。
|
|
112
|
+
*
|
|
113
|
+
* @returns {Array<{name:string, path:string}>}
|
|
114
|
+
*/
|
|
115
|
+
export function listShares() {
|
|
116
|
+
if (process.platform !== 'win32') return []
|
|
117
|
+
try {
|
|
118
|
+
const r = spawnSync('net', ['share'], { encoding: 'utf8', windowsHide: true, timeout: 8000 })
|
|
119
|
+
if (r.status !== 0 || typeof r.stdout !== 'string') return []
|
|
120
|
+
const out = []
|
|
121
|
+
for (const line of r.stdout.split(/\r?\n/)) {
|
|
122
|
+
// 形如:`dsh-work-a1b2c3 D:\work `
|
|
123
|
+
const m = /^(\S+)\s+([A-Za-z]:\\[^\s]*)/.exec(line.trim())
|
|
124
|
+
if (m) out.push({ name: m[1], path: m[2] })
|
|
125
|
+
}
|
|
126
|
+
return out
|
|
127
|
+
} catch {
|
|
128
|
+
return []
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 查某个目录是否已被共享(返回共享名)。
|
|
134
|
+
* @param {string} dir
|
|
135
|
+
* @returns {string|null}
|
|
136
|
+
*/
|
|
137
|
+
export function findShareForDir(dir) {
|
|
138
|
+
const want = String(dir).replace(/[\\/]+$/, '').toLowerCase()
|
|
139
|
+
for (const s of listShares()) {
|
|
140
|
+
if (s.path.replace(/[\\/]+$/, '').toLowerCase() === want) return s.name
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 生成**可复制的提权命令**(非管理员时给用户)。
|
|
147
|
+
*
|
|
148
|
+
* 为什么返回命令而不是自己提权:
|
|
149
|
+
* · Node 里弹 UAC 需要额外依赖或 PowerShell 间接调用,且被安全软件拦时
|
|
150
|
+
* 表现为静默失败 —— 比手动跑一条命令难排查得多
|
|
151
|
+
* · 用户自己开管理员 PowerShell 跑一条命令,失败时**能看到真实错误**
|
|
152
|
+
*
|
|
153
|
+
* @param {object} o
|
|
154
|
+
* @param {string} o.dir 要共享的目录
|
|
155
|
+
* @param {'read'|'write'} o.access
|
|
156
|
+
* @returns {{shareName:string, command:string}}
|
|
157
|
+
*/
|
|
158
|
+
export function buildShareCommand({ dir, access = SHARE_ACCESS.READ }) {
|
|
159
|
+
const shareName = shareNameFor(dir)
|
|
160
|
+
const accessFlag = access === SHARE_ACCESS.WRITE ? 'ChangeAccess' : 'ReadAccess'
|
|
161
|
+
// 用户名:用当前用户(`$env:USERNAME`)。共享给"本机这个账号",
|
|
162
|
+
// 主机侧用同一套凭证连(或副机另建账号 —— 那是用户的事,文档里说明)。
|
|
163
|
+
const cmd = [
|
|
164
|
+
'New-SmbShare',
|
|
165
|
+
`-Name '${shareName}'`,
|
|
166
|
+
`-Path '${String(dir).replace(/'/g, "''")}'`,
|
|
167
|
+
`-${accessFlag} "$env:USERNAME"`,
|
|
168
|
+
].join(' ')
|
|
169
|
+
return { shareName, command: cmd }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 生成卸载命令。
|
|
174
|
+
* @param {string} shareName
|
|
175
|
+
*/
|
|
176
|
+
export function buildUnshareCommand(shareName) {
|
|
177
|
+
return `Remove-SmbShare -Name '${String(shareName).replace(/'/g, "''")}' -Force`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 生成主机侧的映射命令(面板展示给用户复制)。
|
|
182
|
+
*
|
|
183
|
+
* @param {object} o
|
|
184
|
+
* @param {string} o.host 副机地址(IP 或主机名)
|
|
185
|
+
* @param {string} o.shareName
|
|
186
|
+
* @param {string} [o.drive] 盘符,默认 X:
|
|
187
|
+
* @returns {string}
|
|
188
|
+
*/
|
|
189
|
+
export function buildMountCommand({ host, shareName, drive = 'X:' }) {
|
|
190
|
+
const d = String(drive).replace(/:?$/, ':')
|
|
191
|
+
return `net use ${d} \\\\${host}\\${shareName} /persistent:yes`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 探测状态:当前是否管理员 + 哪些 dsh-work 共享已存在。
|
|
196
|
+
*
|
|
197
|
+
* @param {object} [o]
|
|
198
|
+
* @param {string[]} [o.dirs] 要查的工作区目录
|
|
199
|
+
* @returns {{platform:string, admin:boolean, supported:boolean, shares:Array, perDir:object}}
|
|
200
|
+
*/
|
|
201
|
+
export function probeShareState({ dirs = [] } = {}) {
|
|
202
|
+
const supported = process.platform === 'win32'
|
|
203
|
+
if (!supported) {
|
|
204
|
+
return { platform: process.platform, admin: false, supported: false, shares: [], perDir: {} }
|
|
205
|
+
}
|
|
206
|
+
const shares = listShares().filter((s) => s.name.startsWith(SHARE_NAME_PREFIX))
|
|
207
|
+
const perDir = {}
|
|
208
|
+
for (const d of dirs) {
|
|
209
|
+
if (typeof d !== 'string' || d === '') continue
|
|
210
|
+
perDir[d] = findShareForDir(d)
|
|
211
|
+
}
|
|
212
|
+
return { platform: process.platform, admin: isAdmin(), supported, shares, perDir }
|
|
213
|
+
}
|
package/lib/node/transport.js
CHANGED
|
@@ -68,6 +68,9 @@ const E2E_REQUIRED_TYPES = new Set([
|
|
|
68
68
|
HUB_TO_NODE.ARTIFACT_CANCEL,
|
|
69
69
|
// 远程文件请求(提案 #25):目录结构与文件内容是敏感信息,必须加密。
|
|
70
70
|
HUB_TO_NODE.FS_REQ,
|
|
71
|
+
// 工作区共享请求(2026-10-17):帧里含工作区**绝对路径**(暴露副机的
|
|
72
|
+
// 目录结构),必须加密。
|
|
73
|
+
HUB_TO_NODE.SHARE_REQ,
|
|
71
74
|
])
|
|
72
75
|
|
|
73
76
|
/** 副机侧永远明文的帧类型(握手/心跳/交换本身)。 */
|
|
@@ -111,6 +114,9 @@ export class NodeTransport extends EventEmitter {
|
|
|
111
114
|
this.hello = o.hello
|
|
112
115
|
this.heartbeatMs = o.heartbeatMs ?? HEARTBEAT_MS
|
|
113
116
|
this.conn = undefined
|
|
117
|
+
// 并发闸门(2026-10-17 修 L1):防 start() 被调两次时开两条 TCP 连接。
|
|
118
|
+
// 见 connectOnce 的说明。
|
|
119
|
+
this.connecting = false
|
|
114
120
|
this.online = false
|
|
115
121
|
this.attempt = 0
|
|
116
122
|
this.stopped = false
|
|
@@ -170,6 +176,25 @@ export class NodeTransport extends EventEmitter {
|
|
|
170
176
|
|
|
171
177
|
async connectOnce() {
|
|
172
178
|
if (this.stopped) return
|
|
179
|
+
|
|
180
|
+
// ⚠ 并发闸门(2026-10-17 修 L1):`start()` 可能被调两次(cordis effect
|
|
181
|
+
// 热重载会重跑 applyWorker)。没有这道闸门就会开两条 TCP 连接,随后陈旧
|
|
182
|
+
// 连接的 close 把 `this.conn` 清成 undefined —— 活着的新连接变成引用丢失
|
|
183
|
+
// 的孤儿,发送全失败,重连又被中继 duplicate-node 拒,副机永久失联。
|
|
184
|
+
//
|
|
185
|
+
// hub 侧的 `RelayLink` 早有这道闸门(那是用真中继动态测试查出来的),
|
|
186
|
+
// 副机侧当时没对称复制过来。
|
|
187
|
+
if (this.connecting) return
|
|
188
|
+
this.connecting = true
|
|
189
|
+
try {
|
|
190
|
+
await this.connectOnceInner()
|
|
191
|
+
} finally {
|
|
192
|
+
this.connecting = false
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async connectOnceInner() {
|
|
197
|
+
if (this.stopped) return
|
|
173
198
|
const url = this.resolveUrl()
|
|
174
199
|
if (url === '') {
|
|
175
200
|
this.emit('error', new Error(`transport: invalid url "${this.rawUrl}"`))
|
|
@@ -279,6 +304,17 @@ export class NodeTransport extends EventEmitter {
|
|
|
279
304
|
})
|
|
280
305
|
|
|
281
306
|
conn.on('close', (code, reason) => {
|
|
307
|
+
// ⚠ 陈旧连接校验(2026-10-17 修 L1,与并发闸门配套)。
|
|
308
|
+
//
|
|
309
|
+
// 只有"当前连接"的 close 才有权清理状态。否则陈旧连接的 close 会把
|
|
310
|
+
// 活着的新连接引用清掉(`this.conn = undefined`)→ 新连接变孤儿 →
|
|
311
|
+
// 发送全失败 + 重连被中继 duplicate-node 拒 → 副机永久失联。
|
|
312
|
+
//
|
|
313
|
+
// 这是 hub 侧 `RelayLink` 早已有的第二条不变式,副机侧当时没复制过来。
|
|
314
|
+
if (this.conn !== conn) {
|
|
315
|
+
// 不是当前连接:什么都不做(它的 close 与当前状态无关)
|
|
316
|
+
return
|
|
317
|
+
}
|
|
282
318
|
const wasOnline = this.online
|
|
283
319
|
this.online = false
|
|
284
320
|
this.conn = undefined
|
|
@@ -384,7 +420,24 @@ export class NodeTransport extends EventEmitter {
|
|
|
384
420
|
this.e2e = null
|
|
385
421
|
}
|
|
386
422
|
this.emit('e2e', 'reset')
|
|
387
|
-
|
|
423
|
+
// ⚠ 时间窗去抖(2026-10-17 修):常量 `E2E_REOFFER_DEBOUNCE_MS` 原先是
|
|
424
|
+
// **死代码** —— 声明了 250ms 窗口但从未被引用,实际只在 `e2eOffer` 在途
|
|
425
|
+
// 时跳过。而 `e2eOffer` 在提议发出后很快就被清空(见 beginE2EOffer 的
|
|
426
|
+
// then 分支),所以 reset 风暴下**每次 reset 都会重发**一次提议。
|
|
427
|
+
//
|
|
428
|
+
// 现在按常量做时间窗:距上次提议不足 250ms 就不重发(等它自然完成)。
|
|
429
|
+
//
|
|
430
|
+
// ⚠ 常量是**实例字段**(`E2E_REOFFER_DEBOUNCE_MS = 250`,不是 static),
|
|
431
|
+
// 所以必须经 `this.` 读 —— 裸写会 ReferenceError(模块作用域里没有它)。
|
|
432
|
+
const debounceMs = typeof this.E2E_REOFFER_DEBOUNCE_MS === 'number'
|
|
433
|
+
? this.E2E_REOFFER_DEBOUNCE_MS
|
|
434
|
+
: 250
|
|
435
|
+
const now = Date.now()
|
|
436
|
+
const last = typeof this.lastE2EOfferAt === 'number' ? this.lastE2EOfferAt : 0
|
|
437
|
+
if (!this.e2eOffer && now - last >= debounceMs) {
|
|
438
|
+
this.lastE2EOfferAt = now
|
|
439
|
+
this.beginE2EOffer()
|
|
440
|
+
}
|
|
388
441
|
return
|
|
389
442
|
}
|
|
390
443
|
this.emit('error', new Error(`relay error: ${code} ${frame.d?.message ?? ''}`.trim()))
|
package/lib/protocol.js
CHANGED
|
@@ -31,6 +31,19 @@ export const HUB_TO_NODE = Object.freeze({
|
|
|
31
31
|
* 必须加密(E2E_REQUIRED_TYPES):目录结构 / 文件内容都是敏感信息。
|
|
32
32
|
*/
|
|
33
33
|
FS_REQ: 'fs/req',
|
|
34
|
+
/**
|
|
35
|
+
* 工作区共享请求(2026-10-17):hub → node 的 SMB 共享操作。
|
|
36
|
+
*
|
|
37
|
+
* 载荷 `{ op: 'probe'|'plan'|'unshare-plan', ...params }`。
|
|
38
|
+
*
|
|
39
|
+
* ⚠ 本帧**只做探测与生成命令**,不执行 `New-SmbShare` —— 那需要管理员,
|
|
40
|
+
* 而 DSH 通常以普通用户运行。执行由用户在提权 PowerShell 里手动做
|
|
41
|
+
* (面板给出可复制的命令)。这个设计是刻意的:Node 里提权失败的表现
|
|
42
|
+
* (静默失败 / 被安全软件拦 / 权限半生效)比让用户手动跑一条命令难排查。
|
|
43
|
+
*
|
|
44
|
+
* 必须加密(E2E_REQUIRED_TYPES):帧里含工作区绝对路径。
|
|
45
|
+
*/
|
|
46
|
+
SHARE_REQ: 'share/req',
|
|
34
47
|
TASK_START: 'task/start',
|
|
35
48
|
TASK_CANCEL: 'task/cancel',
|
|
36
49
|
TASK_QUERY: 'task/query',
|
|
@@ -71,6 +84,11 @@ export const NODE_TO_HUB = Object.freeze({
|
|
|
71
84
|
* { replyTo, ok, result?, code?, message? }。加密发送(node→hub 默认封密)。
|
|
72
85
|
*/
|
|
73
86
|
FS_RES: 'fs/res',
|
|
87
|
+
/**
|
|
88
|
+
* 工作区共享响应(2026-10-17):node → hub 的 share/req 结果。
|
|
89
|
+
* 载荷 `{ replyTo, ok, result?, code?, message? }`。加密发送。
|
|
90
|
+
*/
|
|
91
|
+
SHARE_RES: 'share/res',
|
|
74
92
|
PONG: 'pong',
|
|
75
93
|
ERROR: 'error',
|
|
76
94
|
})
|
package/lib/relay.js
CHANGED
|
@@ -633,9 +633,26 @@ export function createRelay(o = {}) {
|
|
|
633
633
|
stats.upFrames += 1
|
|
634
634
|
stats.upBytes += payload.length
|
|
635
635
|
// 上行:原样推给所有 hub,附带来源
|
|
636
|
+
//
|
|
637
|
+
// ⚠ hub 方向**必须过背压**(2026-10-17 修)。
|
|
638
|
+
//
|
|
639
|
+
// C1-1 的背压修复只加在 `broadcastHubs`(229 行)与
|
|
640
|
+
// `broadcastHubsBinary`(673 行)两条路径上,这里是**第三条** hub 写
|
|
641
|
+
// 路径,当时漏网。而 `ws.js` 的 `overBudget` 只是查询属性、不自动断连
|
|
642
|
+
// (由调用方决定),所以这条路径的写缓冲**没有任何上限检查**。
|
|
643
|
+
//
|
|
644
|
+
// 触发形态:hub 连接停滞(标签页被挂起 / 调试器暂停 / 宿主挂起)+
|
|
645
|
+
// 副机持续上行(exec 输出行心跳、agent 事件流、资源遥测、产物分块)。
|
|
646
|
+
// 实测过的后果是单个停滞对端把 relay writableLength 推到 339.8 MiB、
|
|
647
|
+
// RSS 419 MiB —— 中继是 NAT 场景唯一的会合点、单进程常驻,OOM 即
|
|
648
|
+
// 全体断连。
|
|
649
|
+
//
|
|
650
|
+
// node 方向早已有防护,这是**方向对称性漏洞**:三条 hub 写路径必须
|
|
651
|
+
// 全部过同一道闸。改这里时请同时检查另外两条。
|
|
636
652
|
const out = encodeFrame({ ...frame, from: boundId })
|
|
637
653
|
for (const h of hubs) {
|
|
638
|
-
if (!h
|
|
654
|
+
if (!applyHubBackpressure(h)) continue
|
|
655
|
+
h.send(out)
|
|
639
656
|
}
|
|
640
657
|
})
|
|
641
658
|
|
package/lib/setup-routes.js
CHANGED
|
@@ -27,10 +27,14 @@
|
|
|
27
27
|
* 围栏,所以必须自己补)。写盘走 `lib/setup.js` 的 `writePatchBlock()` ——
|
|
28
28
|
* 它有冲突检测、备份、托管块、写后校验、失败回滚。
|
|
29
29
|
*/
|
|
30
|
+
import fs from 'node:fs'
|
|
30
31
|
import path from 'node:path'
|
|
31
32
|
import os from 'node:os'
|
|
32
33
|
import { ERROR_CODE, RemoteAgentError } from './protocol.js'
|
|
33
34
|
import { writePatchBlock } from './setup.js'
|
|
35
|
+
// 角色解析必须**只有一份实现** —— 面板说"未配置"而插件实际在跑 hub,
|
|
36
|
+
// 是最难排查的撕裂(v0.7.0 踩过)。所以这里直接复用 lib/index.js 的解析器。
|
|
37
|
+
import { resolveRoleDetail } from './index.js'
|
|
34
38
|
|
|
35
39
|
/** 引导路由的前缀。与 hub 的 `/api/remote-agent/ui` 分开 —— 未配置时后者不存在。 */
|
|
36
40
|
export const SETUP_API_PREFIX = '/api/remote-agent/setup'
|
|
@@ -206,14 +210,21 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
|
|
|
206
210
|
route(`${SETUP_API_PREFIX}/state`, (req, res) => {
|
|
207
211
|
if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
|
|
208
212
|
const role = typeof config.role === 'string' ? config.role.trim().toLowerCase() : ''
|
|
209
|
-
|
|
213
|
+
// 复用 lib/index.js 的解析器 —— 它已经处理了 auto 推断、大小写、非法值。
|
|
214
|
+
// 两处判据必须一致,否则会出现"面板说未配置、实际已在跑 hub"这种撕裂。
|
|
215
|
+
const detail = resolveRoleDetail(config)
|
|
216
|
+
// `configured` 的语义是"用户已经做过选择,不需要引导"。
|
|
217
|
+
// auto 也算已选择:它是个明确的工作模式(按 hubUrl 推断),不是"还没配"。
|
|
218
|
+
const configured = role === 'auto' || detail.unconfigured !== true
|
|
210
219
|
writeJson(res, 200, {
|
|
211
220
|
ok: true,
|
|
212
|
-
|
|
213
|
-
|
|
221
|
+
configured,
|
|
222
|
+
// 推断结果(面板据此显示"当前生效角色")—— auto 时它是有值的
|
|
223
|
+
effectiveRole: detail.role,
|
|
224
|
+
inferred: typeof detail.inferred === 'string' ? detail.inferred : '',
|
|
214
225
|
// 回显当前配置里已有的值,方便用户"改一个字段"而不是从头填
|
|
215
226
|
current: {
|
|
216
|
-
role:
|
|
227
|
+
role: typeof config.role === 'string' ? role : '',
|
|
217
228
|
nodeId: typeof config.nodeId === 'string' ? config.nodeId : '',
|
|
218
229
|
transportMode: typeof config.transportMode === 'string' ? config.transportMode : 'relay',
|
|
219
230
|
hubUrl: typeof config.hubUrl === 'string' ? config.hubUrl : '',
|
|
@@ -244,6 +255,9 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
|
|
|
244
255
|
'- id: remote-agent',
|
|
245
256
|
' name: dsh-remote-agent',
|
|
246
257
|
]
|
|
258
|
+
// 非致命提示(如"已自动创建目录 X")—— 随响应回给面板展示,
|
|
259
|
+
// 不打断写入流程。写入成功但目录没建出来,用户需要知道。
|
|
260
|
+
let applyWarnings = []
|
|
247
261
|
// inject 必须跟着 role 走:
|
|
248
262
|
// hub → 需要 webServer/tools/systemPrompt(注册路由与工具)
|
|
249
263
|
// worker → 需要 tools/agentLoop/sessions(agent 的 ctx 继承依赖声明,
|
|
@@ -281,8 +295,37 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
|
|
|
281
295
|
}
|
|
282
296
|
const roots = Array.isArray(body.roots) ? body.roots.map(str).filter((s) => s !== '') : []
|
|
283
297
|
if (roots.length > 0) {
|
|
298
|
+
// ── 自动创建 roots 目录(2026-10-17,开箱即用)────────────────────
|
|
299
|
+
//
|
|
300
|
+
// 此前 roots 必须**已经存在**:用户填了 D:\work 但没建这个目录 →
|
|
301
|
+
// 任务全部报 workspace-denied,而错误信息在任务详情里,用户根本
|
|
302
|
+
// 不会往"目录不存在"这个方向想。
|
|
303
|
+
//
|
|
304
|
+
// 现在写入配置时顺手把不存在的目录建出来。这是安全的:
|
|
305
|
+
// · 只 mkdir -p,不删不改任何已有内容
|
|
306
|
+
// · 路径必须**绝对**(相对路径一律跳过,让 resolveRoots 去拒)
|
|
307
|
+
// · 创建失败不阻塞写配置(只记进 warnings,面板能显示)
|
|
308
|
+
const warnings = []
|
|
309
|
+
for (const r of roots) {
|
|
310
|
+
try {
|
|
311
|
+
if (!path.isAbsolute(r)) {
|
|
312
|
+
warnings.push(`${r} 不是绝对路径,已跳过创建(roots 必须是绝对路径)`)
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
if (!fs.existsSync(r)) {
|
|
316
|
+
fs.mkdirSync(r, { recursive: true })
|
|
317
|
+
warnings.push(`已自动创建目录:${r}`)
|
|
318
|
+
}
|
|
319
|
+
} catch (err) {
|
|
320
|
+
warnings.push(`无法创建 ${r}:${String(err?.message ?? err)}`)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
284
323
|
lines.push(' roots:')
|
|
285
324
|
for (const r of roots) lines.push(` - '${r.replace(/'/g, "''")}'`)
|
|
325
|
+
if (warnings.length > 0) {
|
|
326
|
+
// 记到响应里(面板可展示),不打断写入流程
|
|
327
|
+
applyWarnings = warnings
|
|
328
|
+
}
|
|
286
329
|
}
|
|
287
330
|
|
|
288
331
|
const blockYaml = `${lines.join('\n')}\n`
|
|
@@ -298,13 +341,16 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
|
|
|
298
341
|
file: result.file,
|
|
299
342
|
})
|
|
300
343
|
}
|
|
301
|
-
log('info', 'setup applied', { role, file: result.file, mode: result.mode })
|
|
344
|
+
log('info', 'setup applied', { role, file: result.file, mode: result.mode, warnings: applyWarnings })
|
|
302
345
|
writeJson(res, 200, {
|
|
303
346
|
ok: true,
|
|
304
347
|
role,
|
|
305
348
|
file: result.file,
|
|
306
349
|
backup: result.backup,
|
|
307
350
|
mode: result.mode,
|
|
351
|
+
// 非致命提示(自动建目录的结果等)—— 面板要显示,否则用户以为
|
|
352
|
+
// "目录已就绪"而实际没有
|
|
353
|
+
warnings: applyWarnings,
|
|
308
354
|
note: '已写入配置。**重启 DSH 后生效**。',
|
|
309
355
|
})
|
|
310
356
|
})
|
package/package.json
CHANGED
|
@@ -303,9 +303,15 @@ for (const [i, [file, why]] of MAINT_FILES.entries()) {
|
|
|
303
303
|
check('29', 'profiles/worker.cordis.yml 里有 inject: []',
|
|
304
304
|
/^\s*inject:\s*\[\]\s*$/m.test(workerProfile),
|
|
305
305
|
'profiles/worker.cordis.yml(副机不清空 inject 会永远 PENDING)')
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
306
|
+
// 2026-10-17 改:原断言锁的是 `inject: []`(README 教用户写这个)——
|
|
307
|
+
// **那是错的**:副机 agent 的 ctx 继承插件的依赖声明,`inject: []` 会让
|
|
308
|
+
// agent 没有任何 shell/文件工具,任务"跑完了但什么都没干"(实测踩过)。
|
|
309
|
+
// 正确写法是 worker 三件套。断言跟着改,否则文档会退回错误建议。
|
|
310
|
+
check('30', 'README 说明了副机 inject 的正确写法(tools/agentLoop/sessions,不是 [])',
|
|
311
|
+
/inject:\s*\[[^\]]*agentLoop[^\]]*\]/.test(rootReadme)
|
|
312
|
+
&& /agentLoop/.test(rootReadme)
|
|
313
|
+
&& /没有[^\n]*工具|无[^\n]*工具/.test(rootReadme),
|
|
314
|
+
'README.md(副机配置一节:inject 三件套 + 缺了会怎样的说明)')
|
|
309
315
|
check('31', '包内 cordis.patch.yml 声明了 hub 需要的 inject',
|
|
310
316
|
/inject:\s*\[[^\]]*webServer[^\]]*\]/.test(read('cordis.patch.yml')),
|
|
311
317
|
'cordis.patch.yml')
|
|
@@ -886,6 +892,166 @@ check('65', 'eslint 严格档:lint script + 配置文件 + CI job 三件套齐
|
|
|
886
892
|
'lib/node/index.js readAvailableModels(上报字段恰好 provider/id/name)')
|
|
887
893
|
}
|
|
888
894
|
|
|
895
|
+
/* ── 77~78. 环境体检(2026-10-17)──
|
|
896
|
+
*
|
|
897
|
+
* 这个插件最贵的失败全是**静默**的:
|
|
898
|
+
* · webserver 绑 loopback → 副机永远连不上,主机侧毫无异常
|
|
899
|
+
* · inject 缺服务 → hub 半死(路由活着但工具全丢)
|
|
900
|
+
* · roots 不存在 → 任务全被 workspace-denied 拒绝
|
|
901
|
+
*
|
|
902
|
+
* 用户只能猜"地址错了?token 错了?防火墙?"。所以体检必须覆盖这些前置条件、
|
|
903
|
+
* 每条都给**具体修法**,且命令行与面板共用同一份逻辑(两处不一致更坏)。
|
|
904
|
+
*
|
|
905
|
+
* 另外钉住一条产品边界:**不代用户下载第三方软件**(零依赖是核心卖点,
|
|
906
|
+
* 代下载会让我们变成软件分发方)。
|
|
907
|
+
*/
|
|
908
|
+
{
|
|
909
|
+
const envLib = read('lib/env-check.js')
|
|
910
|
+
const uiSrc2 = read('lib/hub/ui-routes.js')
|
|
911
|
+
const clientSrc2 = read('lib/client.js')
|
|
912
|
+
|
|
913
|
+
check('77', '环境体检:共享模块 + 面板路由 + 面板按钮三处都在',
|
|
914
|
+
/export function envChecks/.test(envLib)
|
|
915
|
+
&& /export function isLoopbackOrigin/.test(envLib)
|
|
916
|
+
&& /export function renderEnvChecks/.test(envLib)
|
|
917
|
+
&& /UI_API_PREFIX\}\/env-check/.test(uiSrc2)
|
|
918
|
+
&& /apiGet\('\/env-check'\)/.test(clientSrc2)
|
|
919
|
+
&& /'体检'/.test(clientSrc2),
|
|
920
|
+
'lib/env-check.js + ui-routes.js /env-check + client.js 体检按钮')
|
|
921
|
+
|
|
922
|
+
check('78', '环境体检:覆盖静默失败项 + 每条带修法 + 不代下载',
|
|
923
|
+
/'reachability'/.test(envLib)
|
|
924
|
+
&& /'inject'/.test(envLib)
|
|
925
|
+
&& /'roots'/.test(envLib)
|
|
926
|
+
&& /'role'/.test(envLib)
|
|
927
|
+
&& /fix:/.test(envLib)
|
|
928
|
+
&& /不会代你下载/.test(envLib),
|
|
929
|
+
'lib/env-check.js(可达性/inject/roots/role + fix + 不代下载边界)')
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/* ── 79~80. 开箱即用(2026-10-17)──
|
|
933
|
+
*
|
|
934
|
+
* 产品标准:用户装完就该能用,不该读文档、改 YAML、猜字段。
|
|
935
|
+
*
|
|
936
|
+
* 此前三道门槛:
|
|
937
|
+
* 1. `role` 默认 '' → 两台机器都要操作一次
|
|
938
|
+
* 2. `webserver` 没绑 0.0.0.0 → 副机永远连不上(且完全静默)
|
|
939
|
+
* 3. 副机表单 4 个字段 → 小白不知道节点 ID / 工作区填什么
|
|
940
|
+
*
|
|
941
|
+
* 核心设计:`role` 默认 **auto**,按 `hubUrl` 推断。
|
|
942
|
+
* · 默认 hub → 副机装完以 hub 身份启动,注册无鉴权端点(**攻击面**)
|
|
943
|
+
* · 默认 '' → 两台都要操作,不是开箱即用
|
|
944
|
+
* · 默认 auto → 主机零操作、副机贴完配对码零操作,且不给副机开错端点
|
|
945
|
+
*/
|
|
946
|
+
{
|
|
947
|
+
const indexSrc = read('lib/index.js')
|
|
948
|
+
const patchSrc = read('cordis.patch.yml')
|
|
949
|
+
const clientSrc3 = read('lib/client.js')
|
|
950
|
+
const setupSrc = read('lib/setup-routes.js')
|
|
951
|
+
|
|
952
|
+
check('79', '开箱即用:role 默认 auto + 包内 patch 绑 0.0.0.0:19387',
|
|
953
|
+
/\.default\('auto'\)/.test(indexSrc)
|
|
954
|
+
&& /ROLE\.AUTO/.test(indexSrc)
|
|
955
|
+
&& /config\.hubUrl/.test(indexSrc)
|
|
956
|
+
// 包内 patch 必须 override webserver(否则副机连不上)
|
|
957
|
+
&& /^- id: webserver$/m.test(patchSrc)
|
|
958
|
+
&& /host:\s*'0\.0\.0\.0'/.test(patchSrc)
|
|
959
|
+
&& /port:\s*19387/.test(patchSrc)
|
|
960
|
+
// 必须披露安全影响
|
|
961
|
+
&& /暴露/.test(patchSrc),
|
|
962
|
+
'lib/index.js(auto 推断)+ cordis.patch.yml(webserver 0.0.0.0:19387 + 安全披露)')
|
|
963
|
+
|
|
964
|
+
check('80', '开箱即用:副机表单简化 + 工作区自动创建 + 首次自动体检',
|
|
965
|
+
// 工作区预填
|
|
966
|
+
/function defaultRootsHint/.test(clientSrc3)
|
|
967
|
+
// 节点 ID 折叠
|
|
968
|
+
&& /默认用主机名/.test(clientSrc3)
|
|
969
|
+
// 首次自动体检
|
|
970
|
+
&& /autoChecked/.test(clientSrc3)
|
|
971
|
+
// 服务端自动建目录
|
|
972
|
+
&& /mkdirSync\(r, \{ recursive: true \}\)/.test(setupSrc)
|
|
973
|
+
&& /warnings: applyWarnings/.test(setupSrc),
|
|
974
|
+
'lib/client.js(预填/折叠/自动体检)+ lib/setup-routes.js(自动建目录)')
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/* ── 81~82. 工作区 SMB 共享(2026-10-17)──
|
|
978
|
+
*
|
|
979
|
+
* 用户需求:想用本地 IDE 直接打开副机的项目目录 —— 唯一现实路径是把工作区
|
|
980
|
+
* 通过 SMB 共享出去,主机 `net use X: \\副机\dsh-work` 映射成盘符。
|
|
981
|
+
*
|
|
982
|
+
* ⚠ **这是本插件唯一主动放宽安全边界的功能**:
|
|
983
|
+
* · roots / denyPaths 全部失效(Windows 内核直读,插件拦不住)
|
|
984
|
+
* · 「清单先于字节」失效(.env / .git 密钥 / 中间产物全部可见)
|
|
985
|
+
* · 新增凭证面(主机需要副机 Windows 账号)
|
|
986
|
+
*
|
|
987
|
+
* 所以产品要求(用户确认):二次确认 + 只读/读写两选项 + 不代执行(需管理员)。
|
|
988
|
+
* 这两条断言把安全约束钉住 —— 它们比功能本身更重要。
|
|
989
|
+
*/
|
|
990
|
+
{
|
|
991
|
+
const shareSvc = read('lib/node/share-service.js')
|
|
992
|
+
const nodeIdx2 = read('lib/node/index.js')
|
|
993
|
+
const uiSrc3 = read('lib/hub/ui-routes.js')
|
|
994
|
+
const clientSrc4 = read('lib/client.js')
|
|
995
|
+
const transportSrc = read('lib/node/transport.js')
|
|
996
|
+
|
|
997
|
+
check('81', '工作区共享:协议帧 + E2E 强制 + 独立 pending 表 + 副机 handler 都在',
|
|
998
|
+
/SHARE_REQ: 'share\/req'/.test(read('lib/protocol.js'))
|
|
999
|
+
&& /SHARE_RES: 'share\/res'/.test(read('lib/protocol.js'))
|
|
1000
|
+
// 工作区绝对路径不许明文过中继
|
|
1001
|
+
&& /HUB_TO_NODE\.SHARE_REQ/.test(transportSrc)
|
|
1002
|
+
// 两张 pending 表不能混(混了超时归因变模糊)
|
|
1003
|
+
&& /this\.sharePending = new Map\(\)/.test(read('lib/hub/node-registry.js'))
|
|
1004
|
+
&& /shareCancelForNode/.test(read('lib/hub/node-registry.js'))
|
|
1005
|
+
&& /handleShareRes/.test(read('lib/hub/registry-ingest.js'))
|
|
1006
|
+
&& /function handleShareReq/.test(nodeIdx2),
|
|
1007
|
+
'protocol.js + transport.js + node-registry.js + registry-ingest.js + node/index.js')
|
|
1008
|
+
|
|
1009
|
+
check('82', '工作区共享安全:二次确认硬要求 + 只读默认 + 不代执行',
|
|
1010
|
+
// 二次确认是硬要求(缺 confirm 必须 400,不能有默认通过)
|
|
1011
|
+
/body\?\.confirm !== 'yes'/.test(uiSrc3)
|
|
1012
|
+
&& /confirm-required/.test(uiSrc3)
|
|
1013
|
+
// 只读默认(最小权限)
|
|
1014
|
+
&& /React\.useState\('read'\)/.test(clientSrc4)
|
|
1015
|
+
&& /disabled: busy \|\| !ack/.test(clientSrc4)
|
|
1016
|
+
// 读写用 -ChangeAccess,不用 -FullAccess(后者会把 ACL 也交出去)
|
|
1017
|
+
&& /ChangeAccess/.test(shareSvc)
|
|
1018
|
+
&& !/'-FullAccess'/.test(shareSvc)
|
|
1019
|
+
// 不代执行(需管理员,Node 提权失败比让用户手动跑难排查)
|
|
1020
|
+
&& !/spawn.*New-SmbShare/.test(nodeIdx2)
|
|
1021
|
+
&& /admin: isAdmin\(\)/.test(nodeIdx2)
|
|
1022
|
+
// 共享目录必须过 roots 边界
|
|
1023
|
+
&& /resolveWorkspace\(/.test(nodeIdx2),
|
|
1024
|
+
'ui-routes.js(二次确认)+ client.js(勾选框/只读默认)+ share-service.js + node/index.js')
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/* ── 83. README 声明的工具数量必须等于代码里实际注册的数量 ──
|
|
1028
|
+
*
|
|
1029
|
+
* 起因(2026-10-17):README 写「7 个 remote_* 工具」,实际早已是 12 个
|
|
1030
|
+
* (8 个 remote_* + 4 个 fs 工具)。这种数字漂移是最容易积累的文档债务 ——
|
|
1031
|
+
* 每次加工具都忘了改 README,而读者会拿它当事实。
|
|
1032
|
+
*
|
|
1033
|
+
* 断言做法:数 `reg(` / `fsOp(` 的注册调用,与 README 里的声明比对。
|
|
1034
|
+
* 两边任一变化都会立刻失败,逼着改的人同步另一处。
|
|
1035
|
+
*/
|
|
1036
|
+
{
|
|
1037
|
+
const toolsSrc = read('lib/hub/tools.js')
|
|
1038
|
+
const rootReadme2 = read('README.md')
|
|
1039
|
+
|
|
1040
|
+
// 数实际注册:`reg(` 与 `fsOp(` 的调用点(都是工具注册入口)
|
|
1041
|
+
const regCount = (toolsSrc.match(/^\s{2}reg\(/gm) || []).length
|
|
1042
|
+
const fsOpCount = (toolsSrc.match(/^\s{2}fsOp\(/gm) || []).length
|
|
1043
|
+
const actual = regCount + fsOpCount
|
|
1044
|
+
|
|
1045
|
+
// 从 README 里抽声明(形如「12 个 remote_* 工具」或「工具速查(共 12 个)」)
|
|
1046
|
+
const m = /(?:共\s*(\d+)\s*个|(\d+)\s*个\s*remote_\*)/.exec(rootReadme2)
|
|
1047
|
+
const declared = m ? Number(m[1] ?? m[2]) : null
|
|
1048
|
+
|
|
1049
|
+
check('83', 'README 声明的工具数量等于代码里实际注册的数量',
|
|
1050
|
+
declared !== null && declared === actual,
|
|
1051
|
+
`lib/hub/tools.js(实际 ${actual} 个:reg=${regCount} + fsOp=${fsOpCount})`
|
|
1052
|
+
+ ` vs README.md(声明 ${declared === null ? '未找到' : declared} 个)`)
|
|
1053
|
+
}
|
|
1054
|
+
|
|
889
1055
|
/* ── 输出 ── */
|
|
890
1056
|
const failed = results.filter((r) => !r.ok)
|
|
891
1057
|
if (process.argv.includes('--json')) {
|