dsh-remote-agent 0.0.0-stage → 0.4.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 +700 -0
- package/CONTRIBUTING.md +358 -0
- package/KNOWN-ISSUES.md +601 -0
- package/LICENSE +21 -0
- package/README.en.md +353 -0
- package/README.md +317 -2
- package/SECURITY.md +348 -0
- package/bin/dsh-remote-relay.js +257 -0
- package/bin/dsh-remote-setup.js +440 -0
- package/bin//345/217/214/345/207/273/350/277/220/350/241/214-/351/205/215/347/275/256/345/220/221/345/257/274.cmd +28 -0
- package/cordis.patch.yml +87 -0
- package/deploy/relay-vps/Caddyfile +24 -0
- package/deploy/relay-vps/README.md +132 -0
- package/deploy/relay-vps/dsh-remote-relay.service +47 -0
- package/deploy/relay-vps/nginx.conf +49 -0
- package/deploy/relay-vps/relay.env.example +20 -0
- package/docs/dispatch-automation-eval.md +140 -0
- package/docs/screenshots/panel-main.png +0 -0
- package/docs/screenshots/panel-task-detail.png +0 -0
- package/docs/verification-agent-e2e.md +425 -0
- package/docs/verification-repos-routing-real.md +521 -0
- package/docs/verification-resume-real.md +254 -0
- package/docs/worker-setup-pitfalls.md +266 -0
- package/lib/artifacts.js +451 -0
- package/lib/client.js +1752 -0
- package/lib/e2e.js +304 -0
- package/lib/glob.js +225 -0
- package/lib/hub/audit-log.js +161 -0
- package/lib/hub/hub-util.js +114 -0
- package/lib/hub/index.js +500 -0
- package/lib/hub/node-registry.js +943 -0
- package/lib/hub/pull-manager.js +781 -0
- package/lib/hub/registry-handshake.js +403 -0
- package/lib/hub/registry-ingest.js +346 -0
- package/lib/hub/relay-link.js +228 -0
- package/lib/hub/subagent-provider.js +424 -0
- package/lib/hub/templates.js +693 -0
- package/lib/hub/tools.js +1059 -0
- package/lib/hub/ui-routes.js +856 -0
- package/lib/hub/views.js +351 -0
- package/lib/identity.js +271 -0
- package/lib/index.js +272 -0
- package/lib/node/artifact-service.js +231 -0
- package/lib/node/diff.js +124 -0
- package/lib/node/exec.js +536 -0
- package/lib/node/hub-pin.js +71 -0
- package/lib/node/index.js +1022 -0
- package/lib/node/repos.js +134 -0
- package/lib/node/resource.js +63 -0
- package/lib/node/runner.js +792 -0
- package/lib/node/task-engine.js +712 -0
- package/lib/node/task-store.js +273 -0
- package/lib/node/transport.js +763 -0
- package/lib/pairing.js +186 -0
- package/lib/protocol.js +414 -0
- package/lib/relay-config.js +317 -0
- package/lib/relay.js +732 -0
- package/lib/setup-routes.js +313 -0
- package/lib/setup.js +522 -0
- package/lib/ws.js +466 -0
- package/package.json +103 -4
- package/profiles/README.md +573 -0
- package/profiles/hub.cordis.yml +84 -0
- package/profiles/worker.cordis.yml +60 -0
- package/scripts/check-doc-drift.mjs +785 -0
- package/scripts/check-packaging.mjs +130 -0
- package/scripts/diag-inject.mjs +243 -0
- package/scripts/e2e-live-check.mjs +181 -0
- package/scripts/relay-init.ps1 +286 -0
- package/scripts/relay-init.sh +199 -0
package/lib/ws.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 最小 RFC6455 WebSocket 实现(零外部依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 只实现 dsh-remote-agent 需要的那一片:
|
|
5
|
+
* - 文本帧 / 二进制帧 / ping / pong / close
|
|
6
|
+
* - 分片(continuation)重组
|
|
7
|
+
* - 客户端强制掩码,服务端强制不掩码
|
|
8
|
+
* - 单帧长度上限,防止畸形帧吃内存
|
|
9
|
+
* 不做:扩展协商(permessage-deflate)、子协议、UTF-8 增量校验。
|
|
10
|
+
* 这是刻意的取舍 —— 少一层可变行为,跨网络长连接更好预测。
|
|
11
|
+
*/
|
|
12
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
13
|
+
import { EventEmitter } from 'node:events'
|
|
14
|
+
import http from 'node:http'
|
|
15
|
+
import https from 'node:https'
|
|
16
|
+
import { MAX_ARTIFACT_FRAME_BYTES, MAX_CONTROL_FRAME_BYTES } from './protocol.js'
|
|
17
|
+
|
|
18
|
+
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
19
|
+
|
|
20
|
+
const OP = { CONT: 0x0, TEXT: 0x1, BINARY: 0x2, CLOSE: 0x8, PING: 0x9, PONG: 0xa }
|
|
21
|
+
|
|
22
|
+
export function acceptKey(key) {
|
|
23
|
+
return createHash('sha1').update(String(key) + GUID).digest('base64')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function encodeFrame(opcode, payload, mask) {
|
|
27
|
+
const len = payload.length
|
|
28
|
+
let header
|
|
29
|
+
if (len < 126) {
|
|
30
|
+
header = Buffer.allocUnsafe(2)
|
|
31
|
+
header[1] = len
|
|
32
|
+
} else if (len < 65536) {
|
|
33
|
+
header = Buffer.allocUnsafe(4)
|
|
34
|
+
header[1] = 126
|
|
35
|
+
header.writeUInt16BE(len, 2)
|
|
36
|
+
} else {
|
|
37
|
+
header = Buffer.allocUnsafe(10)
|
|
38
|
+
header[1] = 127
|
|
39
|
+
header.writeBigUInt64BE(BigInt(len), 2)
|
|
40
|
+
}
|
|
41
|
+
header[0] = 0x80 | opcode
|
|
42
|
+
if (!mask) return Buffer.concat([header, payload], header.length + len)
|
|
43
|
+
|
|
44
|
+
header[1] |= 0x80
|
|
45
|
+
const key = randomBytes(4)
|
|
46
|
+
const out = Buffer.allocUnsafe(len)
|
|
47
|
+
for (let i = 0; i < len; i += 1) out[i] = payload[i] ^ key[i & 3]
|
|
48
|
+
return Buffer.concat([header, key, out], header.length + 4 + len)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 增量帧解析器:喂 Buffer,吐 {opcode, payload}。 */
|
|
52
|
+
class FrameParser {
|
|
53
|
+
constructor(onFrame, onProtocolError) {
|
|
54
|
+
this.buf = Buffer.alloc(0)
|
|
55
|
+
this.onFrame = onFrame
|
|
56
|
+
this.onProtocolError = onProtocolError
|
|
57
|
+
this.fragOp = 0
|
|
58
|
+
this.frags = []
|
|
59
|
+
this.fragBytes = 0
|
|
60
|
+
this.dead = false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
push(chunk) {
|
|
64
|
+
if (this.dead) return
|
|
65
|
+
this.buf = this.buf.length === 0 ? chunk : Buffer.concat([this.buf, chunk])
|
|
66
|
+
while (!this.dead) {
|
|
67
|
+
const consumed = this.step()
|
|
68
|
+
if (consumed === 0) break
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @returns {number} 消费的字节数;0 表示数据不足。 */
|
|
73
|
+
step() {
|
|
74
|
+
const b = this.buf
|
|
75
|
+
if (b.length < 2) return 0
|
|
76
|
+
|
|
77
|
+
const fin = (b[0] & 0x80) !== 0
|
|
78
|
+
const rsv = b[0] & 0x70
|
|
79
|
+
const opcode = b[0] & 0x0f
|
|
80
|
+
const masked = (b[1] & 0x80) !== 0
|
|
81
|
+
let len = b[1] & 0x7f
|
|
82
|
+
let offset = 2
|
|
83
|
+
|
|
84
|
+
if (rsv !== 0) {
|
|
85
|
+
this.fail('rsv-set')
|
|
86
|
+
return 0
|
|
87
|
+
}
|
|
88
|
+
if (len === 126) {
|
|
89
|
+
if (b.length < offset + 2) return 0
|
|
90
|
+
len = b.readUInt16BE(offset)
|
|
91
|
+
offset += 2
|
|
92
|
+
} else if (len === 127) {
|
|
93
|
+
if (b.length < offset + 8) return 0
|
|
94
|
+
const big = b.readBigUInt64BE(offset)
|
|
95
|
+
if (big > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
96
|
+
this.fail('frame-too-large')
|
|
97
|
+
return 0
|
|
98
|
+
}
|
|
99
|
+
len = Number(big)
|
|
100
|
+
offset += 8
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const limit = opcode === OP.BINARY ? MAX_ARTIFACT_FRAME_BYTES : MAX_CONTROL_FRAME_BYTES
|
|
104
|
+
if (len > limit) {
|
|
105
|
+
this.fail('frame-too-large')
|
|
106
|
+
return 0
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let maskKey
|
|
110
|
+
if (masked) {
|
|
111
|
+
if (b.length < offset + 4) return 0
|
|
112
|
+
maskKey = b.subarray(offset, offset + 4)
|
|
113
|
+
offset += 4
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (b.length < offset + len) return 0
|
|
117
|
+
|
|
118
|
+
const payload = Buffer.allocUnsafe(len)
|
|
119
|
+
b.copy(payload, 0, offset, offset + len)
|
|
120
|
+
if (masked) {
|
|
121
|
+
for (let i = 0; i < len; i += 1) payload[i] ^= maskKey[i & 3]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const total = offset + len
|
|
125
|
+
this.buf = b.length === total ? Buffer.alloc(0) : b.subarray(total)
|
|
126
|
+
this.dispatch(fin, opcode, payload)
|
|
127
|
+
return total
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
dispatch(fin, opcode, payload) {
|
|
131
|
+
if (opcode === OP.CONT) {
|
|
132
|
+
if (this.fragOp === 0) {
|
|
133
|
+
this.fail('unexpected-continuation')
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
this.frags.push(payload)
|
|
137
|
+
this.fragBytes += payload.length
|
|
138
|
+
// ⚠ 分片也要有**总量**上限,不能只限单帧。
|
|
139
|
+
//
|
|
140
|
+
// 上面 `len > limit` 只挡住单个分片;RFC 6455 允许把一个消息拆成任意多片,
|
|
141
|
+
// 而 CONT 帧数量不限 —— 于是一条连接可以发 N 个 4MiB 分片,把
|
|
142
|
+
// `Buffer.concat(frags, fragBytes)` 之前的 frags 数组撑到任意大小。
|
|
143
|
+
// 直连端点在 `nodeToken` 留空时**不做鉴权**(文档明示只适用于可信私网),
|
|
144
|
+
// 所以这是一条"一条连接打爆 hub 内存"的路径。
|
|
145
|
+
//
|
|
146
|
+
// 阈值取单帧上限的 4 倍:产物分块本身不需要跨多片(单片上限已是 4MiB),
|
|
147
|
+
// 留 4 倍余量足够容纳合法的大消息,又不给无限增长留空间。
|
|
148
|
+
if (this.fragBytes > MAX_ARTIFACT_FRAME_BYTES * 4) {
|
|
149
|
+
this.fail('fragment-too-large')
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
if (fin) {
|
|
153
|
+
const full = Buffer.concat(this.frags, this.fragBytes)
|
|
154
|
+
const op = this.fragOp
|
|
155
|
+
this.fragOp = 0
|
|
156
|
+
this.frags = []
|
|
157
|
+
this.fragBytes = 0
|
|
158
|
+
this.onFrame(op, full)
|
|
159
|
+
}
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (opcode === OP.PING || opcode === OP.PONG || opcode === OP.CLOSE) {
|
|
164
|
+
this.onFrame(opcode, payload)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (opcode !== OP.TEXT && opcode !== OP.BINARY) {
|
|
169
|
+
this.fail('bad-opcode')
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (this.fragOp !== 0) {
|
|
174
|
+
this.fail('interleaved-fragments')
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (fin) {
|
|
179
|
+
this.onFrame(opcode, payload)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.fragOp = opcode
|
|
184
|
+
this.frags = [payload]
|
|
185
|
+
this.fragBytes = payload.length
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
fail(reason) {
|
|
189
|
+
this.dead = true
|
|
190
|
+
this.onProtocolError(reason)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 一条已建立握手的 WebSocket 连接。
|
|
196
|
+
* 事件:'message'(data:Buffer, isBinary:boolean) / 'close'(code, reason) / 'error'(err)
|
|
197
|
+
*/
|
|
198
|
+
export class WSConnection extends EventEmitter {
|
|
199
|
+
constructor(socket, { mask, maxQueuedBytes = 64 * 1024 * 1024, name = 'ws' } = {}) {
|
|
200
|
+
super()
|
|
201
|
+
this.socket = socket
|
|
202
|
+
this.mask = mask
|
|
203
|
+
this.name = name
|
|
204
|
+
this.closed = false
|
|
205
|
+
this.queuedBytes = 0
|
|
206
|
+
this.maxQueuedBytes = maxQueuedBytes
|
|
207
|
+
this.parser = new FrameParser(
|
|
208
|
+
(op, payload) => this.handleFrame(op, payload),
|
|
209
|
+
(reason) => this.destroy(new Error(`ws protocol error: ${reason}`)),
|
|
210
|
+
)
|
|
211
|
+
socket.on('data', (chunk) => {
|
|
212
|
+
try {
|
|
213
|
+
this.parser.push(chunk)
|
|
214
|
+
} catch (err) {
|
|
215
|
+
this.destroy(err)
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
socket.on('error', (err) => this.destroy(err))
|
|
219
|
+
|
|
220
|
+
// 对端硬断(kill -9 / 拔网线 / NAT 超时)时**不发** WebSocket close 帧,只发 FIN。
|
|
221
|
+
// upgrade 过的 socket 是 allowHalfOpen=true,所以 FIN 只 emit 'end'、不 emit 'close',
|
|
222
|
+
// 而 finish() 原本只挂在 'close' 上 —— 结果 conn.on('close') 永不触发,
|
|
223
|
+
// 上层(relay 的 node 清理、hub 的节点下线)会永久泄漏这条连接:
|
|
224
|
+
// 死节点占着 maxNodes 配额,节点列表与在线广播全部失真。
|
|
225
|
+
// 实测:客户端 destroy() 后 2 秒,服务端只见 'socket end','close' 从未来过。
|
|
226
|
+
socket.on('end', () => {
|
|
227
|
+
this.finish(1006, 'peer closed without close frame')
|
|
228
|
+
// allowHalfOpen 下 'end' 之后 socket 仍可写,必须主动销毁才能释放 fd
|
|
229
|
+
try {
|
|
230
|
+
socket.destroy()
|
|
231
|
+
} catch {
|
|
232
|
+
/* 已销毁 */
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
socket.on('close', () => this.finish(1006, 'socket closed'))
|
|
237
|
+
socket.setNoDelay?.(true)
|
|
238
|
+
// 长连接不被中间设备静默掐断:交给上层 heartbeat,但底层也关掉空闲超时。
|
|
239
|
+
socket.setTimeout?.(0)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
handleFrame(opcode, payload) {
|
|
243
|
+
if (opcode === OP.PING) {
|
|
244
|
+
this.rawSend(OP.PONG, payload)
|
|
245
|
+
this.emit('ping', payload)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
if (opcode === OP.PONG) {
|
|
249
|
+
this.emit('pong', payload)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (opcode === OP.CLOSE) {
|
|
253
|
+
const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1005
|
|
254
|
+
const reason = payload.length > 2 ? payload.subarray(2).toString('utf8') : ''
|
|
255
|
+
this.rawSend(OP.CLOSE, payload.length >= 2 ? payload.subarray(0, 2) : Buffer.alloc(0))
|
|
256
|
+
this.finish(code, reason)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
this.emit('message', payload, opcode === OP.BINARY)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 写缓冲是否已超预算。
|
|
264
|
+
*
|
|
265
|
+
* `maxQueuedBytes` 此前只是声明、从未被读(死代码)。中继用它做下行背压:
|
|
266
|
+
* 一个停滞的节点(不读 socket)会让 `socket.write` 的缓冲无限增长 ——
|
|
267
|
+
* 实测推 2000×256KiB 后 writableLength 达 339.8 MiB、relay RSS 419 MiB,
|
|
268
|
+
* 单个停滞节点就能把中继 OOM 打掉。
|
|
269
|
+
*
|
|
270
|
+
* 注意:只对**数据帧**生效(send/sendBinary)。PING/PONG/CLOSE 走 rawSend,
|
|
271
|
+
* 必须始终能发出,否则一条卡住的连接连优雅关闭都做不到。
|
|
272
|
+
*/
|
|
273
|
+
get overBudget() {
|
|
274
|
+
return this.bufferedBytes > this.maxQueuedBytes
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
rawSend(opcode, payload) {
|
|
278
|
+
if (this.closed) return false
|
|
279
|
+
try {
|
|
280
|
+
this.socket.write(encodeFrame(opcode, payload, this.mask))
|
|
281
|
+
return true
|
|
282
|
+
} catch (err) {
|
|
283
|
+
this.destroy(err)
|
|
284
|
+
return false
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 发送文本帧。 */
|
|
289
|
+
send(text) {
|
|
290
|
+
return this.rawSend(OP.TEXT, Buffer.from(String(text), 'utf8'))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 发送二进制帧(产物分块走这里,避免 base64 的 33% 膨胀)。 */
|
|
294
|
+
sendBinary(buf) {
|
|
295
|
+
return this.rawSend(OP.BINARY, Buffer.isBuffer(buf) ? buf : Buffer.from(buf))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 尚未写出的字节数。
|
|
300
|
+
* 产物分块必须靠它做背压 —— 一个 200MB 的产物若一次灌进 socket,
|
|
301
|
+
* 内存会被撑爆,而且中继侧的转发队列会跟着一起涨。
|
|
302
|
+
*/
|
|
303
|
+
get bufferedBytes() {
|
|
304
|
+
return this.socket?.writableLength ?? 0
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** 等待写缓冲降到阈值以下,或超时。 */
|
|
308
|
+
drain(threshold = 1 * 1024 * 1024, timeoutMs = 30_000) {
|
|
309
|
+
if (this.bufferedBytes <= threshold) return Promise.resolve(true)
|
|
310
|
+
return new Promise((resolve) => {
|
|
311
|
+
const started = Date.now()
|
|
312
|
+
const tick = () => {
|
|
313
|
+
if (this.closed) {
|
|
314
|
+
resolve(false)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
if (this.bufferedBytes <= threshold) {
|
|
318
|
+
resolve(true)
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
if (Date.now() - started > timeoutMs) {
|
|
322
|
+
resolve(false)
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
setTimeout(tick, 5)
|
|
326
|
+
}
|
|
327
|
+
tick()
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
ping() {
|
|
332
|
+
return this.rawSend(OP.PING, Buffer.alloc(0))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
close(code = 1000, reason = '') {
|
|
336
|
+
if (this.closed) return
|
|
337
|
+
const r = Buffer.from(reason, 'utf8')
|
|
338
|
+
const payload = Buffer.allocUnsafe(2 + r.length)
|
|
339
|
+
payload.writeUInt16BE(code, 0)
|
|
340
|
+
r.copy(payload, 2)
|
|
341
|
+
this.rawSend(OP.CLOSE, payload)
|
|
342
|
+
this.finish(code, reason)
|
|
343
|
+
try {
|
|
344
|
+
this.socket.end()
|
|
345
|
+
} catch {
|
|
346
|
+
/* socket 可能已断,忽略 */
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
destroy(err) {
|
|
351
|
+
if (err) this.emit('error', err)
|
|
352
|
+
this.finish(1006, err ? err.message : 'destroyed')
|
|
353
|
+
try {
|
|
354
|
+
this.socket.destroy()
|
|
355
|
+
} catch {
|
|
356
|
+
/* 已销毁 */
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
finish(code, reason) {
|
|
361
|
+
if (this.closed) return
|
|
362
|
+
this.closed = true
|
|
363
|
+
this.parser.dead = true
|
|
364
|
+
this.emit('close', code, reason)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 服务端:接管一个已经 upgrade 的 socket。
|
|
370
|
+
* 直接对接 ctx.webServer.registerUpgrade({ path, handler }) 的 handler 签名。
|
|
371
|
+
*/
|
|
372
|
+
export function acceptUpgrade(req, socket, head, opts = {}) {
|
|
373
|
+
const key = req.headers['sec-websocket-key']
|
|
374
|
+
if (!key || req.headers.upgrade?.toLowerCase() !== 'websocket') {
|
|
375
|
+
socket.destroy()
|
|
376
|
+
return undefined
|
|
377
|
+
}
|
|
378
|
+
const headers = [
|
|
379
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
380
|
+
'Upgrade: websocket',
|
|
381
|
+
'Connection: Upgrade',
|
|
382
|
+
`Sec-WebSocket-Accept: ${acceptKey(key)}`,
|
|
383
|
+
'\r\n',
|
|
384
|
+
]
|
|
385
|
+
socket.write(headers.join('\r\n'))
|
|
386
|
+
const conn = new WSConnection(socket, { mask: false, ...opts })
|
|
387
|
+
if (head && head.length > 0) conn.parser.push(head)
|
|
388
|
+
return conn
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 客户端:连到 ws:// 或 wss://。
|
|
393
|
+
* @returns {Promise<WSConnection>}
|
|
394
|
+
*/
|
|
395
|
+
export function connect(url, { headers = {}, timeoutMs = 15_000, name = 'ws-client' } = {}) {
|
|
396
|
+
return new Promise((resolve, reject) => {
|
|
397
|
+
let target
|
|
398
|
+
try {
|
|
399
|
+
target = new URL(url)
|
|
400
|
+
} catch {
|
|
401
|
+
reject(new Error(`invalid ws url: ${url}`))
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
const isSecure = target.protocol === 'wss:'
|
|
405
|
+
const mod = isSecure ? https : http
|
|
406
|
+
const key = randomBytes(16).toString('base64')
|
|
407
|
+
const req = mod.request({
|
|
408
|
+
protocol: target.protocol === 'wss:' ? 'https:' : 'http:',
|
|
409
|
+
hostname: target.hostname,
|
|
410
|
+
port: target.port || (isSecure ? 443 : 80),
|
|
411
|
+
path: `${target.pathname}${target.search}`,
|
|
412
|
+
method: 'GET',
|
|
413
|
+
headers: {
|
|
414
|
+
Connection: 'Upgrade',
|
|
415
|
+
Upgrade: 'websocket',
|
|
416
|
+
'Sec-WebSocket-Version': '13',
|
|
417
|
+
'Sec-WebSocket-Key': key,
|
|
418
|
+
...headers,
|
|
419
|
+
},
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
let settled = false
|
|
423
|
+
const timer = setTimeout(() => {
|
|
424
|
+
if (settled) return
|
|
425
|
+
settled = true
|
|
426
|
+
req.destroy()
|
|
427
|
+
reject(new Error(`ws connect timeout after ${timeoutMs}ms`))
|
|
428
|
+
}, timeoutMs)
|
|
429
|
+
if (timer.unref) timer.unref()
|
|
430
|
+
|
|
431
|
+
req.on('upgrade', (res, socket, head) => {
|
|
432
|
+
if (settled) {
|
|
433
|
+
socket.destroy()
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
settled = true
|
|
437
|
+
clearTimeout(timer)
|
|
438
|
+
const expect = acceptKey(key)
|
|
439
|
+
if (res.headers['sec-websocket-accept'] !== expect) {
|
|
440
|
+
socket.destroy()
|
|
441
|
+
reject(new Error('ws handshake: bad Sec-WebSocket-Accept'))
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
const conn = new WSConnection(socket, { mask: true, name })
|
|
445
|
+
if (head && head.length > 0) conn.parser.push(head)
|
|
446
|
+
resolve(conn)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
req.on('response', (res) => {
|
|
450
|
+
if (settled) return
|
|
451
|
+
settled = true
|
|
452
|
+
clearTimeout(timer)
|
|
453
|
+
reject(new Error(`ws handshake rejected: HTTP ${res.statusCode}`))
|
|
454
|
+
res.resume()
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
req.on('error', (err) => {
|
|
458
|
+
if (settled) return
|
|
459
|
+
settled = true
|
|
460
|
+
clearTimeout(timer)
|
|
461
|
+
reject(err)
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
req.end()
|
|
465
|
+
})
|
|
466
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,105 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "把另一台电脑变成你 DSH 的远程 Agent 同事:副机本地持久跑任务、独立 Agent 循环,只回传 handoff 摘要与产物清单,成品按需拉取。",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/zbzbhs/dsh-remote-agent.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/zbzbhs/dsh-remote-agent/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/zbzbhs/dsh-remote-agent#readme",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"dsh",
|
|
17
|
+
"deepseek-harness",
|
|
18
|
+
"dsh-plugin",
|
|
19
|
+
"ai-agent",
|
|
20
|
+
"agent",
|
|
21
|
+
"multi-agent",
|
|
22
|
+
"remote-agent",
|
|
23
|
+
"remote-execution",
|
|
24
|
+
"task-automation",
|
|
25
|
+
"self-hosted",
|
|
26
|
+
"zero-dependency",
|
|
27
|
+
"handoff",
|
|
28
|
+
"artifact",
|
|
29
|
+
"subagent-provider"
|
|
30
|
+
],
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./lib/index.js",
|
|
33
|
+
"./client": "./lib/client.js",
|
|
34
|
+
"./hub": "./lib/hub/index.js",
|
|
35
|
+
"./worker": "./lib/node/index.js",
|
|
36
|
+
"./protocol": "./lib/protocol.js",
|
|
37
|
+
"./pairing": "./lib/pairing.js",
|
|
38
|
+
"./relay": "./lib/relay.js",
|
|
39
|
+
"./setup": "./lib/setup.js",
|
|
40
|
+
"./package.json": "./package.json",
|
|
41
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
42
|
+
"./profiles/hub.cordis.yml": "./profiles/hub.cordis.yml",
|
|
43
|
+
"./profiles/worker.cordis.yml": "./profiles/worker.cordis.yml"
|
|
44
|
+
},
|
|
45
|
+
"main": "./lib/index.js",
|
|
46
|
+
"bin": {
|
|
47
|
+
"dsh-remote-relay": "./bin/dsh-remote-relay.js",
|
|
48
|
+
"dsh-remote-setup": "./bin/dsh-remote-setup.js"
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"lib",
|
|
52
|
+
"bin",
|
|
53
|
+
"deploy",
|
|
54
|
+
"docs",
|
|
55
|
+
"profiles",
|
|
56
|
+
"scripts",
|
|
57
|
+
"cordis.patch.yml",
|
|
58
|
+
"README.md",
|
|
59
|
+
"CHANGELOG.md",
|
|
60
|
+
"CONTRIBUTING.md",
|
|
61
|
+
"SECURITY.md",
|
|
62
|
+
"KNOWN-ISSUES.md",
|
|
63
|
+
"LICENSE"
|
|
64
|
+
],
|
|
65
|
+
"dsh": {
|
|
66
|
+
"bundle": {
|
|
67
|
+
"patch": "./cordis.patch.yml"
|
|
68
|
+
},
|
|
69
|
+
"client": {
|
|
70
|
+
"platform": "web",
|
|
71
|
+
"immediately": true,
|
|
72
|
+
"inject": []
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"peerDependencies": {
|
|
76
|
+
"@deepseek-ai/schemastery": "*"
|
|
77
|
+
},
|
|
78
|
+
"peerDependenciesMeta": {
|
|
79
|
+
"@deepseek-ai/schemastery": {
|
|
80
|
+
"optional": true
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"dependencies": {
|
|
84
|
+
"@deepseek-ai/schemastery": "^3.18.4"
|
|
85
|
+
},
|
|
86
|
+
"engines": {
|
|
87
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
88
|
+
},
|
|
89
|
+
"scripts": {
|
|
90
|
+
"test": "node --test test/*.test.js",
|
|
91
|
+
"check:docs": "node scripts/check-doc-drift.mjs",
|
|
92
|
+
"check:packaging": "node scripts/check-packaging.mjs",
|
|
93
|
+
"lint": "eslint .",
|
|
94
|
+
"check": "npm run check:packaging && npm run check:docs && npm run diag:inject && npm test",
|
|
95
|
+
"diag:inject": "node scripts/diag-inject.mjs"
|
|
96
|
+
},
|
|
97
|
+
"devDependencies": {
|
|
98
|
+
"@eslint/js": "10.0.1",
|
|
99
|
+
"eslint": "10.12.0",
|
|
100
|
+
"globals": "17.13.0"
|
|
101
|
+
},
|
|
102
|
+
"publishConfig": {
|
|
103
|
+
"access": "public"
|
|
104
|
+
}
|
|
105
|
+
}
|