@whyshu/argent 0.2.4 → 0.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whyshu/argent",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "description": "Argent 单 bundle(dsh web / DSH Desktop 同一插件):ERP 连接器 + 问述代理 + 登录/续期 + MCP 网关 + 伞伞桌宠(含积分)",
6
6
  "main": "src/index.js",
package/src/config.mjs CHANGED
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * 单独成文件是为了让 **office 运行时** 与 **health 聚合** 共用同一份解析,
5
5
  * 避免"两处各写一遍 enabled/token 判据"(历史事故的根因形态)。
6
+ * 2026-11-07 增 {@link readTunnelConfig}:App 隧道(契约 §2.1)的开关/地址/machineId 也走本文件,
7
+ * ⛔ 不在这里读凭据(账号 JWT 由 tunnel.mjs 复用 account-token 的解析点)。
6
8
  * 纯 node 内建,无宿主 import。
7
9
  */
8
10
  import { readFileSync } from 'node:fs'
@@ -12,6 +14,8 @@ import { join } from 'node:path'
12
14
  export const REPORT_FILE = 'agent-report.json'
13
15
  export const MIN_INTERVAL_SEC = 5
14
16
  export const MAX_INTERVAL_SEC = 300
17
+ /** App 隧道默认地址(契约 §2.1;`ARGENT_TUNNEL_URL` 可覆盖) */
18
+ export const TUNNEL_DEFAULT_URL = 'wss://whyshu.com/api/argent/tunnel'
15
19
 
16
20
  /**
17
21
  * @param {string} dataDir
@@ -45,3 +49,41 @@ export function readConfig(dataDir) {
45
49
  return { ok: false, reason: e?.code === 'ENOENT' ? 'missing' : 'invalid', error: String(e?.message || e) }
46
50
  }
47
51
  }
52
+
53
+ /** 隧道总开关:`ARGENT_TUNNEL=1` 才开(契约 §2.1;默认关)。 */
54
+ export function isTunnelEnabled(env = process.env) {
55
+ return String(env.ARGENT_TUNNEL || '').trim() === '1'
56
+ }
57
+
58
+ /**
59
+ * App 隧道配置(契约 §2.1)——本文件是 agent-report.json 的唯一解析点,隧道也走这里。
60
+ * · 开关:`ARGENT_TUNNEL=1`(默认关)
61
+ * · 地址:`ARGENT_TUNNEL_URL` → {@link TUNNEL_DEFAULT_URL}
62
+ * · machineId:`ARGENT_MACHINE_ID` → `agent-report.json#machineId`(缺失 ⇒ ok:false,不启动)
63
+ * ⛔ 不含任何凭据读取(账号 JWT 由 tunnel.mjs 复用 account-token 的解析点;此处只解析非密字段)。
64
+ * @returns {{enabled:false,ok:false,reason:'disabled'}|{enabled:true,ok:false,reason:string,url:string}|{enabled:true,ok:true,url:string,machineId:string,deviceName:string,serverUrl:string}}
65
+ */
66
+ export function readTunnelConfig({ env = process.env, dataDir } = {}) {
67
+ if (!isTunnelEnabled(env)) return { enabled: false, ok: false, reason: 'disabled' }
68
+ const url = String(env.ARGENT_TUNNEL_URL || '').trim() || TUNNEL_DEFAULT_URL
69
+ try {
70
+ const proto = new URL(url).protocol
71
+ if (proto !== 'ws:' && proto !== 'wss:') return { enabled: true, ok: false, reason: 'bad-url', url }
72
+ } catch {
73
+ return { enabled: true, ok: false, reason: 'bad-url', url }
74
+ }
75
+ let report = {}
76
+ if (dataDir) {
77
+ try { report = JSON.parse(readFileSync(join(dataDir, REPORT_FILE), 'utf8')) || {} } catch { report = {} }
78
+ }
79
+ const machineId = String(env.ARGENT_MACHINE_ID || report.machineId || '').trim()
80
+ if (!machineId) return { enabled: true, ok: false, reason: 'no-machine-id', url }
81
+ return {
82
+ enabled: true,
83
+ ok: true,
84
+ url,
85
+ machineId,
86
+ deviceName: String(report.machineName || '').trim() || os.hostname(),
87
+ serverUrl: String(report.serverUrl || '').trim(),
88
+ }
89
+ }
package/src/index.js CHANGED
@@ -25,6 +25,7 @@ import { acquireLease, heartbeatLease, releaseLease, leaseHolder } from './lease
25
25
  import { resolveAccountToken } from './account-token.mjs'
26
26
  import { installMachineSessionReporter } from './machine-session.mjs'
27
27
  import { syncRuntimeIfNeeded } from './runtime-sync.mjs'
28
+ import { isTunnelEnabled } from './config.mjs'
28
29
 
29
30
  const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
30
31
  const PKG_VERSION = (() => {
@@ -243,5 +244,20 @@ export function apply(ctx) {
243
244
  console.error('[argent] ERP 连接器启动失败(不影响 dsh):', e?.message || e)
244
245
  }
245
246
  }
247
+
248
+ // ⑥ App 真流式隧道(默认关:ARGENT_TUNNEL=1 才启动;Mac 出站到 relay,契约 §2)
249
+ // 放在内核解析之后(用最终 dataDir 读 agent-report.json#machineId);未登录/缺 machineId 只记日志不抛。
250
+ if (isTunnelEnabled()) {
251
+ try {
252
+ const { startTunnel } = await import('./tunnel.mjs')
253
+ const tunnel = startTunnel({ dataDir, version: PKG_VERSION })
254
+ if (tunnel) {
255
+ ctx.on?.('dispose', () => { try { tunnel.stop() } catch { /* 退出清理失败不影响宿主 */ } })
256
+ console.log(`[argent] App 隧道已启动(${tunnel.endpoint},machineId=${tunnel.machineId})`)
257
+ }
258
+ } catch (e) {
259
+ console.error('[argent] 隧道启动失败(不影响其余功能):', e?.message || e)
260
+ }
261
+ }
246
262
  })()
247
263
  }
package/src/tunnel.mjs ADDED
@@ -0,0 +1,848 @@
1
+ /**
2
+ * App ↔ Mac 真流式隧道(Mac 侧;契约 §2/§6/§7,唯一真源:
3
+ * `~/Whyshu/argent-android/docs/真流式-隧道与租户门控-实施契约-2026-11-07.md`)
4
+ *
5
+ * 链路:`App ──WSS──▶ whyshu relay ──WS──▶ 本隧穿 ──WS/HTTP──▶ dsh 127.0.0.1:3080`
6
+ *
7
+ * 本文件只做三件事,**不解析业务帧**:
8
+ * ① 出站 WSS 注册到 relay(Authorization: Bearer 账号 JWT + x-argent-machine-id)
9
+ * ② 代持 dsh 准入 cookie(`/api/argent-state/access` → `/?token=` 换 `dsh-auth-*`,仅内存缓存)
10
+ * ③ 按契约 §2.3 的帧协议透传 http / WS 两种流量(path 白名单 = `/api/` 前缀)
11
+ *
12
+ * 红线(契约 §6):
13
+ * · 隧道默认关闭(`ARGENT_TUNNEL=1` 才启动);只允许 `/api/` 开头的路径,⛔ 不做通用反向代理;
14
+ * · 账号 JWT / dsh 启动 token / dsh cookie **只在内存**,⛔ 不落盘、不打印、不写日志正文;
15
+ * · 审计只记连接/断开、帧数、字节数、错误码;⛔ 不记正文。
16
+ *
17
+ * 依赖:纯 node 内建(node:http/https + 自带的 RFC 6455 客户端)。
18
+ * 为什么不用 `ws` 包:本插件是**已发布 npm 包**(@whyshu/argent),隧道要带自定义
19
+ * Authorization 头,而 Node 内置 WebSocket 不支持自定义头;为此给发布包加一个运行时依赖
20
+ * 不划算 ⇒ 只实现客户端必需的 RFC 6455 子集(文本帧/分片/ping-pong/close,客户端掩码)。
21
+ */
22
+ import { createHash, randomBytes } from 'node:crypto'
23
+ import http from 'node:http'
24
+ import https from 'node:https'
25
+ import { readFileSync } from 'node:fs'
26
+ import { hostname } from 'node:os'
27
+ import { dirname, join } from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+
30
+ import { readTunnelConfig, TUNNEL_DEFAULT_URL } from './config.mjs'
31
+ import { resolveAccountToken } from './account-token.mjs'
32
+
33
+ // ── 常量(契约 §2.1/§2.2/§3.3)─────────────────────────────────────────────
34
+ export const DEFAULT_TUNNEL_URL = TUNNEL_DEFAULT_URL
35
+ export const PING_MS = 25_000
36
+ export const PONG_TIMEOUT_MS = 45_000
37
+ export const RECONNECT_MIN_MS = 1_000
38
+ export const RECONNECT_MAX_MS = 30_000
39
+ export const RECONNECT_JITTER = 0.2
40
+ /** relay ⇄ tunnel 单帧上限(§3.3:单帧 ≤1MB) */
41
+ export const MAX_RELAY_FRAME_BYTES = 1024 * 1024
42
+ /** dsh ⇄ tunnel 单帧上限(会话快照可能偏大;超过 relay 上限的转出帧会被主动断开) */
43
+ export const MAX_DSH_FRAME_BYTES = 4 * 1024 * 1024
44
+ /** http 帧 body 上限(§3.3 同理) */
45
+ export const MAX_HTTP_BODY_BYTES = 1024 * 1024
46
+ /** dsh 一元响应体上限(防御性;正常 API 远小于此) */
47
+ export const MAX_DSH_RESPONSE_BYTES = 8 * 1024 * 1024
48
+
49
+ const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
50
+ const DSH_ACCESS_PATH = '/api/argent-state/access'
51
+
52
+ function readPkgVersion() {
53
+ try { return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')).version || '' } catch { return '' }
54
+ }
55
+
56
+ // ── 纯函数:帧协议(§2.3;字段名逐字对齐,⛔ 不自创)───────────────────────
57
+ /**
58
+ * 契约 §2.3 的帧构造器。所有帧都是 JSON 文本帧。
59
+ * | 方向 | 帧 |
60
+ * | relay→tunnel | {t:"http", id, method, path, headers?, bodyB64?} |
61
+ * | tunnel→relay | {t:"http_res", id, status, headers?, bodyB64?} |
62
+ * | relay→tunnel | {t:"ws_open", id, path, query?} |
63
+ * | tunnel→relay | {t:"ws_ready", id} / {t:"ws_msg", id, data} / {t:"ws_close", id, code?, reason?} |
64
+ * | 双向 | {t:"ping", ts} / {t:"pong", ts} |
65
+ */
66
+ export const FRAME = {
67
+ hello: ({ machineId, deviceName, version }) => ({ t: 'hello', machineId, deviceName, version }),
68
+ ping: (ts) => ({ t: 'ping', ts }),
69
+ pong: (ts) => ({ t: 'pong', ts }),
70
+ httpRes: ({ id, status, headers, bodyB64 }) => ({
71
+ t: 'http_res', id, status,
72
+ ...(headers && Object.keys(headers).length ? { headers } : {}),
73
+ ...(bodyB64 ? { bodyB64 } : {}),
74
+ }),
75
+ wsReady: (id) => ({ t: 'ws_ready', id }),
76
+ wsMsg: (id, data) => ({ t: 'ws_msg', id, data }),
77
+ wsClose: (id, code, reason) => ({
78
+ t: 'ws_close', id,
79
+ ...(Number.isFinite(code) ? { code } : {}),
80
+ ...(reason ? { reason } : {}),
81
+ }),
82
+ }
83
+
84
+ export const encodeFrame = (obj) => JSON.stringify(obj)
85
+
86
+ /** 解析 relay 帧;形状不对一律抛(调用方只记错误码,不记正文)。 */
87
+ export function decodeFrame(text) {
88
+ const v = JSON.parse(String(text))
89
+ if (!v || typeof v !== 'object' || Array.isArray(v) || typeof v.t !== 'string' || !v.t) throw new Error('bad-frame')
90
+ return v
91
+ }
92
+
93
+ /**
94
+ * path 白名单(契约 §2.4/§6.1):只允许 `/api/` 前缀,且**归一化后**仍在 /api/ 下。
95
+ * ⛔ 拒绝绝对 URL、`//host`、越界 `..`、CRLF 注入。
96
+ */
97
+ export function isAllowedPath(path) {
98
+ if (typeof path !== 'string' || path.length === 0 || path.length > 4096) return false
99
+ if (!path.startsWith('/')) return false // 相对路径会被 URL 解析成 /api/…,必须显式拒绝
100
+ if (/[\r\n]/.test(path)) return false
101
+ let u
102
+ try { u = new URL(path, 'http://127.0.0.1') } catch { return false }
103
+ if (u.origin !== 'http://127.0.0.1') return false
104
+ if (!u.pathname.startsWith('/api/')) return false
105
+ let decoded
106
+ try { decoded = decodeURIComponent(u.pathname) } catch { return false }
107
+ if (decoded.split('/').includes('..')) return false
108
+ return true
109
+ }
110
+
111
+ /** 指数退避 1s→30s,±20% 抖动(`random` 可注入 ⇒ 测试可确定)。 */
112
+ export function backoffDelay(attempt, {
113
+ minMs = RECONNECT_MIN_MS, maxMs = RECONNECT_MAX_MS, jitter = RECONNECT_JITTER, random = Math.random,
114
+ } = {}) {
115
+ const n = Math.min(30, Math.max(0, Math.floor(Number(attempt) || 0)))
116
+ const base = Math.min(maxMs, minMs * 2 ** n)
117
+ const delta = base * jitter
118
+ const jittered = base + (random() * 2 - 1) * delta
119
+ return Math.max(0, Math.round(jittered))
120
+ }
121
+
122
+ // ── 纯函数:HTTP 头过滤(代持 cookie;⛔ 不透传 relay 侧 cookie/authorization)──
123
+ const HOP_BY_HOP = new Set([
124
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer',
125
+ 'transfer-encoding', 'upgrade', 'host', 'content-length', 'cookie', 'authorization',
126
+ ])
127
+ const UNSAFE_RESPONSE_HEADERS = new Set([
128
+ 'set-cookie', 'content-length', 'content-encoding', 'transfer-encoding', 'connection', 'keep-alive', 'upgrade',
129
+ ])
130
+
131
+ /** relay 帧里的 headers 只做"去危险头"后转发;cookie 由隧道自己代持。 */
132
+ export function filterRequestHeaders(headers) {
133
+ const out = {}
134
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return out
135
+ for (const [k, v] of Object.entries(headers)) {
136
+ const key = String(k).toLowerCase()
137
+ if (!key || HOP_BY_HOP.has(key) || key.startsWith('sec-websocket-')) continue
138
+ if (v === undefined || v === null) continue
139
+ out[key] = Array.isArray(v) ? v.map(String) : String(v)
140
+ }
141
+ return out
142
+ }
143
+
144
+ function filterResponseHeaders(headers) {
145
+ const out = {}
146
+ try {
147
+ for (const [k, v] of headers) {
148
+ const key = String(k).toLowerCase()
149
+ if (UNSAFE_RESPONSE_HEADERS.has(key)) continue
150
+ out[key] = v
151
+ }
152
+ } catch { /* 无 headers 的桩响应:返回空表 */ }
153
+ return out
154
+ }
155
+
156
+ function readSetCookies(res) {
157
+ try {
158
+ if (typeof res.headers?.getSetCookie === 'function') return res.headers.getSetCookie()
159
+ const single = res.headers?.get?.('set-cookie')
160
+ return single ? [single] : []
161
+ } catch { return [] }
162
+ }
163
+
164
+ const b64Json = (obj) => Buffer.from(JSON.stringify(obj), 'utf8').toString('base64')
165
+
166
+ /** 只保留 origin+pathname,日志里永不带 query(可能含 token)。 */
167
+ function safeEndpoint(raw) {
168
+ try {
169
+ const u = new URL(raw)
170
+ return `${u.origin}${u.pathname}`
171
+ } catch { return 'invalid-url' }
172
+ }
173
+
174
+ const err = (code, detail) => Object.assign(new Error(code), { code, detail })
175
+
176
+ // ── RFC 6455 客户端(只做隧道需要的最小子集)────────────────────────────────
177
+ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
178
+ export const wsAcceptKey = (key) => createHash('sha1').update(String(key) + WS_GUID).digest('base64')
179
+
180
+ /** 客户端→服务端文本帧:FIN+text,必须掩码。 */
181
+ export function encodeClientTextFrame(text) {
182
+ const payload = Buffer.isBuffer(text) ? text : Buffer.from(String(text), 'utf8')
183
+ const len = payload.length
184
+ let headLen
185
+ if (len < 126) headLen = 2
186
+ else if (len < 65536) headLen = 4
187
+ else headLen = 10
188
+ const header = Buffer.alloc(headLen + 4)
189
+ header[0] = 0x81 // FIN + opcode=text
190
+ if (len < 126) header[1] = 0x80 | len
191
+ else if (len < 65536) { header[1] = 0x80 | 126; header.writeUInt16BE(len, 2) } else { header[1] = 0x80 | 127; header.writeUInt32BE(0, 2); header.writeUInt32BE(len, 6) }
192
+ const mask = randomBytes(4)
193
+ mask.copy(header, headLen)
194
+ const out = Buffer.alloc(header.length + len)
195
+ header.copy(out, 0)
196
+ for (let i = 0; i < len; i += 1) out[header.length + i] = payload[i] ^ mask[i & 3]
197
+ return out
198
+ }
199
+
200
+ function clientControlFrame(opcode, payload = Buffer.alloc(0)) {
201
+ const p = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload), 'utf8')
202
+ const len = Math.min(p.length, 125)
203
+ const mask = randomBytes(4)
204
+ const out = Buffer.alloc(6 + len)
205
+ out[0] = 0x80 | (opcode & 0x0f)
206
+ out[1] = 0x80 | len
207
+ mask.copy(out, 2)
208
+ for (let i = 0; i < len; i += 1) out[6 + i] = p[i] ^ mask[i & 3]
209
+ return out
210
+ }
211
+
212
+ const closePayload = (code, reason) => {
213
+ const r = Buffer.from(String(reason || ''), 'utf8').subarray(0, 123)
214
+ const b = Buffer.alloc(2 + r.length)
215
+ b.writeUInt16BE(Number.isFinite(code) ? code : 1000, 0)
216
+ r.copy(b, 2)
217
+ return b
218
+ }
219
+
220
+ /**
221
+ * 增量帧解析器。服务端帧不掩码(掩码也接受);文本/二进制都按 utf8 交给 onText
222
+ * (dsh 与 relay 的业务帧都是 JSON 文本)。超限 ⇒ onError({closeCode:1009})。
223
+ */
224
+ export function createFrameParser({
225
+ onText, onPing, onPong, onClose, onError, maxPayload = MAX_RELAY_FRAME_BYTES,
226
+ } = {}) {
227
+ let buf = Buffer.alloc(0)
228
+ let fragments = null
229
+ let failed = false
230
+
231
+ const fail = (reason, closeCode = 1002) => {
232
+ if (failed) return
233
+ failed = true
234
+ onError?.({ code: 'protocol-error', reason, closeCode })
235
+ }
236
+
237
+ const parseOne = () => {
238
+ if (buf.length < 2) return false
239
+ const b0 = buf[0]
240
+ const b1 = buf[1]
241
+ const fin = (b0 & 0x80) !== 0
242
+ const opcode = b0 & 0x0f
243
+ const masked = (b1 & 0x80) !== 0
244
+ let len = b1 & 0x7f
245
+ let offset = 2
246
+ if ((b0 & 0x70) !== 0) { fail('rsv'); return false }
247
+ if (len === 126) {
248
+ if (buf.length < 4) return false
249
+ len = buf.readUInt16BE(2)
250
+ offset = 4
251
+ } else if (len === 127) {
252
+ if (buf.length < 10) return false
253
+ const big = buf.readBigUInt64BE(2)
254
+ if (big > BigInt(Number.MAX_SAFE_INTEGER)) { fail('length'); return false }
255
+ len = Number(big)
256
+ offset = 10
257
+ }
258
+ if (len > maxPayload) { fail('too-large', 1009); return false }
259
+ const maskOffset = masked ? offset : -1
260
+ const payloadOffset = offset + (masked ? 4 : 0)
261
+ const total = payloadOffset + len
262
+ if (buf.length < total) return false
263
+ let payload = buf.subarray(payloadOffset, total)
264
+ if (masked) {
265
+ const mask = buf.subarray(maskOffset, maskOffset + 4)
266
+ const out = Buffer.allocUnsafe(len)
267
+ for (let i = 0; i < len; i += 1) out[i] = payload[i] ^ mask[i & 3]
268
+ payload = out
269
+ }
270
+ buf = buf.subarray(total)
271
+
272
+ if (opcode >= 0x8) {
273
+ if (!fin || len > 125) { fail('control'); return false }
274
+ if (opcode === 0x9) onPing?.(payload)
275
+ else if (opcode === 0xa) onPong?.(payload)
276
+ else if (opcode === 0x8) {
277
+ let code = 1005
278
+ let reason = ''
279
+ if (payload.length >= 2) { code = payload.readUInt16BE(0); reason = payload.subarray(2).toString('utf8') }
280
+ onClose?.({ code, reason, remote: true })
281
+ } else fail('opcode')
282
+ return true
283
+ }
284
+ if (opcode === 0x1 || opcode === 0x2) {
285
+ if (fragments) { fail('nested-fragment'); return false }
286
+ if (fin) { onText?.(payload.toString('utf8'), opcode); return true }
287
+ fragments = { opcode, chunks: [payload], size: len }
288
+ return true
289
+ }
290
+ if (opcode === 0x0) {
291
+ if (!fragments) { fail('stray-continuation'); return false }
292
+ fragments.chunks.push(payload)
293
+ fragments.size += len
294
+ if (fragments.size > maxPayload) { fail('too-large', 1009); return false }
295
+ if (fin) {
296
+ const whole = Buffer.concat(fragments.chunks)
297
+ fragments = null
298
+ onText?.(whole.toString('utf8'), opcode)
299
+ }
300
+ return true
301
+ }
302
+ fail('opcode')
303
+ return false
304
+ }
305
+
306
+ return {
307
+ push(chunk) {
308
+ if (failed || !chunk || !chunk.length) return
309
+ buf = buf.length ? Buffer.concat([buf, chunk]) : chunk
310
+ while (!failed && parseOne()) { /* 继续吃缓冲区 */ }
311
+ },
312
+ get buffered() { return buf.length },
313
+ }
314
+ }
315
+
316
+ /**
317
+ * 建一条 WS 客户端连接(带自定义请求头)。回调**异步**触发(与真实 socket 一致)。
318
+ * @returns {{send:(text:string)=>boolean, close:(code?:number,reason?:string)=>void, readyState:string}}
319
+ */
320
+ export function connectWebSocket({
321
+ url, headers = {}, maxPayload = MAX_RELAY_FRAME_BYTES, handshakeTimeoutMs = 10_000,
322
+ onOpen, onText, onClose, onError,
323
+ } = {}) {
324
+ const target = new URL(String(url))
325
+ if (target.protocol !== 'ws:' && target.protocol !== 'wss:') throw err('bad-ws-url')
326
+ const secure = target.protocol === 'wss:'
327
+ const key = randomBytes(16).toString('base64')
328
+ let socket = null
329
+ let closed = false
330
+ let closeNotified = false
331
+ let upgraded = false
332
+
333
+ const notifyClose = (info) => {
334
+ if (closeNotified) return
335
+ closeNotified = true
336
+ try { onClose?.(info) } catch { /* 回调失败不影响 socket 清理 */ }
337
+ }
338
+ const notifyError = (info) => { try { onError?.(info) } catch { /* noop */ } }
339
+ const sendRaw = (frameBuf) => {
340
+ if (closed || !socket || socket.destroyed) return false
341
+ try { socket.write(frameBuf); return true } catch { return false }
342
+ }
343
+ const send = (text) => sendRaw(encodeClientTextFrame(text))
344
+ const destroy = () => { try { socket?.destroy() } catch { /* noop */ } }
345
+ const close = (code = 1000, reason = '') => {
346
+ if (closed) return
347
+ sendRaw(clientControlFrame(0x8, closePayload(code, reason)))
348
+ closed = true
349
+ destroy()
350
+ notifyClose({ code, reason, local: true })
351
+ }
352
+
353
+ const parser = createFrameParser({
354
+ maxPayload,
355
+ onText: (text) => onText?.(text),
356
+ onPing: (payload) => sendRaw(clientControlFrame(0xa, payload)),
357
+ onPong: () => {},
358
+ onClose: (info) => {
359
+ sendRaw(clientControlFrame(0x8, closePayload(info.code, info.reason)))
360
+ closed = true
361
+ destroy()
362
+ notifyClose(info)
363
+ },
364
+ onError: (e) => {
365
+ notifyError(e)
366
+ sendRaw(clientControlFrame(0x8, closePayload(e.closeCode || 1002, '')))
367
+ closed = true
368
+ destroy()
369
+ notifyClose({ code: e.closeCode || 1002, reason: e.reason || 'protocol-error' })
370
+ },
371
+ })
372
+
373
+ const reqHeaders = {
374
+ Connection: 'Upgrade',
375
+ Upgrade: 'websocket',
376
+ 'Sec-WebSocket-Version': '13',
377
+ 'Sec-WebSocket-Key': key,
378
+ ...headers,
379
+ }
380
+ const path = `${target.pathname}${target.search}`
381
+ const req = (secure ? https : http).request({
382
+ host: target.hostname,
383
+ port: target.port || (secure ? 443 : 80),
384
+ path,
385
+ method: 'GET',
386
+ headers: reqHeaders,
387
+ })
388
+ const timer = setTimeout(() => req.destroy(new Error('handshake-timeout')), handshakeTimeoutMs)
389
+ timer.unref?.()
390
+
391
+ req.on('upgrade', (res, sock, head) => {
392
+ upgraded = true
393
+ clearTimeout(timer)
394
+ socket = sock
395
+ sock.setNoDelay?.(true)
396
+ sock.setTimeout?.(0)
397
+ if (String(res.headers['sec-websocket-accept'] || '') !== wsAcceptKey(key)) {
398
+ notifyError({ code: 'handshake-accept' })
399
+ closed = true
400
+ destroy()
401
+ notifyClose({ code: 1006, reason: 'handshake-accept' })
402
+ return
403
+ }
404
+ sock.on('data', (chunk) => parser.push(chunk))
405
+ sock.on('error', () => {
406
+ closed = true
407
+ notifyClose({ code: 1006, reason: 'socket-error' })
408
+ })
409
+ sock.on('close', () => {
410
+ closed = true
411
+ notifyClose({ code: 1006, reason: 'socket-closed' })
412
+ })
413
+ if (head && head.length) parser.push(head)
414
+ try { onOpen?.() } catch { /* 回调失败不影响连接 */ }
415
+ })
416
+ req.on('response', (res) => {
417
+ clearTimeout(timer)
418
+ try { res.resume?.() } catch { /* noop */ }
419
+ notifyError({ code: 'handshake-status', status: res.statusCode })
420
+ closed = true
421
+ notifyClose({ code: 1006, reason: 'handshake-status', status: res.statusCode })
422
+ })
423
+ req.on('error', (e) => {
424
+ clearTimeout(timer)
425
+ if (upgraded) return
426
+ notifyError({ code: e?.code || 'handshake-error' })
427
+ closed = true
428
+ notifyClose({ code: 1006, reason: 'handshake-error' })
429
+ })
430
+ req.end()
431
+
432
+ return {
433
+ send,
434
+ close,
435
+ get readyState() { return closed ? 'closed' : (socket ? 'open' : 'connecting') },
436
+ }
437
+ }
438
+
439
+ // ── 隧道主体 ────────────────────────────────────────────────────────────────
440
+ const DEFAULT_DSH_BASE = 'http://127.0.0.1:3080'
441
+
442
+ /** 把 `ws_open` 的 query(字符串/对象)拼到 path 上;⛔ 不放行换行/绝对 URL。 */
443
+ export function buildWsPath(path, query) {
444
+ if (!isAllowedPath(path)) return null
445
+ if (query === undefined || query === null || query === '') return path
446
+ let qs
447
+ if (typeof query === 'string') qs = query.replace(/^[?&]+/, '')
448
+ else if (typeof query === 'object' && !Array.isArray(query)) qs = new URLSearchParams(query).toString()
449
+ else return null
450
+ if (/[\r\n]/.test(qs)) return null
451
+ if (!qs) return path
452
+ const joined = `${path}${path.includes('?') ? '&' : '?'}${qs}`
453
+ return isAllowedPath(joined) ? joined : null
454
+ }
455
+
456
+ /**
457
+ * 启动隧道(`ARGENT_TUNNEL=1` 且 machineId 可解析才真正启动)。
458
+ * @param {object} options 测试/宿主注入点:config、env、dataDir、tokenProvider、fetchImpl、
459
+ * connectWs、timers、random、now、log、dshBase、version、deviceName、pingMs、pongTimeoutMs、
460
+ * reconnect、autoStart
461
+ * @returns {null|{stop:()=>void, getStats:()=>object, machineId:string, endpoint:string}}
462
+ */
463
+ export function startTunnel(options = {}) {
464
+ const env = options.env || process.env
465
+ const logFn = options.log || ((m) => console.log(`[argent] ${m}`))
466
+ const cfg = options.config || readTunnelConfig({ env, dataDir: options.dataDir })
467
+ if (!cfg || cfg.enabled === false) return null
468
+ if (!cfg.ok) {
469
+ const why = cfg.reason === 'no-machine-id'
470
+ ? '缺 machineId(ARGENT_MACHINE_ID 与 agent-report.json#machineId 都没有)'
471
+ : cfg.reason === 'bad-url' ? 'ARGENT_TUNNEL_URL 不是 ws/wss 地址' : String(cfg.reason || 'unknown')
472
+ logFn(`隧道未启动:${why}`)
473
+ return null
474
+ }
475
+
476
+ const timers = options.timers || { setTimeout, clearTimeout }
477
+ const random = options.random || Math.random
478
+ const now = options.now || Date.now
479
+ const fetchImpl = options.fetchImpl || globalThis.fetch
480
+ const connect = options.connectWs || connectWebSocket
481
+ const endpoint = options.url || cfg.url
482
+ const machineId = options.machineId || cfg.machineId
483
+ const deviceName = options.deviceName || cfg.deviceName || hostname()
484
+ const version = options.version || readPkgVersion()
485
+ const dshBase = String(options.dshBase || env.ARGENT_DSH_BASE || DEFAULT_DSH_BASE).replace(/\/+$/, '')
486
+ const dshWsBase = dshBase.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
487
+ const pingMs = options.pingMs ?? PING_MS
488
+ const pongTimeoutMs = options.pongTimeoutMs ?? PONG_TIMEOUT_MS
489
+ const reconnect = {
490
+ minMs: RECONNECT_MIN_MS, maxMs: RECONNECT_MAX_MS, jitter: RECONNECT_JITTER, ...(options.reconnect || {}),
491
+ }
492
+ const tokenProvider = options.tokenProvider || (() => resolveAccountToken({ env }).token || '')
493
+
494
+ const stats = {
495
+ connects: 0, disconnects: 0, framesIn: 0, framesOut: 0, bytesIn: 0, bytesOut: 0,
496
+ errors: 0, http: 0, wsOpen: 0,
497
+ }
498
+ const dsh = { cookie: '' }
499
+ const streams = new Map()
500
+ let stopped = false
501
+ let attempt = 0
502
+ let ws = null
503
+ let reconnectTimer = null
504
+ let pingTimer = null
505
+ let tokenMissingLogged = false
506
+
507
+ const scheduleReconnect = () => {
508
+ if (stopped) return
509
+ const delay = backoffDelay(attempt, { ...reconnect, random })
510
+ attempt += 1
511
+ logFn(`隧道将重连(${delay}ms 后,第 ${attempt} 次)`)
512
+ reconnectTimer = timers.setTimeout(() => { reconnectTimer = null; void connectOnce() }, delay)
513
+ reconnectTimer?.unref?.()
514
+ }
515
+
516
+ const stopHeartbeat = () => {
517
+ if (pingTimer) { timers.clearTimeout(pingTimer); pingTimer = null }
518
+ }
519
+
520
+ const send = (obj) => {
521
+ if (!ws) return false
522
+ let text
523
+ try { text = encodeFrame(obj) } catch { return false }
524
+ const ok = ws.send(text)
525
+ if (ok) {
526
+ stats.framesOut += 1
527
+ stats.bytesOut += Buffer.byteLength(text, 'utf8')
528
+ }
529
+ return ok
530
+ }
531
+
532
+ // ── dsh 准入 cookie(仅内存;401/403 重换一次)──────────────────────────
533
+ const acquireCookie = async ({ refresh = false } = {}) => {
534
+ if (!refresh && dsh.cookie) return dsh.cookie
535
+ let access
536
+ try {
537
+ access = await fetchImpl(`${dshBase}${DSH_ACCESS_PATH}`, {
538
+ headers: { accept: 'application/json' },
539
+ signal: AbortSignal.timeout(5000),
540
+ })
541
+ } catch { throw err('access-unreachable') }
542
+ let body = null
543
+ try { body = await access.json() } catch { body = null }
544
+ if (!access.ok || !body || body.ok !== true || !body.token) throw err('access-unavailable', `HTTP ${access.status}`)
545
+ let res
546
+ try {
547
+ res = await fetchImpl(`${dshBase}/?token=${encodeURIComponent(String(body.token))}`, {
548
+ redirect: 'manual',
549
+ signal: AbortSignal.timeout(5000),
550
+ })
551
+ } catch { throw err('cookie-unreachable') }
552
+ const cookies = readSetCookies(res)
553
+ .map((c) => String(c).split(';')[0].trim())
554
+ .filter((c) => c.startsWith('dsh-auth-'))
555
+ if (!cookies.length) throw err('cookie-missing', `HTTP ${res.status}`)
556
+ dsh.cookie = cookies.join('; ')
557
+ return dsh.cookie
558
+ }
559
+
560
+ const dshRequest = async ({ method, path, headers, body, retry = true }) => {
561
+ const build = async (cookie) => {
562
+ const h = filterRequestHeaders(headers)
563
+ h.cookie = cookie
564
+ const init = { method, headers: h, redirect: 'manual', signal: AbortSignal.timeout(30_000) }
565
+ if (body && body.length && method !== 'GET' && method !== 'HEAD') init.body = body
566
+ return { init, headers: h }
567
+ }
568
+ const first = await build(await acquireCookie())
569
+ let res = await fetchImpl(`${dshBase}${path}`, first.init)
570
+ if (retry && (res.status === 401 || res.status === 403)) {
571
+ const retried = await build(await acquireCookie({ refresh: true }))
572
+ res = await fetchImpl(`${dshBase}${path}`, retried.init)
573
+ }
574
+ const buf = Buffer.from(await res.arrayBuffer())
575
+ if (buf.length > MAX_DSH_RESPONSE_BYTES) throw err('response-too-large')
576
+ return { status: res.status, headers: filterResponseHeaders(res.headers), body: buf }
577
+ }
578
+
579
+ // ── 帧处理 ────────────────────────────────────────────────────────────────
580
+ const handleHttp = async (frame) => {
581
+ stats.http += 1
582
+ const id = frame.id
583
+ if (!isAllowedPath(frame.path)) {
584
+ send(FRAME.httpRes({ id, status: 403, headers: { 'content-type': 'application/json' }, bodyB64: b64Json({ ok: false, error: 'path-not-allowed' }) }))
585
+ return
586
+ }
587
+ let body = Buffer.alloc(0)
588
+ if (frame.bodyB64) {
589
+ try { body = Buffer.from(String(frame.bodyB64), 'base64') } catch { body = null }
590
+ if (!body) {
591
+ send(FRAME.httpRes({ id, status: 400, headers: { 'content-type': 'application/json' }, bodyB64: b64Json({ ok: false, error: 'bad-body' }) }))
592
+ return
593
+ }
594
+ if (body.length > MAX_HTTP_BODY_BYTES) {
595
+ send(FRAME.httpRes({ id, status: 413, headers: { 'content-type': 'application/json' }, bodyB64: b64Json({ ok: false, error: 'body-too-large' }) }))
596
+ return
597
+ }
598
+ }
599
+ const method = String(frame.method || 'GET').toUpperCase()
600
+ if (!/^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)$/.test(method)) {
601
+ send(FRAME.httpRes({ id, status: 405, headers: { 'content-type': 'application/json' }, bodyB64: b64Json({ ok: false, error: 'method-not-allowed' }) }))
602
+ return
603
+ }
604
+ try {
605
+ const res = await dshRequest({ method, path: frame.path, headers: frame.headers, body })
606
+ send(FRAME.httpRes({
607
+ id, status: res.status, headers: res.headers,
608
+ bodyB64: res.body.length ? res.body.toString('base64') : undefined,
609
+ }))
610
+ } catch (e) {
611
+ stats.errors += 1
612
+ const reason = String(e?.code || 'dsh-error')
613
+ logFn(`隧道代理 dsh 失败:${reason}`)
614
+ send(FRAME.httpRes({
615
+ id, status: 502, headers: { 'content-type': 'application/json' },
616
+ bodyB64: b64Json({ ok: false, error: 'dsh-unavailable', reason }),
617
+ }))
618
+ }
619
+ }
620
+
621
+ const closeStream = (id, code = 1000, reason = '') => {
622
+ const stream = streams.get(id)
623
+ if (!stream) return
624
+ stream.closed = true
625
+ streams.delete(id)
626
+ try { stream.handle?.close(code, reason) } catch { /* noop */ }
627
+ }
628
+
629
+ const closeAllStreams = (code = 1001, reason = 'relay-closed') => {
630
+ for (const id of [...streams.keys()]) closeStream(id, code, reason)
631
+ }
632
+
633
+ const connectDshWs = ({ path, cookie, stream }) => new Promise((resolve, reject) => {
634
+ let open = false
635
+ let settled = false
636
+ let handle = null
637
+ const fail = (e) => { if (settled) return; settled = true; reject(e) }
638
+ const done = () => { if (settled) return; settled = true; open = true; resolve(handle) }
639
+ try {
640
+ handle = connect({
641
+ url: `${dshWsBase}${path}`,
642
+ headers: { cookie, host: new URL(dshBase).host },
643
+ maxPayload: MAX_DSH_FRAME_BYTES,
644
+ onOpen: () => done(),
645
+ onText: (text) => {
646
+ if (!open || stream.closed) return
647
+ if (Buffer.byteLength(text, 'utf8') > MAX_RELAY_FRAME_BYTES) {
648
+ stats.errors += 1
649
+ send(FRAME.wsClose(stream.id, 1009, 'message-too-large'))
650
+ closeStream(stream.id, 1009, 'message-too-large')
651
+ return
652
+ }
653
+ send(FRAME.wsMsg(stream.id, text))
654
+ },
655
+ onClose: (info) => {
656
+ if (!open) { fail({ code: 'closed-before-open', status: info?.status }); return }
657
+ if (stream.closed) return
658
+ stream.closed = true
659
+ streams.delete(stream.id)
660
+ send(FRAME.wsClose(stream.id, info?.code, info?.reason))
661
+ },
662
+ onError: (e) => {
663
+ if (!open) { fail(e); return }
664
+ stats.errors += 1
665
+ logFn(`隧道 dsh 流错误:${e?.code || 'dsh-ws-error'}`)
666
+ },
667
+ })
668
+ } catch (e) { fail(e) }
669
+ })
670
+
671
+ const openDshWs = async (path, stream) => {
672
+ let cookie = await acquireCookie()
673
+ for (let i = 0; i < 2; i += 1) {
674
+ try {
675
+ return await connectDshWs({ path, cookie, stream })
676
+ } catch (e) {
677
+ if (i === 0 && e?.code === 'handshake-status' && (e.status === 401 || e.status === 403)) {
678
+ cookie = await acquireCookie({ refresh: true })
679
+ continue
680
+ }
681
+ throw e
682
+ }
683
+ }
684
+ throw err('dsh-ws-unavailable')
685
+ }
686
+
687
+ const handleWsOpen = async (frame) => {
688
+ stats.wsOpen += 1
689
+ const id = frame.id
690
+ if (!isAllowedPath(frame.path)) {
691
+ send(FRAME.wsClose(id, 1008, 'path-not-allowed'))
692
+ return
693
+ }
694
+ const path = buildWsPath(frame.path, frame.query)
695
+ if (!path) {
696
+ send(FRAME.wsClose(id, 1008, 'bad-query'))
697
+ return
698
+ }
699
+ if (streams.has(id)) closeStream(id, 1000, 'replaced')
700
+ const stream = { id, handle: null, closed: false }
701
+ streams.set(id, stream)
702
+ try {
703
+ const handle = await openDshWs(path, stream)
704
+ stream.handle = handle
705
+ if (stream.closed) { try { handle.close(1000, 'cancelled') } catch { /* noop */ }; return }
706
+ send(FRAME.wsReady(id))
707
+ } catch (e) {
708
+ stats.errors += 1
709
+ logFn(`隧道打开 dsh 流失败:${e?.code || 'dsh-ws-error'}`)
710
+ const code = String(e?.code || 'dsh-ws-error').slice(0, 60)
711
+ if (streams.get(id) === stream) { stream.closed = true; streams.delete(id) }
712
+ send(FRAME.wsClose(id, 1011, code))
713
+ }
714
+ }
715
+
716
+ const handleWsMsg = (frame) => {
717
+ const stream = streams.get(frame.id)
718
+ if (!stream || stream.closed || !stream.handle) { stats.errors += 1; return }
719
+ const data = typeof frame.data === 'string' ? frame.data : JSON.stringify(frame.data ?? '')
720
+ if (!stream.handle.send(data)) stats.errors += 1
721
+ }
722
+
723
+ const handleWsClose = (frame) => {
724
+ closeStream(frame.id, Number.isFinite(frame.code) ? frame.code : 1000, String(frame.reason || ''))
725
+ }
726
+
727
+ const handleRelayText = (text, conn) => {
728
+ const size = Buffer.byteLength(String(text ?? ''), 'utf8')
729
+ stats.framesIn += 1
730
+ stats.bytesIn += size
731
+ if (size > MAX_RELAY_FRAME_BYTES) {
732
+ stats.errors += 1
733
+ logFn('隧道收到超大帧,断开连接')
734
+ try { conn.handle?.close(1009, 'frame-too-large') } catch { /* noop */ }
735
+ return
736
+ }
737
+ let frame
738
+ try { frame = decodeFrame(text) } catch { stats.errors += 1; logFn('隧道收到非法帧(已忽略)'); return }
739
+ switch (frame.t) {
740
+ case 'ping': send(FRAME.pong(frame.ts)); break
741
+ case 'pong': conn.lastPong = now(); break
742
+ case 'http': void handleHttp(frame); break
743
+ case 'ws_open': void handleWsOpen(frame); break
744
+ case 'ws_msg': handleWsMsg(frame); break
745
+ case 'ws_close': handleWsClose(frame); break
746
+ default: stats.errors += 1; logFn(`隧道收到未知帧类型(已忽略):${String(frame.t).slice(0, 24)}`)
747
+ }
748
+ }
749
+
750
+ const startHeartbeat = (conn) => {
751
+ stopHeartbeat()
752
+ const tick = () => {
753
+ if (stopped || conn.closed || !conn.open) return
754
+ if (now() - conn.lastPong > pongTimeoutMs) {
755
+ stats.errors += 1
756
+ logFn(`隧道心跳超时(${pongTimeoutMs}ms 无 pong),重连`)
757
+ try { conn.handle?.close(1001, 'pong-timeout') } catch { /* noop */ }
758
+ return
759
+ }
760
+ send(FRAME.ping(now()))
761
+ pingTimer = timers.setTimeout(tick, pingMs)
762
+ pingTimer?.unref?.()
763
+ }
764
+ pingTimer = timers.setTimeout(tick, pingMs)
765
+ pingTimer?.unref?.()
766
+ }
767
+
768
+ const handleRelayClose = (conn, info) => {
769
+ conn.closed = true
770
+ stopHeartbeat()
771
+ closeAllStreams(1001, 'relay-closed')
772
+ if (ws === conn.handle) ws = null
773
+ if (conn.open) {
774
+ stats.disconnects += 1
775
+ logFn(`隧道连接已断开(code=${info?.code ?? 1006})`)
776
+ }
777
+ if (stopped) return
778
+ scheduleReconnect()
779
+ }
780
+
781
+ const connectOnce = async () => {
782
+ if (stopped) return
783
+ let token = ''
784
+ try { token = await tokenProvider() } catch { token = '' }
785
+ if (!token) {
786
+ if (!tokenMissingLogged) {
787
+ logFn('隧道未连接:未登录问述账号(WHYSHU_API_KEY 缺失),将持续重试')
788
+ tokenMissingLogged = true
789
+ }
790
+ scheduleReconnect()
791
+ return
792
+ }
793
+ tokenMissingLogged = false
794
+ const conn = { handle: null, open: false, closed: false, lastPong: 0 }
795
+ let handle
796
+ try {
797
+ handle = connect({
798
+ url: endpoint,
799
+ headers: { Authorization: `Bearer ${token}`, 'x-argent-machine-id': machineId },
800
+ maxPayload: MAX_RELAY_FRAME_BYTES,
801
+ onOpen: () => {
802
+ if (stopped || conn.closed) return
803
+ conn.open = true
804
+ attempt = 0
805
+ stats.connects += 1
806
+ conn.lastPong = now()
807
+ logFn(`隧道已注册(machineId=${machineId},${safeEndpoint(endpoint)})`)
808
+ send(FRAME.hello({ machineId, deviceName, version }))
809
+ startHeartbeat(conn)
810
+ },
811
+ onText: (text) => { if (ws === conn.handle) handleRelayText(text, conn) },
812
+ onClose: (info) => handleRelayClose(conn, info),
813
+ onError: (e) => {
814
+ stats.errors += 1
815
+ logFn(`隧道连接错误:${e?.code || 'unknown'}${e?.status ? `(HTTP ${e.status})` : ''}`)
816
+ },
817
+ })
818
+ } catch (e) {
819
+ stats.errors += 1
820
+ logFn(`隧道连接失败:${e?.code || 'connect-error'}`)
821
+ scheduleReconnect()
822
+ return
823
+ }
824
+ conn.handle = handle
825
+ ws = handle
826
+ }
827
+
828
+ const stop = () => {
829
+ if (stopped) return
830
+ stopped = true
831
+ if (reconnectTimer) { timers.clearTimeout(reconnectTimer); reconnectTimer = null }
832
+ stopHeartbeat()
833
+ closeAllStreams(1000, 'shutdown')
834
+ const closing = ws
835
+ ws = null
836
+ try { closing?.close(1000, 'shutdown') } catch { /* noop */ }
837
+ logFn('隧道已停止')
838
+ }
839
+
840
+ if (options.autoStart !== false) void connectOnce()
841
+
842
+ return {
843
+ stop,
844
+ getStats: () => ({ ...stats }),
845
+ machineId,
846
+ endpoint: safeEndpoint(endpoint),
847
+ }
848
+ }