@mrrisega/dsh-remote 0.7.9 → 0.7.10-beta.3

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.
@@ -0,0 +1,479 @@
1
+ /**
2
+ * push-reporter — 把电脑端的事件(任务完成 / 需要拍板 / 出错)上报到 SaaS,由服务端决定
3
+ * 是否推送(规则化审批在服务端做、审计在服务端落),本模块只负责**可靠、克制地把事件送到**。
4
+ *
5
+ * 设计边界(每一条都重要)
6
+ * ---------------------------------------------------------------
7
+ * 1. **默认关闭**:只有服务端明确"推送已开启且本账号有订阅"时才启动;任何一步失败都静默降级。
8
+ * 2. **绝不阻塞主链路**:不 await 在 bridge 的事件循环里,所有网络动作都在自己的队列里串行做;
9
+ * 异常一律 catch,绝不冒泡到隧道帧循环(隧道挂 = 全用户断连)。
10
+ * 3. **隐私最小化**:默认只上报"标题 + 一句结果摘要",**不上报完整命令/代码**;
11
+ * 只有 `DSH_PUSH_DETAIL=1` 时才带 `approval.summary`(由服务端规则引擎判断风险)。
12
+ * 4. **幂等**:同一事件(同一 event_id)重试不会让服务端推两次(服务端也有幂等,这里是第一道)。
13
+ * 5. **有界**:队列最多 50 条,满了丢最旧并计数;网络失败指数退避,最多 3 次。
14
+ *
15
+ * 跨仓库契约(企业端 `/api/notify/event`,字段不可改)
16
+ * ---------------------------------------------------------------
17
+ * POST {apiBase}/api/notify/event Authorization: Bearer <device JWT>
18
+ * {
19
+ * event_id, device_id, kind, at,
20
+ * session: { id, title? },
21
+ * title, body,
22
+ * approval?: { request_id, summary?, options: [{value,label}] }
23
+ * }
24
+ * 响应 200 { ok, notified, suppressed, reason? }
25
+ */
26
+
27
+ import { createEventSubscriber, NODE_KINDS, TURN_END_REASONS } from "./dsh-events.mjs";
28
+
29
+ /** 上报体里 kind 的闭集(与服务端契约一致)。 */
30
+ export const NOTIFY_KINDS = Object.freeze({
31
+ TURN_DONE: "turn_done",
32
+ TURN_ERROR: "turn_error",
33
+ TURN_BLOCKED: "turn_blocked",
34
+ APPROVAL_REQUEST: "approval_request",
35
+ });
36
+
37
+ /** 队列上限 / 重试退避 / 单次请求超时。 */
38
+ export const MAX_QUEUE = 50;
39
+ export const RETRY_DELAYS_MS = Object.freeze([5_000, 15_000, 45_000]);
40
+ export const REQUEST_TIMEOUT_MS = 10_000;
41
+ export const MAX_EVENT_ID_LEN = 128;
42
+ /** 上报正文硬上限(服务端也会截,这里先截,省流量)。 */
43
+ export const MAX_BODY_LEN = 200;
44
+
45
+ /** 结果摘要里不该出现的、像"命令"的东西(默认模式下再兜一层)。 */
46
+ const COMMAND_HINT = /(^|\s)(rm|sudo|npm|pnpm|yarn|git|docker|kubectl|curl|wget|ssh|scp|chmod|chown|systemctl|psql|mysql)\s/i;
47
+
48
+ /**
49
+ * 事件节点 → 上报体。返回 `null` 表示"这类节点不上报"(例如 gap/fault/简报)。
50
+ *
51
+ * 导出是为了单测能直接钉住映射(不依赖真实订阅器)。
52
+ *
53
+ * @param {object} node dsh-events 的分类节点
54
+ * @param {{deviceId:string, detail?:boolean, sessionTitle?:(sessionId:string)=>string, now?:()=>number}} opts
55
+ * @returns {object|null}
56
+ */
57
+ export function toNotifyPayload(node, opts = {}) {
58
+ if (!node || typeof node !== "object") return null;
59
+ const deviceId = String(opts.deviceId || "");
60
+ if (!deviceId) return null;
61
+ const now = typeof opts.now === "function" ? opts.now : () => Date.now();
62
+ const at = Number.isFinite(node.at) ? node.at : now();
63
+ const sessionId = typeof node.sessionId === "string" ? node.sessionId : "";
64
+ const sessionTitleRaw = typeof opts.sessionTitle === "function" ? opts.sessionTitle(sessionId) : "";
65
+ const sessionTitle = truncate(String(sessionTitleRaw || ""), 60);
66
+ const session = { id: sessionId };
67
+ if (sessionTitle) session.title = sessionTitle;
68
+
69
+ if (node.kind === NODE_KINDS.APPROVAL_REQUEST || node.kind === NODE_KINDS.PLAN_REVIEW
70
+ || node.kind === NODE_KINDS.USER_QUESTION) {
71
+ const requestId = String(node.eventId || "");
72
+ if (!requestId) return null;
73
+ const isPlan = node.kind === NODE_KINDS.PLAN_REVIEW;
74
+ const isQuestion = node.kind === NODE_KINDS.USER_QUESTION;
75
+ const tool = String(node.toolName || "").trim();
76
+ // 摘要只给"能看懂在等什么"的最小信息;完整命令默认不外发(隐私)。
77
+ const rawSummary = String(node.reason || tool || "").trim();
78
+ // ★ 默认模式一律只给"动作名",完整命令只有 detail 模式才外发(隐私边界)。
79
+ const action = opts.detail ? truncate(rawSummary, MAX_BODY_LEN) : safeSummary(tool, rawSummary);
80
+ const title = isQuestion ? "等你回答" : isPlan ? "等你批准计划" : "需要你拍板";
81
+ const body = isQuestion
82
+ ? "agent 提问:需要你选一个选项才能继续"
83
+ : isPlan
84
+ ? "agent 在等你批准计划"
85
+ : (action ? `需要你拍板:${action}` : "需要你拍板");
86
+ return {
87
+ event_id: makeEventId(deviceId, "approval", requestId),
88
+ device_id: deviceId,
89
+ kind: NOTIFY_KINDS.APPROVAL_REQUEST,
90
+ at,
91
+ session,
92
+ title,
93
+ body,
94
+ approval: {
95
+ request_id: requestId,
96
+ // 默认模式下 summary 只保留"动作名",不带完整命令(detail 模式才带原文)
97
+ summary: action,
98
+ options: normalizeOptions(node.options)
99
+ }
100
+ };
101
+ }
102
+
103
+ if (node.kind === NODE_KINDS.TURN_END) {
104
+ const reason = String(node.reason || "unknown");
105
+ const known = TURN_END_REASONS.includes(reason);
106
+ let kind = NOTIFY_KINDS.TURN_DONE;
107
+ let title = "任务完成";
108
+ let body = sessionTitle ? `${sessionTitle} 跑完了` : "任务跑完了";
109
+ if (reason === "error" || reason === "max-tokens") {
110
+ kind = NOTIFY_KINDS.TURN_ERROR;
111
+ title = reason === "max-tokens" ? "任务被截断" : "任务出错";
112
+ body = sessionTitle ? `${sessionTitle} ${title}` : title;
113
+ } else if (reason === "blocked") {
114
+ kind = NOTIFY_KINDS.TURN_BLOCKED;
115
+ title = "任务被拦下";
116
+ body = sessionTitle ? `${sessionTitle} 被拦下了` : "任务被拦下了,需要你看一下";
117
+ } else if (!known) {
118
+ /* 未知 reason:当成完成上报,但不假装"成功"——正文如实写原因 */
119
+ body = `${sessionTitle || "任务"} 结束了(${reason})`;
120
+ }
121
+ const key = `turn:${sessionId}:${node.turn ?? "?"}:${node.seq ?? "?"}:${reason}`;
122
+ return {
123
+ event_id: makeEventId(deviceId, "turn", key),
124
+ device_id: deviceId,
125
+ kind,
126
+ at,
127
+ session,
128
+ title,
129
+ body: truncate(body, MAX_BODY_LEN)
130
+ };
131
+ }
132
+
133
+ if (node.kind === NODE_KINDS.SESSION_ERROR) {
134
+ const message = truncate(String(node.message || "任务出错"), MAX_BODY_LEN);
135
+ return {
136
+ event_id: makeEventId(deviceId, "error", `${sessionId}:${node.code || message}:${at}`),
137
+ device_id: deviceId,
138
+ kind: NOTIFY_KINDS.TURN_ERROR,
139
+ at,
140
+ session,
141
+ title: "任务出错",
142
+ body: message
143
+ };
144
+ }
145
+
146
+ // gap / fault / digest / quota / membership / expired:本模块不负责(微信通道/其他模块在做)
147
+ return null;
148
+ }
149
+
150
+ function normalizeOptions(raw) {
151
+ const out = [];
152
+ if (Array.isArray(raw)) {
153
+ for (const o of raw) {
154
+ if (!o || typeof o !== "object") continue;
155
+ const value = typeof o.value === "string" ? o.value : "";
156
+ const label = typeof o.label === "string" ? o.label : "";
157
+ if (!value || !label) continue;
158
+ out.push({ value, label: truncate(label, 20) });
159
+ if (out.length >= 4) break;
160
+ }
161
+ }
162
+ return out.length ? out : [
163
+ { value: "allowed-once", label: "允许一次" },
164
+ { value: "rejected", label: "拒绝" }
165
+ ];
166
+ }
167
+
168
+ /** 默认模式下的安全摘要:只留动作名,绝不把完整命令行外发。 */
169
+ function safeSummary(tool, fallback) {
170
+ const t = String(tool || "").trim();
171
+ if (t) return truncate(t, 40);
172
+ const s = String(fallback || "").trim();
173
+ if (!s) return "需要你拍板";
174
+ if (COMMAND_HINT.test(s)) {
175
+ const m = s.split(/\s+/)[0];
176
+ return truncate(m, 40);
177
+ }
178
+ return truncate(s, 40);
179
+ }
180
+
181
+ /** 服务端契约:event_id 只允许 [A-Za-z0-9._:-](其它字符 400)。 */
182
+ const EVENT_ID_SAFE = /[^A-Za-z0-9._:-]/g;
183
+
184
+ function makeEventId(deviceId, scope, key) {
185
+ const raw = `${deviceId}:${scope}:${String(key || "").slice(0, 80)}`;
186
+ // 同 key 同一毫秒重复 = 同一事件;加上 at 会被去重逻辑判成不同事件,故不加
187
+ const id = raw.replace(EVENT_ID_SAFE, "_"); // 空格/问号/斜杠等一律换成下划线
188
+ return id.length > MAX_EVENT_ID_LEN ? id.slice(0, MAX_EVENT_ID_LEN) : id;
189
+ }
190
+
191
+ function truncate(s, n) {
192
+ const str = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
193
+ if (str.length <= n) return str;
194
+ return str.slice(0, Math.max(0, n - 1)) + "…";
195
+ }
196
+
197
+ /**
198
+ * 创建上报器。不自动启动(见 `start()`)。
199
+ *
200
+ * @param {{
201
+ * apiBase: string,
202
+ * tokenProvider: () => (string|Promise<string>),
203
+ * deviceId: string,
204
+ * upstream: () => string,
205
+ * cookie?: () => string,
206
+ * detail?: boolean,
207
+ * sessionTitle?: (sessionId:string)=>string,
208
+ * log?: (level:string, message:string, meta?:object)=>void,
209
+ * fetchImpl?: typeof fetch,
210
+ * WebSocketImpl?: any,
211
+ * now?: () => number,
212
+ * maxQueue?: number,
213
+ * retryDelaysMs?: number[],
214
+ * startOnInit?: boolean,
215
+ * }} options
216
+ */
217
+ export function createPushReporter(options = {}) {
218
+ const apiBase = String(options.apiBase || "").replace(/\/+$/, "");
219
+ const deviceId = String(options.deviceId || "");
220
+ const tokenProvider = typeof options.tokenProvider === "function" ? options.tokenProvider : () => "";
221
+ const fetchImpl = typeof options.fetchImpl === "function" ? options.fetchImpl : globalThis.fetch;
222
+ const now = typeof options.now === "function" ? options.now : () => Date.now();
223
+ const log = typeof options.log === "function" ? options.log : () => {};
224
+ const detail = options.detail === true;
225
+ const maxQueue = Number.isFinite(options.maxQueue) ? options.maxQueue : MAX_QUEUE;
226
+ const retryDelaysMs = Array.isArray(options.retryDelaysMs) ? options.retryDelaysMs : RETRY_DELAYS_MS;
227
+
228
+ /** @type {{payload:object, tries:number}[]} */
229
+ const queue = [];
230
+ const seen = new Map(); // event_id -> at(有界)
231
+ const stats = { started: false, reason: "", enqueued: 0, sent: 0, dropped: 0, duplicates: 0, failed: 0, lastError: "" };
232
+
233
+ let subscriber = null;
234
+ let flushing = false;
235
+ let stopped = false;
236
+ let flushTimer = null;
237
+
238
+ function remember(id) {
239
+ if (seen.has(id)) return false;
240
+ seen.set(id, now());
241
+ // 有界:超过 500 条淘汰最旧(Map 保插入序)
242
+ if (seen.size > 500) {
243
+ const first = seen.keys().next();
244
+ if (!first.done) seen.delete(first.value);
245
+ }
246
+ return true;
247
+ }
248
+
249
+ function enqueue(payload) {
250
+ if (!payload) return false;
251
+ if (!remember(payload.event_id)) {
252
+ stats.duplicates += 1;
253
+ return false;
254
+ }
255
+ if (queue.length >= maxQueue) {
256
+ queue.shift();
257
+ stats.dropped += 1;
258
+ log("warn", "[push] 上报队列已满,丢弃最旧一条");
259
+ }
260
+ queue.push({ payload, tries: 0 });
261
+ stats.enqueued += 1;
262
+ scheduleFlush(0);
263
+ return true;
264
+ }
265
+
266
+ function scheduleFlush(delayMs) {
267
+ if (stopped) return;
268
+ if (flushTimer) return;
269
+ flushTimer = setTimeout(() => {
270
+ flushTimer = null;
271
+ void flush();
272
+ }, Math.max(0, delayMs));
273
+ // 不阻止进程退出(bridge 退出时最多等 1s,见 stop())
274
+ if (flushTimer && typeof flushTimer.unref === "function") flushTimer.unref();
275
+ }
276
+
277
+ async function flush() {
278
+ if (stopped && queue.length === 0) return;
279
+ if (flushing) return;
280
+ flushing = true;
281
+ try {
282
+ while (queue.length > 0) {
283
+ const item = queue[0];
284
+ const token = await Promise.resolve(tokenProvider()).catch(() => "");
285
+ if (!token) {
286
+ stats.lastError = "no_token";
287
+ log("warn", "[push] 没有可用 token,稍后再上报");
288
+ scheduleFlush(retryDelaysMs[0] || 5_000);
289
+ return;
290
+ }
291
+ const result = await postOnce(token, item.payload, apiBase, fetchImpl);
292
+ if (result.ok) {
293
+ queue.shift();
294
+ stats.sent += 1;
295
+ continue;
296
+ }
297
+ if (result.authFailed) {
298
+ // token 失效:丢这批并等下一次 device-login 刷新(不无限重试)
299
+ queue.shift();
300
+ stats.failed += 1;
301
+ stats.lastError = "auth_failed";
302
+ log("warn", "[push] device token 失效,丢弃本批上报(等下次登录刷新)");
303
+ continue;
304
+ }
305
+ if (result.clientError) {
306
+ // 契约错误(400/404/422):重试没意义
307
+ queue.shift();
308
+ stats.failed += 1;
309
+ stats.lastError = `http_${result.status}`;
310
+ log("warn", `[push] 服务端拒绝上报(HTTP ${result.status}),丢弃该条`);
311
+ continue;
312
+ }
313
+ item.tries += 1;
314
+ stats.failed += 1;
315
+ stats.lastError = result.error || `http_${result.status}`;
316
+ if (item.tries > retryDelaysMs.length) {
317
+ queue.shift();
318
+ log("warn", `[push] 上报重试 ${item.tries - 1} 次仍失败,丢弃该条`);
319
+ continue;
320
+ }
321
+ const delay = retryDelaysMs[item.tries - 1] || 5_000;
322
+ scheduleFlush(delay);
323
+ return;
324
+ }
325
+ } catch (e) {
326
+ stats.lastError = String(e && e.message ? e.message : e);
327
+ log("warn", `[push] 上报循环异常(已忽略):${stats.lastError}`);
328
+ } finally {
329
+ flushing = false;
330
+ }
331
+ }
332
+
333
+ /**
334
+ * 启动门禁(两层,都是服务端说了算 —— 客户端开关只是省流量,不是安全边界):
335
+ * ① `GET /api/me` → `notify.enabled`:服务端总开关(push_enabled)。老服务端没有该字段 = 关闭。
336
+ * ② `GET /api/push/public-key` → `subscribed`:本账号有没有订阅;没有就不必起事件订阅器。
337
+ */
338
+ async function probeEnabled(token) {
339
+ try {
340
+ const me = await fetchWithTimeout(`${apiBase}/api/me`, {
341
+ method: "GET",
342
+ headers: { authorization: `Bearer ${token}` }
343
+ }, fetchImpl, REQUEST_TIMEOUT_MS);
344
+ if (!me) return { ok: false, reason: "no_response" };
345
+ if (me.status === 401 || me.status === 403) return { ok: false, reason: "auth_failed" };
346
+ if (me.status !== 200) return { ok: false, reason: `http_${me.status}` };
347
+ const meBody = await safeJson(me);
348
+ const enabled = !!(meBody && meBody.notify && meBody.notify.enabled === true);
349
+ if (!enabled) return { ok: false, reason: "disabled" };
350
+
351
+ const sub = await fetchWithTimeout(`${apiBase}/api/push/public-key`, {
352
+ method: "GET",
353
+ headers: { authorization: `Bearer ${token}` }
354
+ }, fetchImpl, REQUEST_TIMEOUT_MS);
355
+ if (!sub) return { ok: false, reason: "no_response" };
356
+ if (sub.status === 200) {
357
+ const subBody = await safeJson(sub);
358
+ if (subBody && subBody.subscribed === true) return { ok: true };
359
+ return { ok: false, reason: "no_subscription" };
360
+ }
361
+ // 老服务端没有推送端点:当作没有订阅(不启动)
362
+ return { ok: false, reason: `http_${sub.status}` };
363
+ } catch (e) {
364
+ return { ok: false, reason: String(e && e.message ? e.message : e) };
365
+ }
366
+ }
367
+
368
+ return {
369
+ /** 启动:先问服务端"推送开了吗、本账号订阅了吗",再开始订阅事件。 */
370
+ async start() {
371
+ if (stats.started || stopped) return stats;
372
+ if (!apiBase) { stats.reason = "no_api_base"; return stats; }
373
+ if (!deviceId) { stats.reason = "no_device"; return stats; }
374
+ const token = await Promise.resolve(tokenProvider()).catch(() => "");
375
+ if (!token) { stats.reason = "no_token"; return stats; }
376
+ const gate = await probeEnabled(token);
377
+ if (!gate.ok) { stats.reason = gate.reason || "disabled"; log("info", `[push] 未启动上报:${stats.reason}`); return stats; }
378
+ try {
379
+ subscriber = createEventSubscriber({
380
+ upstream: typeof options.upstream === "function" ? options.upstream : String(options.upstream || ""),
381
+ cookie: typeof options.cookie === "function" ? options.cookie : (options.cookie || ""),
382
+ follow: true,
383
+ log: (level, message) => log(level === "error" ? "warn" : level, `[push/events] ${message}`),
384
+ now,
385
+ WebSocketImpl: options.WebSocketImpl,
386
+ fetchImpl
387
+ });
388
+ subscriber.on("event", (node) => {
389
+ try {
390
+ const payload = toNotifyPayload(node, {
391
+ deviceId,
392
+ detail,
393
+ sessionTitle: options.sessionTitle,
394
+ now
395
+ });
396
+ if (payload) enqueue(payload);
397
+ } catch (e) {
398
+ log("warn", `[push] 事件归一化失败(已忽略):${e && e.message ? e.message : e}`);
399
+ }
400
+ });
401
+ subscriber.on("auth-error", () => log("warn", "[push] 事件订阅鉴权失败,等 harness cookie 自愈"));
402
+ subscriber.on("gap", () => log("warn", "[push] 事件订阅断线窗口,期间通知可能漏发"));
403
+ subscriber.start();
404
+ stats.started = true;
405
+ stats.reason = "started";
406
+ } catch (e) {
407
+ stats.reason = `subscribe_failed:${e && e.message ? e.message : e}`;
408
+ log("warn", `[push] 订阅器启动失败(已忽略):${stats.reason}`);
409
+ }
410
+ return stats;
411
+ },
412
+
413
+ /** 停止:最多 1s 尽力 flush,然后释放订阅器。 */
414
+ async stop() {
415
+ stopped = true;
416
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
417
+ const deadline = now() + 1_000;
418
+ while (queue.length > 0 && now() < deadline) {
419
+ await flush();
420
+ if (queue.length > 0) await new Promise((r) => setTimeout(r, 50));
421
+ }
422
+ try { if (subscriber && typeof subscriber.close === "function") subscriber.close(); } catch { /* 忽略 */ }
423
+ subscriber = null;
424
+ stats.started = false;
425
+ },
426
+
427
+ /** 只读状态(面板/诊断用;不包含任何事件内容)。 */
428
+ status() {
429
+ return { ...stats, queued: queue.length };
430
+ },
431
+
432
+ /** 仅供单测:直接塞一条(绕过订阅器)。 */
433
+ _enqueue(payload) { return enqueue(payload); },
434
+ /** 仅供单测:立即跑一次发送循环。 */
435
+ _flush: flush,
436
+ /** 仅供单测:把映射函数暴露出去,避免测试重复实现。 */
437
+ _toNotifyPayload: toNotifyPayload
438
+ };
439
+ }
440
+
441
+ async function postOnce(token, payload, apiBase, fetchImpl) {
442
+ try {
443
+ const r = await fetchWithTimeout(`${apiBase}/api/notify/event`, {
444
+ method: "POST",
445
+ headers: {
446
+ authorization: `Bearer ${token}`,
447
+ "content-type": "application/json"
448
+ },
449
+ body: JSON.stringify(payload)
450
+ }, fetchImpl, REQUEST_TIMEOUT_MS);
451
+ if (!r) return { ok: false, error: "no_response" };
452
+ if (r.status === 200) {
453
+ const body = await safeJson(r);
454
+ if (body && body.ok === true) return { ok: true };
455
+ return { ok: false, clientError: true, status: r.status };
456
+ }
457
+ if (r.status === 401 || r.status === 403) return { ok: false, authFailed: true, status: r.status };
458
+ if (r.status >= 400 && r.status < 500) return { ok: false, clientError: true, status: r.status };
459
+ return { ok: false, status: r.status };
460
+ } catch (e) {
461
+ return { ok: false, error: String(e && e.message ? e.message : e) };
462
+ }
463
+ }
464
+
465
+ async function fetchWithTimeout(url, init, fetchImpl, timeoutMs) {
466
+ if (typeof fetchImpl !== "function") return null;
467
+ const ctl = typeof AbortController === "function" ? new AbortController() : null;
468
+ const timer = ctl ? setTimeout(() => { try { ctl.abort(); } catch { /* 忽略 */ } }, timeoutMs) : null;
469
+ if (timer && typeof timer.unref === "function") timer.unref();
470
+ try {
471
+ return await fetchImpl(url, ctl ? { ...init, signal: ctl.signal } : init);
472
+ } finally {
473
+ if (timer) clearTimeout(timer);
474
+ }
475
+ }
476
+
477
+ async function safeJson(r) {
478
+ try { return await r.json(); } catch { return null; }
479
+ }
@@ -0,0 +1,90 @@
1
+ // E2EE 死会话拒绝的**可观测字段**回归 · bridge 侧(0.7.10 / W1-T3)。
2
+ //
3
+ // 背景(2026-10 生产复盘):手机端与电脑之间的 WS 升级被拒里 **71.8%** 是
4
+ // `e2ee_session_locked`(死会话),涉 163 用户;单个设备一天能撞 8997 次。
5
+ // 0.7.3+ 已闭环(行为回归见 e2ee-shim-selfheal / mobile-adapter-resume 两个用例):
6
+ // · 手机侧:清内存会话 + 清交接单 + 每标签页最多一次受控 reload,徽标写「加密会话已失效」;
7
+ // · bridge 侧:拒绝时回 `retryable:true, action:"rehandshake"`,手机可自动重握手。
8
+ //
9
+ // 缺的那一半是「闭环之后还剩多少」:这个比例以前只在 nginx/router 侧算得出来,
10
+ // 客户端自己的支持包答不出。本文件锁死新增的可观测记账:
11
+ // ① 拒绝次数进 `.dsh-bridge-state.json`(节流 ≥10s,不加定时器、不额外写盘);
12
+ // ② 协议行为一个字节都不变(拒绝帧仍带 retryable/action)。
13
+ // 插件侧「复制诊断信息」带出这一行的用例见 packages/dsh-remote-web/test/e2ee-reject-diag.test.mjs。
14
+ import assert from "node:assert/strict";
15
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import test from "node:test";
19
+
20
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), "dsh-e2ee-rej-"));
21
+ // 环境必须在 import dsh-bridge 之前定好(模块级常量在 import 时求值)
22
+ process.env.DSH_BRIDGE_CONFIG = path.join(tmpDir, ".dsh-config.json");
23
+ process.env.DSH_BRIDGE_UPSTREAM = "http://127.0.0.1:1"; // 本用例只走拒绝分支,不连上游
24
+ process.env.DSH_BRIDGE_TUNNEL_URL = "ws://127.0.0.1:1";
25
+ const bridge = await import("../dsh-bridge.mjs");
26
+ const e2eeMod = await import("../e2ee-client.mjs");
27
+ const STATE_FILE = path.join(tmpDir, ".dsh-bridge-state.json");
28
+
29
+ test.after(async () => {
30
+ try { bridge.__setE2eeForTest(new e2eeMod.E2eeService({ enabled: false })); } catch { /* 忽略 */ }
31
+ try { await rm(tmpDir, { recursive: true, force: true }); } catch { /* 忽略 */ }
32
+ });
33
+
34
+ function spy() {
35
+ const frames = [];
36
+ return { frames, send: (obj) => { frames.push(obj); return true; } };
37
+ }
38
+ const readState = async () => {
39
+ try { return JSON.parse(await readFile(STATE_FILE, "utf8")) || {}; } catch { return {}; }
40
+ };
41
+
42
+ test("★ e2ee 会话失效拒绝:协议不变(retryable+rehandshake)且记进状态文件", async () => {
43
+ bridge.__setE2eeForTest(new e2eeMod.E2eeService({
44
+ enabled: true, mk: Buffer.alloc(32, 7), profile: "pbkdf2-sha256-600k", salt: "salt-abc", epoch: 0
45
+ }));
46
+ try {
47
+ const before = bridge.__e2eeRejectStatsForTest().locked;
48
+ const { frames, send } = spy();
49
+ const sessId = e2eeMod.newSessId();
50
+ await bridge.handleWsOpen(send, {
51
+ id: "e2ee-locked",
52
+ type: "ws-open",
53
+ path: `/ws-echo?e2ee=${sessId}&w=0123456789abcdef`,
54
+ headers: {}
55
+ });
56
+ assert.equal(frames.length, 1);
57
+ assert.equal(frames[0].ok, false);
58
+ assert.equal(frames[0].code, 1008);
59
+ assert.equal(frames[0].retryable, true, "★ 必须告诉手机这是可自动重试的(0.7.3+ 闭环的一半)");
60
+ assert.equal(frames[0].action, "rehandshake", "★ 让手机自动重握手,而不是弹「请重新解锁」");
61
+ assert.match(String(frames[0].reason || ""), /e2ee_session_locked/);
62
+
63
+ assert.equal(bridge.__e2eeRejectStatsForTest().locked, before + 1, "拒绝必须记账(可观测字段的来源)");
64
+ // 首次拒绝立即落盘(之后 10s 内节流合并)—— 支持包里不必等心跳就能看到
65
+ const state = await readState();
66
+ assert.equal(state.e2ee_ws_reject_locked, before + 1, "状态文件应带 e2ee_ws_reject_locked");
67
+ assert.ok(Number(state.e2ee_ws_reject_at) > 0, "应有最近一次拒绝时间(判断是否还在持续撞)");
68
+ } finally {
69
+ bridge.__setE2eeForTest(new e2eeMod.E2eeService({ enabled: false }));
70
+ }
71
+ });
72
+
73
+ test("★ e2ee 未启用(明文回退)的拒绝单独计数,不与死会话混在一起", async () => {
74
+ bridge.__setE2eeForTest(new e2eeMod.E2eeService({ enabled: false }));
75
+ try {
76
+ const before = bridge.__e2eeRejectStatsForTest().disabled;
77
+ const { frames, send } = spy();
78
+ await bridge.handleWsOpen(send, {
79
+ id: "e2ee-off",
80
+ type: "ws-open",
81
+ path: `/ws-echo?e2ee=${e2eeMod.newSessId()}&w=0123456789abcdef`,
82
+ headers: {}
83
+ });
84
+ assert.equal(frames[0].ok, false);
85
+ assert.match(String(frames[0].reason || ""), /e2ee_disabled/);
86
+ assert.equal(bridge.__e2eeRejectStatsForTest().disabled, before + 1, "e2ee_disabled 必须单独计数");
87
+ } finally {
88
+ bridge.__setE2eeForTest(new e2eeMod.E2eeService({ enabled: false }));
89
+ }
90
+ });