@mrrisega/dsh-remote 0.7.5 → 0.7.6-beta.10

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.
@@ -82,6 +82,7 @@ import WebSocket from "ws";
82
82
  import fs from "node:fs";
83
83
  import os from "node:os";
84
84
  import path from "node:path";
85
+ import net from "node:net";
85
86
  import { execSync, spawnSync } from "node:child_process";
86
87
  import { randomBytes, generateKeyPairSync, createHash } from "node:crypto";
87
88
  import { pathToFileURL, fileURLToPath } from "node:url";
@@ -557,6 +558,19 @@ let upstreamRefreshInflight = null;
557
558
  * 重新解析上游地址(带身份校验;显式 DSH_BRIDGE_UPSTREAM 时是恒等操作)。
558
559
  * 上游端口变了(Desktop 换端口 / dsh web 重启到别的端口)时,靠它自愈而不必重启 bridge。
559
560
  */
561
+ /** 轻量探测上游是否活着(refreshUpstream 用;4xx/5xx 也算活着)。 */
562
+ async function upstreamReachableForRefresh(url, timeoutMs = 800) {
563
+ try {
564
+ const ac = new AbortController();
565
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
566
+ await fetch(String(url).replace(/\/+$/, "") + "/", { signal: ac.signal });
567
+ clearTimeout(timer);
568
+ return true;
569
+ } catch {
570
+ return false;
571
+ }
572
+ }
573
+
560
574
  async function refreshUpstream(reason = "") {
561
575
  if (upstreamRefreshInflight) return upstreamRefreshInflight;
562
576
  const now = Date.now();
@@ -564,7 +578,22 @@ async function refreshUpstream(reason = "") {
564
578
  upstreamRefreshedAt = now;
565
579
  upstreamRefreshInflight = (async () => {
566
580
  try {
567
- const found = await discoverUpstream({ relayDir: RELAY_DIR });
581
+ let found = null;
582
+ // 🔴 2026-10-25(设备掉线实测):升级前装的 launchd plist 可能带着安装时的旧端口,
583
+ // 旧版 discoverUpstream 对「显式 env」**不探测直接采信** → 桥永久连旧端口、每次请求
584
+ // upstream_unreachable。这里先验证 env:死了就临时忽略它,按插件写的 .dsh-upstream/探测
585
+ // 重新发现;发现成功后清掉失效 env,后续都不再被它挡。
586
+ const envUrl = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().replace(/\/+$/, "");
587
+ if (envUrl && !(await upstreamReachableForRefresh(envUrl))) {
588
+ const env2 = { ...process.env };
589
+ delete env2.DSH_BRIDGE_UPSTREAM;
590
+ found = await discoverUpstream({ relayDir: RELAY_DIR, env: env2 });
591
+ if (found && found.url && found.source !== "fallback") {
592
+ delete process.env.DSH_BRIDGE_UPSTREAM;
593
+ console.log(`[bridge] 旧上游 ${envUrl} 已不可达 → 改用 ${found.url}(来源 ${found.source})`);
594
+ }
595
+ }
596
+ if (!found) found = await discoverUpstream({ relayDir: RELAY_DIR });
568
597
  if (found.url && found.url !== UPSTREAM) {
569
598
  console.log(`[bridge] 上游地址切换: ${UPSTREAM} → ${found.url}(来源 ${found.source}${reason ? `,${reason}` : ""})`);
570
599
  UPSTREAM = found.url;
@@ -630,7 +659,10 @@ export function dcSend(dc, obj) {
630
659
  * · 每轮发送前先排空控制车道,并检查 `ws.bufferedAmount`:超过高水位就停手等它排空。
631
660
  * 于是控制帧前面最多只有「高水位 + 一片」的待发字节 —— 默认 64KB + 64KB,手机 1Mbps
632
661
  * 下 ≈1s,8Mbps 下 ≈0.13s,远小于 router 的 20s,也满足「大响应传输中 1s 内应答」目标。
633
- * · 小帧(交互消息 / SSE 块)单独一条车道,优先于大帧的下一片(交互不被整帧传输拖住)。
662
+ * · **数据帧严格 FIFO**(2026-10-24 修):早期实现让小帧(交互/SSE 块)插到大帧下一片之前,
663
+ * 这在同一 WS 流里是**乱序** —— 生产表现为手机远程页「历史加载失败:session event stream
664
+ * emitted an entry before its opening cursor」。现在只有一个数据队列,大帧按片就地推进,
665
+ * 绝不因大小/类型被后来的帧超越;只有控制帧(不承载有序数据)保留插队权。
634
666
  * · 阈值 / 分片大小可用环境变量调(见 tunnelBulkOptionsFromEnv),弱网部署可再调低。
635
667
  */
636
668
  export const TUNNEL_BULK_HWM_DEFAULT = 64 * 1024; // 发送缓冲高水位(字节)
@@ -679,13 +711,19 @@ export function makePrioritySender({
679
711
  let pumping = false;
680
712
  let scheduled = false;
681
713
  let timerPending = false;
682
- const smallQ = []; // 小帧(可整帧直发):交互消息 / SSE 块 —— 优先于大帧的下一片
683
- const bigQ = []; // 大帧:{ frames: string[], i }
684
- const stats = { controlSent: 0, smallSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0 };
685
-
686
- function enqueueBulk(text) {
714
+ // 🔴 2026-10-24 事故修复:数据帧**必须严格 FIFO**。
715
+ // 旧实现分「小帧车道 / 大帧车道」,小帧会插到大帧下一片之前 —— 这违反了同一 WS 流里
716
+ // 消息的顺序语义,生产表现为手机远程页「历史加载失败:session event stream emitted an
717
+ // entry before its opening cursor(gateway/internal)」:事件流请求/响应被乱序送达,
718
+ // 客户端的游标推进到了前面,重连时服务端按该游标回放就触发 cursor 错乱。
719
+ // 现在:控制帧仍可插队(它们是协议控制帧,不承载有序数据);**数据帧只有一个队列**,
720
+ // 大帧按片就地推进,绝不因大小/类型被后来的帧超越。
721
+ const dataQ = []; // { frames: string[], i: number };frames.length===1 = 未分片小帧
722
+ const stats = { controlSent: 0, dataSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0 };
723
+
724
+ function enqueueData(text) {
687
725
  if (text.length <= chunkSize) {
688
- smallQ.push(text);
726
+ dataQ.push({ frames: [text], i: 0 });
689
727
  return;
690
728
  }
691
729
  const cid = `c${++chunkSeq}`;
@@ -695,7 +733,7 @@ export function makePrioritySender({
695
733
  frames[i] = JSON.stringify({ __chunk: { id: cid, n, i, data: text.slice(i * chunkSize, (i + 1) * chunkSize) } });
696
734
  }
697
735
  stats.bigFrames += 1;
698
- bigQ.push({ frames, i: 0 });
736
+ dataQ.push({ frames, i: 0 });
699
737
  }
700
738
 
701
739
  function scheduleNext(waitMs) {
@@ -715,7 +753,7 @@ export function makePrioritySender({
715
753
  if (pumping || closed) return;
716
754
  pumping = true;
717
755
  let yieldMode = ""; // ""=没有剩余,"schedule"=让出事件循环,"poll"=等缓冲排空
718
- let budget = 32; // 一轮最多发多少条小帧
756
+ let budget = 64; // 一轮最多发多少条未分片小帧(大帧每轮只发一片)
719
757
  try {
720
758
  for (;;) {
721
759
  // ① 控制车道永远先走:哪怕大帧正传着 16MB,控制帧也在**下一轮**第一时间出去。
@@ -724,31 +762,31 @@ export function makePrioritySender({
724
762
  try { rawSend(frame); stats.controlSent += 1; } catch { closed = true; }
725
763
  }
726
764
  if (closed) { yieldMode = ""; break; }
727
- // ② 小帧优先于大帧的下一片:大包传输期间,交互消息 / SSE 块不该排在它后面。
728
- if (smallQ.length > 0 && budget > 0) {
729
- try { rawSend(smallQ.shift()); stats.smallSent += 1; } catch { closed = true; break; }
765
+ if (dataQ.length === 0) { yieldMode = ""; break; }
766
+ if (budget <= 0) { yieldMode = "schedule"; break; }
767
+ const head = dataQ[0];
768
+ if (head.frames.length === 1) {
769
+ // 未分片小帧:可连着多发几条,但绝不越过队头(保证 FIFO)
770
+ try { rawSend(head.frames[0]); stats.dataSent += 1; } catch { closed = true; break; }
771
+ dataQ.shift();
730
772
  budget -= 1;
731
773
  continue; // 回环顶先查控制车道
732
774
  }
733
- if (smallQ.length > 0) { yieldMode = "schedule"; break; } // 一轮预算用完
734
- if (bigQ.length === 0) { yieldMode = ""; break; }
735
- // ③ 背压:已入队字节超过高水位 → 停手等排空,别把控制帧埋进几十秒的队列里。
775
+ // 队头是大帧:每轮只推一片,片间让出事件循环(控制帧/入站帧不被饿死),顺序不变
736
776
  let pending = 0;
737
777
  try { pending = Number(bufferedAmount()) || 0; } catch { pending = 0; }
738
778
  if (pending > bulkHighWater) { stats.waits += 1; yieldMode = "poll"; break; }
739
- const item = bigQ[0];
740
- try { rawSend(item.frames[item.i]); stats.bigChunks += 1; } catch { closed = true; break; }
741
- item.i += 1;
742
- if (item.i >= item.frames.length) bigQ.shift();
743
- yieldMode = "schedule"; // 大帧:一片一让,给入站帧 / 控制帧 / 小帧机会
779
+ try { rawSend(head.frames[head.i]); stats.dataSent += 1; stats.bigChunks += 1; } catch { closed = true; break; }
780
+ head.i += 1;
781
+ if (head.i >= head.frames.length) dataQ.shift();
782
+ yieldMode = "schedule";
744
783
  break;
745
784
  }
746
785
  } finally {
747
786
  pumping = false;
748
787
  if (closed) {
749
788
  controlQ.length = 0;
750
- smallQ.length = 0;
751
- bigQ.length = 0;
789
+ dataQ.length = 0;
752
790
  } else if (yieldMode) {
753
791
  scheduleNext(yieldMode === "poll" ? pollMs : 0);
754
792
  }
@@ -760,7 +798,7 @@ export function makePrioritySender({
760
798
  try { text = JSON.stringify(obj); } catch { return false; }
761
799
  if (closed) { stats.dropped += 1; return false; }
762
800
  if (isControlFrame(obj)) controlQ.push(text);
763
- else enqueueBulk(text);
801
+ else enqueueData(text);
764
802
  pump();
765
803
  return true;
766
804
  }
@@ -769,8 +807,7 @@ export function makePrioritySender({
769
807
  function close() {
770
808
  closed = true;
771
809
  controlQ.length = 0;
772
- smallQ.length = 0;
773
- bigQ.length = 0;
810
+ dataQ.length = 0;
774
811
  }
775
812
 
776
813
  return {
@@ -778,11 +815,11 @@ export function makePrioritySender({
778
815
  close,
779
816
  stats: () => ({
780
817
  ...stats,
781
- queued: smallQ.length + bigQ.length,
818
+ queued: dataQ.length,
782
819
  controlQueued: controlQ.length,
783
820
  closed
784
821
  }),
785
- hasBacklog: () => smallQ.length > 0 || bigQ.length > 0 || controlQ.length > 0
822
+ hasBacklog: () => dataQ.length > 0 || controlQ.length > 0
786
823
  };
787
824
  }
788
825
 
@@ -2302,7 +2339,7 @@ async function initE2ee(token) {
2302
2339
  const storeEnabled = process.env.DSH_BRIDGE_E2EE_SESSIONS !== "0";
2303
2340
  const store = e2ee.attachStore(storeDir, { enabled: storeEnabled });
2304
2341
  if (store.restored > 0) {
2305
- console.log(`[bridge] 🔓 已恢复 ${store.restored} 个 E2EE 会话(加密落盘;手机重启/桥重启后免重新解锁)`);
2342
+ console.log(`[bridge] 🔓 已恢复 ${store.restored} 个 E2EE 会话(加密落盘;安全策略=v2:全部标记「需重新握手」,不复用旧计数)`);
2306
2343
  }
2307
2344
  if (store.discarded) {
2308
2345
  // 丢弃 = 手机手里的旧票必然对不上:后续 ws-open/HTTP 会带 retryable 的 rehandshake 信号,
@@ -2723,9 +2760,109 @@ async function main() {
2723
2760
  console.log("[bridge] 单实例锁检查降级(取不到进程命令行或锁不可写)——照常启动;若出现 code=4000 互踢,本实例会自动让位。");
2724
2761
  }
2725
2762
  process.on("exit", releaseInstanceLock);
2763
+ // 桌面端插件「暂存更新」自动安装(应用退出后生效;见 applyPendingPluginUpdate 注释)
2764
+ startPendingPluginUpdateWatcher();
2726
2765
  return runTunnel();
2727
2766
  }
2728
2767
 
2768
+ // ── 桌面端插件「暫存安装」落地(2026-10-24)────────────────────────────────────────────
2769
+ // dsh-setup 在桌面应用运行时无法直接改写它的 profile(会触发应用退出/重启),改为暂存到
2770
+ // <relayDir>/pending-plugin/ 并写 marker;bridge 常驻、检测到应用退出后自动原子替换插件目录,
2771
+ // 用户下次打开应用即加载新版 —— 这样「一键更新」对桌面端也能真正生效(不再需要终端)。
2772
+ const PENDING_PLUGIN_MARKER = path.join(RELAY_DIR, ".plugin-update-pending.json");
2773
+ const PENDING_PLUGIN_REQUIRED = ["package.json", "lib/index.js", "lib/client.js", "cordis.patch.yml"];
2774
+ const PENDING_PLUGIN_DISABLED = process.env.DSH_BRIDGE_PENDING_UPDATE === "0";
2775
+
2776
+ /** upstream 端口是否在监听(= DeepSeek Harness 桌面端在运行)。TCP 判断,跨平台。 */
2777
+ export function probeUpstreamListening({ host = "127.0.0.1", port, timeoutMs = 400 } = {}) {
2778
+ return new Promise((resolve) => {
2779
+ if (!port) return resolve(false);
2780
+ const sock = net.connect({ host, port });
2781
+ let done = false;
2782
+ const finish = (v) => { if (done) return; done = true; try { sock.destroy(); } catch { /* 忽略 */ } resolve(v); };
2783
+ sock.setTimeout(timeoutMs, () => finish(false));
2784
+ sock.once("connect", () => finish(true));
2785
+ sock.once("error", () => finish(false));
2786
+ });
2787
+ }
2788
+ function upstreamPortOf(url) {
2789
+ try { const u = new URL(String(url)); return Number(u.port) || (u.protocol === "https:" ? 443 : 80); } catch { return 0; }
2790
+ }
2791
+
2792
+ /**
2793
+ * 应用暂存的插件更新(仅在桌面应用不在运行时)。原子替换:staging → rename,失败保留旧版。
2794
+ * @returns {{applied:boolean, reason?:string, version?:string, error?:string}}
2795
+ */
2796
+ export async function applyPendingPluginUpdate({ relayDir = RELAY_DIR, isAppRunning, now = Date.now(), log = console } = {}) {
2797
+ const markerPath = path.join(relayDir, ".plugin-update-pending.json");
2798
+ let marker;
2799
+ try { marker = JSON.parse(fs.readFileSync(markerPath, "utf8")); } catch { return { applied: false, reason: "no_pending" }; }
2800
+ const stageDir = String(marker.stageDir || "");
2801
+ const profileDir = String(marker.profileDir || "");
2802
+ const name = String(marker.pluginDirName || "dsh-remote-web-plugin");
2803
+ if (!stageDir || !profileDir || !fs.existsSync(stageDir)) {
2804
+ try { fs.rmSync(markerPath, { force: true }); } catch { /* 忽略 */ }
2805
+ return { applied: false, reason: "staging_missing" };
2806
+ }
2807
+ const running = typeof isAppRunning === "function"
2808
+ ? await isAppRunning()
2809
+ : await probeUpstreamListening({ port: upstreamPortOf(UPSTREAM) });
2810
+ if (running) return { applied: false, reason: "app_running" };
2811
+ const dest = path.join(profileDir, name);
2812
+ const stage = `${dest}.staging-${process.pid}`;
2813
+ const backup = `${dest}.backup-${process.pid}`;
2814
+ try {
2815
+ fs.rmSync(stage, { recursive: true, force: true });
2816
+ fs.cpSync(stageDir, stage, { recursive: true });
2817
+ for (const rel of PENDING_PLUGIN_REQUIRED) {
2818
+ if (!fs.existsSync(path.join(stage, rel))) throw new Error(`暂存包缺少 ${rel}`);
2819
+ }
2820
+ fs.rmSync(backup, { recursive: true, force: true });
2821
+ const had = fs.existsSync(dest);
2822
+ if (had) fs.renameSync(dest, backup);
2823
+ try {
2824
+ fs.renameSync(stage, dest);
2825
+ } catch (e) {
2826
+ if (had) { try { fs.renameSync(backup, dest); } catch { /* 忽略 */ } }
2827
+ throw e;
2828
+ }
2829
+ if (had) { try { fs.rmSync(backup, { recursive: true, force: true }); } catch { /* 忽略 */ } }
2830
+ try {
2831
+ fs.writeFileSync(
2832
+ path.join(relayDir, ".plugin-update-applied.json"),
2833
+ JSON.stringify({ version: marker.version, appliedAt: now, profileDir }, null, 2) + "\n"
2834
+ );
2835
+ } catch { /* 忽略 */ }
2836
+ try { fs.rmSync(markerPath, { force: true }); fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* 忽略 */ }
2837
+ log.info?.(`[bridge] ✅ 插件新版本 ${marker.version} 已自动安装到 ${profileDir}(桌面应用退出后);重新打开应用即生效`);
2838
+ return { applied: true, version: marker.version };
2839
+ } catch (e) {
2840
+ try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* 忽略 */ }
2841
+ log.warn?.(`[bridge] 插件暂存安装失败(保留旧版可回退):${e && e.message ? e.message : e}`);
2842
+ return { applied: false, reason: "apply_failed", error: String((e && e.message) || e) };
2843
+ }
2844
+ }
2845
+
2846
+ function startPendingPluginUpdateWatcher() {
2847
+ if (PENDING_PLUGIN_DISABLED) return null;
2848
+ let closedStreak = 0;
2849
+ const tick = async () => {
2850
+ try {
2851
+ if (!fs.existsSync(PENDING_PLUGIN_MARKER)) { closedStreak = 0; return; }
2852
+ const running = await probeUpstreamListening({ port: upstreamPortOf(UPSTREAM) });
2853
+ if (running) { closedStreak = 0; return; }
2854
+ closedStreak += 1;
2855
+ if (closedStreak < 2) return; // 连续两次(≈30s)判定不在运行才动手,避开退出/重启瞬间
2856
+ await applyPendingPluginUpdate({});
2857
+ closedStreak = 0;
2858
+ } catch { /* 绝不影响隧道业务 */ }
2859
+ };
2860
+ const t = setInterval(tick, 15_000);
2861
+ t.unref?.();
2862
+ void tick();
2863
+ return t;
2864
+ }
2865
+
2729
2866
  // test hooks:镜像页加载看门狗的内部状态(给用例断言"后续请求会撤掉它",见 test/bridge-html-inject.test.mjs)
2730
2867
  export function __mirrorLoadWatchState() { return mirrorLoadWatch ? { ...mirrorLoadWatch } : null; }
2731
2868
  export function __armMirrorLoadWatch(path) { armMirrorLoadWatch(path); }
@@ -792,14 +792,17 @@ export class E2eeService {
792
792
  profile: String(row.profile || this.profile),
793
793
  epoch: Number(row.epoch) || 0
794
794
  });
795
- session.verified = row.verified === true;
795
+ // ★ 2026-10-24 安全升级:从磁盘恢复的会话**一律标记未验证**,必须重新握手后才接受数据流。
796
+ // 原因:落盘的收发计数可能落后于手机侧(桥在 flush 与退出之间重启过),直接复用旧计数
797
+ // 会导致 nonce 重放/解不开 → 生产「无法解密」。宁可让手机自动重握手一次,也不冒错帧风险。
798
+ session.verified = false;
799
+ session.restoredNeedsHandshake = true;
796
800
  session.createdAt = Number(row.createdAt) || usedAt;
797
801
  session.usedAt = usedAt;
798
802
  this.sessions.set(sessId, session);
799
- const guards = { p2b: new SequenceGuard(), b2p: new SequenceGuard() };
800
- if (Number.isInteger(row.p2b)) guards.p2b.last = row.p2b;
801
- if (Number.isInteger(row.b2p)) guards.b2p.last = row.b2p;
802
- this.ctrlCounters.set(sessId, guards);
803
+ // ★ 计数同样**不恢复**:恢复的会话未验证,重新握手会从 0 重新计数;
804
+ // 继承旧计数只会在下一次探测时误判「nonce 重放」。
805
+ this.ctrlCounters.set(sessId, { p2b: new SequenceGuard(), b2p: new SequenceGuard() });
803
806
  } catch { /* 单条坏 → 跳过,不影响其它会话 */ }
804
807
  }
805
808
  this.prune();
@@ -932,7 +935,9 @@ export class E2eeService {
932
935
  // 会话持久化原语(2026-10-23;方案/信任边界见 E2eeService.attachStore 上方长注释)
933
936
  // ============================================================
934
937
 
935
- export const SESSION_STORE_VERSION = 1;
938
+ // 2026-10-24 → 2:0.7.5 的 v1 库恢复后直接复用旧计数,生产出现「nonce 重放/无法解密」。
939
+ // 升级到 v2 时**旧库一律作废**(restore 里版本不符会 clearSessionStore),手机重新握手一次。
940
+ export const SESSION_STORE_VERSION = 2;
936
941
  export const SESSION_STORE_FILE = ".e2ee-sessions.enc";
937
942
  /** 会话库 AAD(域分隔;换格式/换用途必须改它,旧文件自动解不开 → 丢弃重握手)。 */
938
943
  export const SESSION_STORE_AAD = Buffer.from(`${E2EE_DOMAIN}\x00sessions-store-v1`, "utf8");
@@ -151,7 +151,7 @@ test("契约②:发送缓冲高水位以上时停手(控制帧不会被埋
151
151
  assert.equal(pos, beforeControl, "排空后第一时间发控制帧(之前的背压不能把它排到分块后面)");
152
152
  });
153
153
 
154
- test("契约③:大帧传输中小帧优先(交互不被整帧传输拖住);close() 丢弃未发内容", () => {
154
+ test("契约③:数据帧严格 FIFO(大小不得插队,2026-10-24 历史游标错乱事故回归);close() 丢弃未发内容", () => {
155
155
  const sock = fakeSocket();
156
156
  const sched = makeScheduler();
157
157
  const sender = makePrioritySender({
@@ -162,15 +162,23 @@ test("契约③:大帧传输中小帧优先(交互不被整帧传输拖住
162
162
  });
163
163
  // 大帧刚发出第一片(之后每轮只发一片)
164
164
  sender.send({ id: "big3", type: "http", body: "C".repeat(512 * 1024) });
165
- // 传输中插入两条小帧(交互消息 / SSE 块):必须插在大帧的下一片之前,而不是排在整帧之后
165
+ // 传输中插入两条小帧(交互消息 / SSE 块):**不得插队**,必须排在大帧之后(FIFO)
166
166
  sender.send({ id: "i1", type: "ws-msg", data: "hello" });
167
167
  sender.send({ id: "i2", type: "http-chunk", body: "chunk" });
168
168
  const ids = sock.frames.map((s) => {
169
169
  const o = JSON.parse(s);
170
170
  return o.__chunk ? "__chunk" : o.id;
171
171
  });
172
- assert.deepEqual(ids.slice(0, 3), ["__chunk", "i1", "i2"], "小帧必须插在大帧的下一片之前");
173
- assert.equal(sender.stats().bigChunks, 1, "此时大帧只发了 1 片");
172
+ assert.equal(ids[0], "__chunk", "大帧第一片先发");
173
+ assert.ok(!ids.slice(0, 3).includes("i1"), "后入队的小帧绝不能插队到大帧分片之前(顺序语义)");
174
+ for (let i = 0; i < 5000 && sched.pending() > 0; i += 1) { sock.drain(10 * 1024 * 1024); sched.tick(); }
175
+ const orderedIds = sock.frames.map((s) => {
176
+ const o = JSON.parse(s);
177
+ return o.__chunk ? "__chunk" : o.id;
178
+ });
179
+ const firstNonChunk = orderedIds.findIndex((id) => id !== "__chunk");
180
+ assert.ok(firstNonChunk >= 8, `大帧的分片必须全部先发完(首个非分片位置 ${firstNonChunk})`);
181
+ assert.deepEqual(orderedIds.slice(firstNonChunk), ["i1", "i2"], "小帧必须在大帧之后、且保持入队顺序");
174
182
 
175
183
  // close 后:未发内容丢弃,后续 send 返回 false(重连后不能把旧分片发到新连接上)
176
184
  const sock2 = fakeSocket();
@@ -98,7 +98,7 @@ test("① 加密落盘:文件里没有 MK/SHK/salt 明文,权限 0600,格
98
98
  svc.disposeStore();
99
99
  });
100
100
 
101
- test("② 重启恢复:新 E2eeService 只凭 MK + 目录恢复 verified 会话 + ctrl 计数,旧票可直接用", () => {
101
+ test("② 重启恢复(2026-10-24 安全策略):只恢复会话材料但一律标记「需重新握手」,不复用旧计数", () => {
102
102
  const dir = tmpDir();
103
103
  const svc1 = mkSvc();
104
104
  const { sessId, client } = unlock(svc1);
@@ -113,15 +113,12 @@ test("② 重启恢复:新 E2eeService 只凭 MK + 目录恢复 verified 会
113
113
  assert.equal(restored.restored, 1);
114
114
  assert.equal(restored.discarded, "", "没有作废");
115
115
  const session = svc2.sessionOf(sessId);
116
- assert.ok(session, "★ 旧 sessId 必须仍可用(这正是持久化的目的)");
117
- assert.equal(session.verified, true, "verified 也要恢复(否则数据 WS 仍被判 locked)");
118
- assert.equal(svc2.ctrlCounters.get(sessId).p2b.last, 3, "ctrl 防重放计数必须恢复(重启不能重置)");
119
- // 恢复的会话密钥仍能正确解密手机端的信封(HTTP 请求)
120
- const plain = Buffer.from(JSON.stringify({ m: "GET", p: "/x", h: {}, b: "" }), "utf8");
121
- const env = client.seal({ kind: "http", dir: "p2b", counter: 1, data: plain });
122
- assert.doesNotThrow(() => svc2.guardHttpRequest(sessId, env), "恢复后的会话必须能过 guardHttpRequest");
123
- const opened = session.open({ kind: "http", dir: "p2b", env, counter: env.c });
124
- assert.equal(JSON.parse(opened.data.toString("utf8")).p, "/x");
116
+ assert.ok(session, "旧 sessId 仍能查到(供重新握手复用)");
117
+ assert.equal(session.verified, false, "★ 恢复后必须未验证(数据 WS 一律先要求重新握手,绝不复用旧计数)");
118
+ assert.equal(session.restoredNeedsHandshake, true, "必须带「需重新握手」标记,便于上层给手机 rehandshake 信号");
119
+ assert.equal(svc2.ctrlCounters.get(sessId).p2b.last, -1, "旧计数不得恢复(否则下次探测误判 nonce 重放)");
120
+ // 未验证前,带旧信封的请求必须被拒(不是静默解密)
121
+ void client;
125
122
  svc2.disposeStore();
126
123
  });
127
124
 
@@ -243,7 +240,7 @@ test("⑦ 原语契约:save→load→clear 往返;版本/AAD 域隔离;sto
243
240
  assert.equal(loaded.payload.sessions.length, 1);
244
241
  // 换版本号 → 解不开(旧格式不能冒名顶替)
245
242
  const blob = JSON.parse(readFileSync(sessionStorePath(dir), "utf8"));
246
- assert.throws(() => decryptSessionStore(MK, { ...blob, v: 2 }), /会话库版本|bad_store/);
243
+ assert.throws(() => decryptSessionStore(MK, { ...blob, v: SESSION_STORE_VERSION + 1 }), /会话库版本|bad_store/);
247
244
  // clear 后读回 absent
248
245
  assert.equal(clearSessionStore(dir), true);
249
246
  assert.equal(loadSessionStore(dir, MK).reason, "absent");
@@ -0,0 +1,96 @@
1
+ /**
2
+ * 桌面端插件「暂存更新」自动安装(2026-10-24)契约测试。
3
+ *
4
+ * 背景:桌面端应用独占自己的 profile,运行中不能改写(会触发应用退出/重启)。
5
+ * 一键更新在应用运行中只能「暂存」,由常驻 bridge 在检测到应用退出后自动安装,
6
+ * 用户下次打开应用即生效 —— 否则就会出现「更新成功但版本没变、还得开终端」。
7
+ *
8
+ * 这里钉住四条最容易破的语义:
9
+ * ① 应用还在运行 → 绝不改写 profile(返回 app_running,marker 保留等待下一轮);
10
+ * ② 应用退出 → 原子替换插件目录、写 applied marker、清掉暂存与 marker;
11
+ * ③ 暂存包不完整(缺必需文件)→ 不动旧版,返回 apply_failed;
12
+ * ④ 没有 marker → no_pending(定时器空转,绝不能误删东西)。
13
+ */
14
+ import { test } from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { applyPendingPluginUpdate } from "../dsh-bridge.mjs";
20
+
21
+ function mkTmpDir() {
22
+ return fs.mkdtempSync(path.join(os.tmpdir(), "dsh-pending-plugin-"));
23
+ }
24
+
25
+ function writeFile(p, content) {
26
+ fs.mkdirSync(path.dirname(p), { recursive: true });
27
+ fs.writeFileSync(p, content);
28
+ }
29
+
30
+ /** 造一个「旧版插件已安装 + 新版已暂存 + marker」的环境。 */
31
+ function setupFixture({ stageComplete = true } = {}) {
32
+ const relayDir = mkTmpDir();
33
+ const profileDir = mkTmpDir();
34
+ const pluginName = "dsh-remote-web-plugin";
35
+ const dest = path.join(profileDir, pluginName);
36
+ // 旧版
37
+ writeFile(path.join(dest, "package.json"), JSON.stringify({ name: "dsh-remote-web", version: "0.7.4" }));
38
+ writeFile(path.join(dest, "lib", "index.js"), "// old\n");
39
+ writeFile(path.join(dest, "lib", "client.js"), "// old\n");
40
+ writeFile(path.join(dest, "cordis.patch.yml"), "old: true\n");
41
+ // 新版暂存
42
+ const stageDir = path.join(relayDir, "pending-plugin");
43
+ writeFile(path.join(stageDir, "lib", "index.js"), "// new\n");
44
+ if (stageComplete) {
45
+ writeFile(path.join(stageDir, "package.json"), JSON.stringify({ name: "dsh-remote-web", version: "0.7.6-beta.2" }));
46
+ writeFile(path.join(stageDir, "lib", "client.js"), "// new\n");
47
+ writeFile(path.join(stageDir, "cordis.patch.yml"), "new: true\n");
48
+ }
49
+ writeFile(
50
+ path.join(relayDir, ".plugin-update-pending.json"),
51
+ JSON.stringify({ version: "0.7.6-beta.2", profileDir, pluginDirName: pluginName, stageDir, stagedAt: Date.now() })
52
+ );
53
+ return { relayDir, profileDir, dest, stageDir, pluginName };
54
+ }
55
+
56
+ test("① 应用仍在运行 → 不改写 profile,marker 保留", async () => {
57
+ const { relayDir, dest } = setupFixture();
58
+ const r = await applyPendingPluginUpdate({ relayDir, isAppRunning: async () => true });
59
+ assert.equal(r.applied, false);
60
+ assert.equal(r.reason, "app_running");
61
+ assert.match(fs.readFileSync(path.join(dest, "package.json"), "utf8"), /0\.7\.4/, "旧版必须原样保留");
62
+ assert.ok(fs.existsSync(path.join(relayDir, ".plugin-update-pending.json")), "marker 必须保留给下一轮");
63
+ });
64
+
65
+ test("② 应用已退出 → 原子替换为新版,写 applied marker 并清理暂存", async () => {
66
+ const { relayDir, dest, stageDir } = setupFixture();
67
+ const r = await applyPendingPluginUpdate({ relayDir, isAppRunning: async () => false });
68
+ assert.equal(r.applied, true);
69
+ assert.equal(r.version, "0.7.6-beta.2");
70
+ assert.match(fs.readFileSync(path.join(dest, "package.json"), "utf8"), /0\.7\.6-beta\.2/, "profile 内必须是新版");
71
+ assert.match(fs.readFileSync(path.join(dest, "lib", "index.js"), "utf8"), /new/);
72
+ const applied = JSON.parse(fs.readFileSync(path.join(relayDir, ".plugin-update-applied.json"), "utf8"));
73
+ assert.equal(applied.version, "0.7.6-beta.2");
74
+ assert.equal(fs.existsSync(path.join(relayDir, ".plugin-update-pending.json")), false, "marker 必须清掉");
75
+ assert.equal(fs.existsSync(stageDir), false, "暂存目录必须清掉");
76
+ // 不留 staging/backup 垃圾
77
+ const leftovers = fs.readdirSync(path.dirname(dest)).filter((n) => /\.(staging|backup)-/.test(n));
78
+ assert.deepEqual(leftovers, []);
79
+ });
80
+
81
+ test("③ 暂存包不完整 → 保留旧版、返回 apply_failed", async () => {
82
+ const { relayDir, dest } = setupFixture({ stageComplete: false });
83
+ const r = await applyPendingPluginUpdate({ relayDir, isAppRunning: async () => false });
84
+ assert.equal(r.applied, false);
85
+ assert.equal(r.reason, "apply_failed");
86
+ assert.ok(r.error && /缺少/.test(r.error), "错误里要写清缺哪个文件");
87
+ assert.match(fs.readFileSync(path.join(dest, "package.json"), "utf8"), /0\.7\.4/, "旧版必须可回退");
88
+ assert.ok(fs.existsSync(path.join(relayDir, ".plugin-update-pending.json")), "marker 保留,等下次修复后重试");
89
+ });
90
+
91
+ test("④ 没有 marker → no_pending(定时器不得误动作)", async () => {
92
+ const relayDir = mkTmpDir();
93
+ const r = await applyPendingPluginUpdate({ relayDir, isAppRunning: async () => false });
94
+ assert.equal(r.applied, false);
95
+ assert.equal(r.reason, "no_pending");
96
+ });