@mrrisega/dsh-remote 0.7.6-beta.1 → 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.
Files changed (27) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +678 -50
  2. package/clients/dsh-remote/e2ee-client.mjs +256 -0
  3. package/clients/dsh-remote/mobile-adapter.mjs +261 -0
  4. package/clients/dsh-remote/test/bridge-control-priority.test.mjs +214 -0
  5. package/clients/dsh-remote/test/bridge-heartbeat-adaptive.test.mjs +110 -0
  6. package/clients/dsh-remote/test/bridge-upstream-refresh.test.mjs +26 -8
  7. package/clients/dsh-remote/test/bridge-ws-open-fast-ack.test.mjs +213 -0
  8. package/clients/dsh-remote/test/e2ee-session-store.test.mjs +251 -0
  9. package/clients/dsh-remote/test/mobile-adapter-single-page.test.mjs +328 -0
  10. package/clients/dsh-remote/test/pending-plugin-update.test.mjs +96 -0
  11. package/dsh-setup.mjs +109 -10
  12. package/package.json +1 -1
  13. package/packages/dsh-remote-web/lib/client.js +257 -13
  14. package/packages/dsh-remote-web/lib/index.js +172 -2
  15. package/packages/dsh-remote-web/package.json +1 -1
  16. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +678 -50
  17. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +256 -0
  18. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +261 -0
  19. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +109 -10
  20. package/packages/dsh-remote-web/test/community-invite-proxy.test.mjs +343 -0
  21. package/packages/dsh-remote-web/test/community-invite-ui.test.mjs +375 -0
  22. package/packages/dsh-remote-web/test/desktop-compat.test.mjs +7 -5
  23. package/packages/dsh-remote-web/test/purchase-agreement-proxy.test.mjs +198 -0
  24. package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +336 -0
  25. package/packages/dsh-remote-web/test/settings-entry.test.mjs +4 -2
  26. package/packages/dsh-remote-web/test/setup-output.test.mjs +17 -8
  27. package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +2 -2
@@ -1127,7 +1127,15 @@ const UPSTREAM_FILE = ".dsh-upstream";
1127
1127
  * 本次安装要**钉住**的上游(null = 不钉,运行时按 env → .dsh-upstream → 探测 取值)。
1128
1128
  * 见 desktopAppPort() 的注释:插件装进多个 profile 后,各宿主会互相覆盖 `.dsh-upstream`。
1129
1129
  */
1130
- let PINNED_UPSTREAM = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().replace(/\/+$/, "") || null;
1130
+ // 🔴 2026-10-25 事故:以前把安装进程里的 DSH_BRIDGE_UPSTREAM(一个**当时的动态端口**)写进
1131
+ // launchd plist,之后 dsh 每次重启换端口,plist 里的旧端口就成了永久生效的"显式指定",
1132
+ // 守护/桥永远连不上 → 设备掉线。现在默认**不钉**(运行时按 插件写的 .dsh-upstream → 探测 取值);
1133
+ // 仅当部署方显式要求(DSH_BRIDGE_PIN_UPSTREAM=1)时才沿用旧行为。
1134
+ let PINNED_UPSTREAM = (() => {
1135
+ const pinFlag = String(process.env.DSH_BRIDGE_PIN_UPSTREAM || "").trim().toLowerCase();
1136
+ if (pinFlag !== "1" && pinFlag !== "true" && pinFlag !== "on") return null;
1137
+ return String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().replace(/\/+$/, "") || null;
1138
+ })();
1131
1139
 
1132
1140
  /**
1133
1141
  * 探测桌面端(Electron 应用)当前监听的**回环端口**。
@@ -1223,6 +1231,18 @@ async function upstreamReachable(url = upstreamUrl(), timeoutMs = 1200) {
1223
1231
  */
1224
1232
  async function isDshWebUp(timeoutMs = 1200) {
1225
1233
  if (await upstreamReachable(upstreamUrl(), timeoutMs)) return true;
1234
+ // 🔴 2026-10-25(设备掉线实测):Electron 每次启动可能换端口。端口文件由 dsh web 内的插件
1235
+ // 在每次启动时重写,比进程内的 discoveredUpstream 缓存新 —— 先直接试文件,别被旧缓存挡住。
1236
+ try {
1237
+ const fromFile = String(fs.readFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), "utf8")).trim();
1238
+ if (/^https?:\/\/[^\s]+$/i.test(fromFile)) {
1239
+ const normalized = fromFile.replace(/\/+$/, "");
1240
+ if (normalized !== upstreamUrl() && (await upstreamReachable(normalized, timeoutMs))) {
1241
+ discoveredUpstream = normalized;
1242
+ return true;
1243
+ }
1244
+ }
1245
+ } catch { /* 没有端口文件:继续走动态发现 */ }
1226
1246
  const before = upstreamUrl();
1227
1247
  const after = await refreshUpstreamDiscovery(true);
1228
1248
  if (after === before) return false;
@@ -1659,7 +1679,22 @@ function releaseSpawnLock() {
1659
1679
  }
1660
1680
 
1661
1681
  // ---------- run:前台跑 bridge(带配置 + 自启动 watcher) ----------
1682
+ /** 以插件写入的 .dsh-upstream 为准校正进程内的旧上游环境变量(dsh 换端口后不再连旧端口)。 */
1683
+ function reconcileUpstreamEnvFromFile() {
1684
+ try {
1685
+ const fileUp = String(fs.readFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), "utf8")).trim().replace(/\/+$/, "");
1686
+ const envUp = String(process.env.DSH_BRIDGE_UPSTREAM || "").trim().replace(/\/+$/, "");
1687
+ if (envUp && /^https?:\/\/[^\s]+$/i.test(fileUp) && fileUp !== envUp) {
1688
+ console.log(`[dsh-remote] 忽略旧的上游地址 ${envUp} → 使用插件写入的最新地址 ${fileUp}(防端口漂移掉线)`);
1689
+ process.env.DSH_BRIDGE_UPSTREAM = fileUp;
1690
+ discoveredUpstream = fileUp;
1691
+ }
1692
+ } catch { /* 无文件:保持原行为 */ }
1693
+ }
1694
+
1662
1695
  async function runBridge() {
1696
+ // 启动/每次拉起前都按插件写入的端口校正继承来的旧 env(旧 plist 可能钉着安装时的端口)
1697
+ reconcileUpstreamEnvFromFile();
1663
1698
  // 去重(所有平台):自启动服务、登录任务、插件半的脱离进程兜底都可能拉起 watcher,
1664
1699
  // 两个 bridge 会抢同一个设备登记 → 以 pid 文件为准。
1665
1700
  //
@@ -1856,9 +1891,13 @@ async function runBridge() {
1856
1891
  }
1857
1892
  warnedDuplicateBridge = false;
1858
1893
 
1894
+ reconcileUpstreamEnvFromFile(); // dsh 可能刚重启并换了端口:拉起前再以最新端口文件为准
1859
1895
  const alive = await checkUpstream();
1860
1896
  if (alive && childStopped(bridgeProc)) {
1861
1897
  starting = true;
1898
+ // dsh 重启可能换端口(Electron 每次启动端口可能变):拉起前**强制重新发现**,
1899
+ // 否则 discoveredUpstream 缓存旧端口 → bridge 连不上上游 → 设备掉线(2026-10-25 实测)。
1900
+ await refreshUpstreamDiscovery(true);
1862
1901
  console.log("[dsh-remote] dsh web 在线,启动 bridge...");
1863
1902
  // 清除代理环境变量(bridge 需直连 relay,不受本机代理影响)
1864
1903
  const childEnv = {
@@ -1900,6 +1939,17 @@ async function runBridge() {
1900
1939
  // 另一个 bridge 把本连接顶掉)后会以"重复实例"退出码优雅退出(见 dsh-bridge.mjs)。
1901
1940
  // 这种退出**绝不能**照常 10s 重拉:本机已经有另一个实例在跑,重拉只会继续乒乓互踢。
1902
1941
  if (code === DUPLICATE_INSTANCE_EXIT_CODE) {
1942
+ // 🔴 2026-10-25:升级/重启后常见「上一代 bridge 变成孤儿继续占着单实例锁」,
1943
+ // 新 bridge 因此退出 code=4。旧逻辑一律暂停 5 分钟 → 设备在线要等 5 分钟(用户实测掉线)。
1944
+ // 现在先**清扫同运行时的孤儿 bridge**;扫到了说明冲突已解除,3 秒后立即重试;
1945
+ // 扫不到(多半真有另一个 watcher 的活实例)才按 5 分钟退避,避免乒乓互踢。
1946
+ let swept = [];
1947
+ try { swept = sweepStrayBridges(); } catch { /* 清扫失败不致命 */ }
1948
+ if (swept.length > 0) {
1949
+ duplicateBridgeBackoffUntil = Date.now() + 3000;
1950
+ console.log(`[dsh-remote] bridge 因同实例冲突退出(code=${code}),已清扫 ${swept.length} 个孤儿 bridge(pid ${swept.join(", ")})→ 3 秒后重试。`);
1951
+ return;
1952
+ }
1903
1953
  duplicateBridgeBackoffUntil = Date.now() + DUPLICATE_BRIDGE_BACKOFF_MS;
1904
1954
  console.log(`[dsh-remote] bridge 检测到同 deviceId 的另一个实例并退出(code=${code})——本守护暂停 ${Math.round(DUPLICATE_BRIDGE_BACKOFF_MS / 60000)} 分钟后再试,避免两个实例反复互踢。`);
1905
1955
  return;
@@ -1913,6 +1963,8 @@ async function runBridge() {
1913
1963
  setTimeout(() => { starting = false; }, 5000);
1914
1964
  } else if (!alive && bridgeProc && bridgeProc.exitCode === null) {
1915
1965
  console.log("[dsh-remote] dsh web 离线,停止 bridge...");
1966
+ // dsh 重启后端口可能变化:清掉进程内缓存,下一轮优先用插件新写的端口文件探测
1967
+ discoveredUpstream = "";
1916
1968
  await stopChildGracefully(bridgeProc); // 同上:Windows 上 kill() 跑不到退出钩子
1917
1969
  }
1918
1970
  };
@@ -2024,9 +2076,11 @@ async function setup(argv) {
2024
2076
  if (!PINNED_UPSTREAM && pluginTargets.some((t) => t.name === "desktop")) {
2025
2077
  const p = desktopAppPort();
2026
2078
  if (p) {
2027
- PINNED_UPSTREAM = `http://127.0.0.1:${p}`;
2028
- try { fs.writeFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), PINNED_UPSTREAM + "\n"); } catch { /* 忽略 */ }
2029
- console.log(`ℹ 桌面端在 127.0.0.1:${p} 监听 → 桥的上游已钉到它(避免与 Web 版互抢)`);
2079
+ // 🔴 2026-10-25:这里只能写运行时端口文件;**不能**回填 PINNED_UPSTREAM ——
2080
+ // 它会被写进 launchd plist 变成永久显式指定,dsh 换端口后守护/桥永远连旧端口
2081
+ // (用户实测 upstream_unreachable)。
2082
+ try { fs.writeFileSync(path.join(CONFIG_DIR, UPSTREAM_FILE), `http://127.0.0.1:${p}` + "\n"); } catch { /* 忽略 */ }
2083
+ console.log(`ℹ 桌面端在 127.0.0.1:${p} 监听 → 已写入运行时上游端口(不再钉进自启动配置)`);
2030
2084
  }
2031
2085
  }
2032
2086
  } catch { /* 探测失败不阻断安装 */ }
@@ -2583,6 +2637,45 @@ function missingPluginFiles(dir) {
2583
2637
  *
2584
2638
  * 另外清掉上次被强杀留下的 `.staging-*` / `.backup-*`,不留垃圾。
2585
2639
  */
2640
+ // ── 桌面端「暫存安装」:应用运行中不能改写它的 profile(会把它弄退出/重启),
2641
+ // 但用户点「一键更新」必须能装到桌面端。做法:先整份拷到 ~/.dsh-remote/pending-plugin/ 并写 marker,
2642
+ // 由常驻的 bridge 在检测到应用退出后自动原子替换 profile 内的插件目录(见 dsh-bridge.mjs)。
2643
+ const PENDING_PLUGIN_MARKER = path.join(CONFIG_DIR, ".plugin-update-pending.json");
2644
+ const PENDING_PLUGIN_DIR = path.join(CONFIG_DIR, "pending-plugin");
2645
+
2646
+ /** 暂存一份插件更新(供 bridge 在应用退出后安装)。返回 marker 对象。 */
2647
+ function stagePluginUpdateForProfile(profileDir, pluginDir) {
2648
+ fs.rmSync(PENDING_PLUGIN_DIR, { recursive: true, force: true });
2649
+ fs.mkdirSync(PENDING_PLUGIN_DIR, { recursive: true });
2650
+ const cpOpts = {
2651
+ recursive: true,
2652
+ filter: (src) => {
2653
+ if (src === pluginDir) return true;
2654
+ const rel = String(path.relative(pluginDir, src));
2655
+ const segs = rel.split(path.sep);
2656
+ if (segs.some((seg) => seg === ".git" || seg === ".DS_Store")) return false;
2657
+ const inBundledRuntime = segs[0] === "runtime";
2658
+ if (!inBundledRuntime && segs.some((seg) => seg === "node_modules")) return false;
2659
+ return true;
2660
+ }
2661
+ };
2662
+ fs.cpSync(pluginDir, PENDING_PLUGIN_DIR, cpOpts);
2663
+ for (const rel of PLUGIN_REQUIRED_FILES) {
2664
+ const f = path.join(PENDING_PLUGIN_DIR, rel);
2665
+ if (!fs.existsSync(f)) throw new Error(`暂存校验失败:缺少 ${rel}`);
2666
+ }
2667
+ const version = JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf8")).version;
2668
+ const marker = {
2669
+ version,
2670
+ profileDir,
2671
+ pluginDirName: PLUGIN_LOCAL_DIR,
2672
+ stageDir: PENDING_PLUGIN_DIR,
2673
+ stagedAt: Date.now()
2674
+ };
2675
+ fs.writeFileSync(PENDING_PLUGIN_MARKER, JSON.stringify(marker, null, 2) + "\n");
2676
+ return marker;
2677
+ }
2678
+
2586
2679
  function copyPluginIntoProfile(profileDir, pluginDir) {
2587
2680
  // 清理历史名残留目录(≤0.4.9 的 dsh-remote-ui-plugin),避免与新版并存
2588
2681
  for (const id of PLUGIN_LEGACY_IDS) {
@@ -3031,16 +3124,21 @@ async function pluginCmd(argv) {
3031
3124
  let last = null;
3032
3125
  let ok = 0;
3033
3126
  let skippedRunning = 0;
3127
+ let stagedRunning = 0;
3034
3128
  const force = hasFlag(argv, "--force");
3035
3129
  for (const t of targets) {
3036
3130
  // ⚠️ 桌面应用在运行时**绝不改写它的 profile**:应用会因此退出/重启(2026-10-11 实测故障)
3037
3131
  if (t.name === "desktop" && !force && dshDesktopAppRunning()) {
3038
- console.log(`⚠️ 跳过 desktop:DeepSeek Harness 应用正在运行。`);
3039
- console.log(` 应用独占该 profile,运行中改写会让应用自己退出/重启。请任选其一:`);
3040
- console.log(` · 完全退出应用后重跑本命令;或`);
3041
- console.log(` · 在应用内「插件」页安装/更新 dsh-remote-web(官方路径,装完即热加载)。`);
3042
- console.log(` (确实要强行写入:加 --force)`);
3043
- skippedRunning += 1;
3132
+ console.log(`⚠️ DeepSeek Harness 应用正在运行 → 不直接改写它的 profile(会把它弄退出/重启)。`);
3133
+ try {
3134
+ const staged = stagePluginUpdateForProfile(t.dir, path.join(THIS_DIR, "packages", PLUGIN_ID));
3135
+ stagedRunning += 1;
3136
+ console.log(` ✅ 新版本 ${staged.version} 已暂存:**退出 DeepSeek Harness 后会自动安装**,重新打开应用即生效(无需终端)。`);
3137
+ } catch (e) {
3138
+ console.log(` ⚠️ 暂存失败:${e && e.message ? e.message : e}`);
3139
+ console.log(` 请完全退出应用后重跑本命令,或在应用内「插件」页安装/更新 dsh-remote-web。`);
3140
+ skippedRunning += 1;
3141
+ }
3044
3142
  continue;
3045
3143
  }
3046
3144
  if (multi) console.log(`\n── profile: ${t.name}(${t.dir})`);
@@ -3050,6 +3148,7 @@ async function pluginCmd(argv) {
3050
3148
  if (r !== undefined && r !== null) last = r;
3051
3149
  }
3052
3150
  if (ok === 0 && skippedRunning === 0) process.exit(1);
3151
+ if (stagedRunning > 0) console.log(`ℹ 桌面端插件已暂存(${stagedRunning} 个):退出 DeepSeek Harness 后由后台服务自动安装,重开应用生效。`);
3053
3152
  if (skippedRunning > 0) console.log(`ℹ 本次跳过了 ${skippedRunning} 个 profile(应用在运行);桥与运行环境不受影响。`);
3054
3153
  return last;
3055
3154
  }
@@ -0,0 +1,343 @@
1
+ // 🎉 企微交流群引导(使用满 2 小时弹一次)—— **宿主半边**(lib/index.js)代理路由回归。
2
+ //
3
+ // 背景(业主 2026-10-23 需求):用户实际使用满 2 小时后要「加入企业微信群」引导,
4
+ // 每用户全局只弹一次;手机 Web 端已实现,桌面端插件面板复用**同一套企业端接口**:
5
+ // GET /api/community-invite/status → { ok, show, reason, used_seconds, required_seconds, qr_url }
6
+ // POST /api/community-invite/shown → { ok:true }(幂等)
7
+ //
8
+ // 桌面端浏览器半没有企业端用户 token 直连,所以本文件锁 node 半两条代理路由的契约:
9
+ // GET /dsh-remote/community-invite/status → 上游 GET {api}/api/community-invite/status
10
+ // POST /dsh-remote/community-invite/shown → 上游 POST {api}/api/community-invite/shown
11
+ //
12
+ // 最要紧的四件(都是"写错就出事故"的点):
13
+ // ① 身份只认本机 device-login token,**不透传浏览器带来的 Authorization**;
14
+ // ② 判定/记账全在服务端:show/reason/时长字段原样透传,node 半**不自己算**;
15
+ // ③ qr_url 归一化成企业端绝对地址(相对路径在 dsh web 源上会解析到 127.0.0.1),
16
+ // 非 http(s)/站内路径一律丢弃(前端降级),绝不把 data:/javascript: 交给 <img src>;
17
+ // ④ 一切失败软着陆:未登录/自建 → 200 + ok:false(面板静默);上游 4xx/5xx 透传;
18
+ // 不可达 → 502 JSON,**绝不抛回 webServer**(否则面板会看到 500 堆栈)。
19
+ import assert from "node:assert/strict";
20
+ import http from "node:http";
21
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import test from "node:test";
25
+ import { apply } from "../lib/index.js";
26
+
27
+ /** 假 relay:device-login 发 token;community-invite 两个端点按 opts 返回(并记录收到的请求)。 */
28
+ async function startFakeRelay(opts = {}) {
29
+ const seen = [];
30
+ const srv = http.createServer((req, res) => {
31
+ let raw = "";
32
+ req.on("data", (c) => { raw += c; });
33
+ req.on("end", () => {
34
+ const entry = { method: req.method, url: req.url, headers: req.headers, body: raw };
35
+ seen.push(entry);
36
+ const send = (code, obj) => {
37
+ res.writeHead(code, { "content-type": "application/json" });
38
+ res.end(JSON.stringify(obj));
39
+ };
40
+ const url = new URL(req.url, "http://x");
41
+ // 模拟"token 拿到了、但企业端 community-invite 端点不可达":直接掐连接(fetch 端表现为 status 0)
42
+ if (opts.killCommunity && url.pathname.startsWith("/api/community-invite")) {
43
+ try { req.socket.destroy(); } catch { /* 忽略 */ }
44
+ return;
45
+ }
46
+ if (req.method === "POST" && url.pathname === "/api/device-login") {
47
+ // device-login 失败场景:返回 401(插件 node 半应静默降级成 not_logged_in)
48
+ if (opts.loginStatus && opts.loginStatus !== 200) return send(opts.loginStatus, { error: { code: "bad_credentials", message: "密码错误" } });
49
+ return send(200, { token: "jwt-abc" });
50
+ }
51
+ if (url.pathname === "/api/community-invite/status") {
52
+ if (opts.statusCode && opts.statusCode !== 200) {
53
+ return send(opts.statusCode, { error: { code: "boom", message: "服务端炸了" } });
54
+ }
55
+ return send(200, opts.statusBody || {
56
+ ok: true,
57
+ show: true,
58
+ reason: "ok",
59
+ used_seconds: 7205,
60
+ required_seconds: 7200,
61
+ qr_url: "/qr/group-qr.png"
62
+ });
63
+ }
64
+ if (req.method === "POST" && url.pathname === "/api/community-invite/shown") {
65
+ return send(200, { ok: true });
66
+ }
67
+ send(404, { error: { code: "not_found" } });
68
+ });
69
+ });
70
+ await new Promise((r) => srv.listen(0, "127.0.0.1", r));
71
+ return { srv, seen, base: `http://127.0.0.1:${srv.address().port}` };
72
+ }
73
+
74
+ function boot(relayDir) {
75
+ const routes = new Map();
76
+ apply({
77
+ webServer: { register(rt) { routes.set(rt.path, rt.handler); return () => {}; } },
78
+ effect(reg) { return reg(); },
79
+ logger: { info() {}, warn() {} },
80
+ }, { relayDir });
81
+ const host = http.createServer((req, res) => {
82
+ const url = new URL(req.url, "http://x");
83
+ // 与 announcements-proxy.test.mjs 对齐:注册对象只有 {kind,path,handler},
84
+ // prefix 路由的 handler 自己判别子路径(没有 route.prefix 字段)。
85
+ const handler = routes.get(url.pathname) || routes.get("/dsh-remote/community-invite");
86
+ (handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
87
+ });
88
+ return host;
89
+ }
90
+
91
+ const writeCfg = (relayDir, apiUrl, extra = {}) => writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
92
+ device_id: "dev-ci-1", api_url: apiUrl, phone: "13800000000", password: "pw", ...extra,
93
+ }));
94
+
95
+ /** 只挑 community-invite 的上游请求(插件装载时还会打 public-config / device-login 等旁路请求)。 */
96
+ const ciReqs = (up) => up.seen.filter((e) => String(e.url || "").startsWith("/api/community-invite"));
97
+
98
+ async function withPlugin(fn) {
99
+ const relayDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ci-"));
100
+ process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 套件级隔离:绝不真的去后台装运行环境
101
+ let host = null;
102
+ try {
103
+ return await fn({ relayDir, startHost: () => { host = boot(relayDir); return host; } });
104
+ } finally {
105
+ try { if (host) host.close(); } catch { /* 忽略 */ }
106
+ process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 恢复(不要 delete:套件本来就靠它隔离)
107
+ await rm(relayDir, { recursive: true, force: true });
108
+ }
109
+ }
110
+
111
+ async function listen(host) {
112
+ await new Promise((r) => host.listen(0, "127.0.0.1", r));
113
+ return `http://127.0.0.1:${host.address().port}`;
114
+ }
115
+
116
+ test("GET status:device-login token 打企业端,show/qr_url 原样透传(qr_url 归一化成绝对地址)", async () => {
117
+ const up = await startFakeRelay();
118
+ try {
119
+ await withPlugin(async ({ relayDir, startHost }) => {
120
+ await writeCfg(relayDir, up.base);
121
+ const base = await listen(startHost());
122
+ const r = await (await fetch(`${base}/dsh-remote/community-invite/status`)).json();
123
+ assert.equal(r.ok, true);
124
+ assert.equal(r.show, true);
125
+ assert.equal(r.reason, "ok");
126
+ assert.equal(r.used_seconds, 7205, "时长口径由服务端算,node 半只透传");
127
+ assert.equal(r.required_seconds, 7200);
128
+ assert.equal(r.qr_url, `${up.base}/qr/group-qr.png`, "相对路径必须拼成企业端绝对地址(否则图片落在 127.0.0.1 上打不开)");
129
+
130
+ const got = ciReqs(up).find((e) => e.method === "GET" && e.url === "/api/community-invite/status");
131
+ assert.ok(got, "必须真的打到企业端 GET /api/community-invite/status");
132
+ assert.equal(got.headers.authorization, "Bearer jwt-abc", "必须用 device-login 换来的 token(sub = user)");
133
+ });
134
+ } finally {
135
+ up.srv.close();
136
+ }
137
+ });
138
+
139
+ test("GET status:show=false(未满 2h / 已弹过 / 没配二维码)原样透传,node 半绝不自己算", async () => {
140
+ const up = await startFakeRelay({
141
+ statusBody: { ok: true, show: false, reason: "already_shown", used_seconds: 9000, required_seconds: 7200, qr_url: "/qr/group-qr.png" }
142
+ });
143
+ try {
144
+ await withPlugin(async ({ relayDir, startHost }) => {
145
+ await writeCfg(relayDir, up.base);
146
+ const base = await listen(startHost());
147
+ const r = await (await fetch(`${base}/dsh-remote/community-invite/status`)).json();
148
+ assert.equal(r.ok, true);
149
+ assert.equal(r.show, false, "服务端说不弹就不弹");
150
+ assert.equal(r.reason, "already_shown", "reason 要透传(面板/排障用)");
151
+ assert.equal(r.used_seconds, 9000);
152
+ });
153
+ } finally {
154
+ up.srv.close();
155
+ }
156
+ });
157
+
158
+ test("GET status:qr_url 为空 / 非法协议(data:/javascript:/协议相对)→ 下发空串(前端降级)", async () => {
159
+ for (const bad of ["", " ", "data:image/png;base64,AAA", "javascript:alert(1)", "//evil.example/qr.png"]) {
160
+ const up = await startFakeRelay({
161
+ statusBody: { ok: true, show: true, reason: "ok", used_seconds: 7300, required_seconds: 7200, qr_url: bad }
162
+ });
163
+ try {
164
+ await withPlugin(async ({ relayDir, startHost }) => {
165
+ await writeCfg(relayDir, up.base);
166
+ const base = await listen(startHost());
167
+ const r = await (await fetch(`${base}/dsh-remote/community-invite/status`)).json();
168
+ assert.equal(r.show, true, `show 仍然透传(${JSON.stringify(bad)})`);
169
+ assert.equal(r.qr_url, "", `非法/空 qr_url 必须下发空串,交给前端降级(实际 ${JSON.stringify(r.qr_url)})`);
170
+ });
171
+ } finally {
172
+ up.srv.close();
173
+ }
174
+ }
175
+ });
176
+
177
+ test("POST shown:幂等上报打到企业端 POST /api/community-invite/shown(Bearer)", async () => {
178
+ const up = await startFakeRelay();
179
+ try {
180
+ await withPlugin(async ({ relayDir, startHost }) => {
181
+ await writeCfg(relayDir, up.base);
182
+ const base = await listen(startHost());
183
+ const res = await fetch(`${base}/dsh-remote/community-invite/shown`, { method: "POST" });
184
+ assert.equal(res.status, 200);
185
+ const r = await res.json();
186
+ assert.equal(r.ok, true);
187
+ const got = ciReqs(up).find((e) => e.method === "POST" && e.url === "/api/community-invite/shown");
188
+ assert.ok(got, "必须真的打到企业端 POST /api/community-invite/shown");
189
+ assert.equal(got.headers.authorization, "Bearer jwt-abc");
190
+ // 上报两次也必须都成功(幂等契约:展示时一次 + 关闭时一次)
191
+ const again = await (await fetch(`${base}/dsh-remote/community-invite/shown`, { method: "POST" })).json();
192
+ assert.equal(again.ok, true);
193
+ assert.equal(ciReqs(up).filter((e) => e.method === "POST").length, 2, "两次上报都要转发(服务端幂等)");
194
+ });
195
+ } finally {
196
+ up.srv.close();
197
+ }
198
+ });
199
+
200
+ test("鉴权:没配账号 → 200 + ok:false/not_logged_in,且完全不碰企业端(不白跑 device-login)", async () => {
201
+ const up = await startFakeRelay();
202
+ try {
203
+ await withPlugin(async ({ relayDir, startHost }) => {
204
+ await writeCfg(relayDir, up.base, { phone: "", password: "" });
205
+ const base = await listen(startHost());
206
+ const r = await (await fetch(`${base}/dsh-remote/community-invite/status`)).json();
207
+ assert.equal(r.ok, false);
208
+ assert.equal(r.show, false, "未登录绝不能让面板弹引导");
209
+ assert.equal(r.reason, "not_logged_in");
210
+ assert.equal(ciReqs(up).length, 0, "不得向企业端发任何 community-invite 请求");
211
+ assert.equal(up.seen.filter((e) => e.url === "/api/device-login").length, 0, "没凭据就不该尝试 device-login");
212
+ });
213
+ } finally {
214
+ up.srv.close();
215
+ }
216
+ });
217
+
218
+ test("鉴权:自建(local_key)没有企业端账号体系 → local_mode,不发请求", async () => {
219
+ const up = await startFakeRelay();
220
+ try {
221
+ await withPlugin(async ({ relayDir, startHost }) => {
222
+ await writeCfg(relayDir, up.base, { phone: "", password: "", local_key: "selfhost-key" });
223
+ const base = await listen(startHost());
224
+ const r = await (await fetch(`${base}/dsh-remote/community-invite/status`)).json();
225
+ assert.equal(r.ok, false);
226
+ assert.equal(r.show, false);
227
+ assert.equal(r.reason, "local_mode");
228
+ assert.equal(ciReqs(up).length, 0, "自建模式不得请求企业端");
229
+ });
230
+ } finally {
231
+ up.srv.close();
232
+ }
233
+ });
234
+
235
+ test("鉴权:device-login 换 token 失败 → 静默 not_logged_in(面板不弹、不报错)", async () => {
236
+ const up = await startFakeRelay({ loginStatus: 401 });
237
+ try {
238
+ await withPlugin(async ({ relayDir, startHost }) => {
239
+ await writeCfg(relayDir, up.base);
240
+ const base = await listen(startHost());
241
+ const res = await fetch(`${base}/dsh-remote/community-invite/status`);
242
+ assert.equal(res.status, 200, "对面板而言是静默降级,不是 500");
243
+ const r = await res.json();
244
+ assert.equal(r.ok, false);
245
+ assert.equal(r.show, false);
246
+ assert.equal(r.reason, "not_logged_in");
247
+ });
248
+ } finally {
249
+ up.srv.close();
250
+ }
251
+ });
252
+
253
+ test("错误语义:企业端 401/500 → 状态码透传 + ok:false + 可读 error(面板依旧静默)", async () => {
254
+ for (const code of [401, 500]) {
255
+ const up = await startFakeRelay({ statusCode: code });
256
+ try {
257
+ await withPlugin(async ({ relayDir, startHost }) => {
258
+ await writeCfg(relayDir, up.base);
259
+ const base = await listen(startHost());
260
+ const res = await fetch(`${base}/dsh-remote/community-invite/status`);
261
+ assert.equal(res.status, code, "上游状态码透传(排障能看见,不伪装成 200)");
262
+ const r = await res.json();
263
+ assert.equal(r.ok, false);
264
+ assert.equal(r.show, false);
265
+ assert.equal(r.reason, "status_failed");
266
+ assert.ok(String(r.error || "").length > 0, "必须给可读错误");
267
+ });
268
+ } finally {
269
+ up.srv.close();
270
+ }
271
+ }
272
+ });
273
+
274
+ test("软着陆:整个企业端不可达(连 token 都拿不到)→ 200 + not_logged_in,面板不弹不报错", async () => {
275
+ await withPlugin(async ({ relayDir, startHost }) => {
276
+ // 指向一个一定没人监听的端口
277
+ await writeCfg(relayDir, "http://127.0.0.1:1");
278
+ const base = await listen(startHost());
279
+ const res = await fetch(`${base}/dsh-remote/community-invite/status`);
280
+ assert.equal(res.status, 200, "拿不到 token 属于'未登录',不是服务端错误(面板静默)");
281
+ const r = await res.json();
282
+ assert.equal(r.ok, false);
283
+ assert.equal(r.show, false);
284
+ assert.equal(r.reason, "not_logged_in");
285
+ });
286
+ });
287
+
288
+ test("错误语义:token 拿到了但企业端 community-invite 端点不可达 → 502 JSON(路由自身绝不抛)", async () => {
289
+ const up = await startFakeRelay({ killCommunity: true });
290
+ try {
291
+ await withPlugin(async ({ relayDir, startHost }) => {
292
+ await writeCfg(relayDir, up.base);
293
+ const base = await listen(startHost());
294
+ const res = await fetch(`${base}/dsh-remote/community-invite/status`);
295
+ assert.equal(res.status, 502, "已确认登录、失败发生在上游请求 → 502(排障可见,不是静默 200)");
296
+ const r = await res.json();
297
+ assert.equal(r.ok, false);
298
+ assert.equal(r.show, false);
299
+ assert.equal(r.reason, "status_failed");
300
+ assert.ok(String(r.error || "").length > 0);
301
+ // 路由本身绝不能把异常抛回 webServer(那会变成 500 + 堆栈)
302
+ assert.notEqual(res.status, 500);
303
+ });
304
+ } finally {
305
+ up.srv.close();
306
+ }
307
+ });
308
+
309
+ test("未知子路径 / 错误方法 → 404,不代理到奇怪的上游路径", async () => {
310
+ const up = await startFakeRelay();
311
+ try {
312
+ await withPlugin(async ({ relayDir, startHost }) => {
313
+ await writeCfg(relayDir, up.base);
314
+ const base = await listen(startHost());
315
+ for (const [p, method] of [["/dsh-remote/community-invite/other", "GET"], ["/dsh-remote/community-invite/status", "POST"], ["/dsh-remote/community-invite/shown", "GET"]]) {
316
+ const res = await fetch(`${base}${p}`, { method });
317
+ assert.equal(res.status, 404, `${method} ${p} 必须 404`);
318
+ const r = await res.json();
319
+ assert.equal(r.reason, "unknown_route");
320
+ }
321
+ assert.equal(ciReqs(up).length, 0, "未知路由绝不请求企业端");
322
+ });
323
+ } finally {
324
+ up.srv.close();
325
+ }
326
+ });
327
+
328
+ test("身份纪律:浏览器带来的 Authorization **不透传**(用户级接口只认本机 node 半 token)", async () => {
329
+ const up = await startFakeRelay();
330
+ try {
331
+ await withPlugin(async ({ relayDir, startHost }) => {
332
+ await writeCfg(relayDir, up.base);
333
+ const base = await listen(startHost());
334
+ await fetch(`${base}/dsh-remote/community-invite/status`, { headers: { authorization: "Bearer browser-forged" } });
335
+ const got = ciReqs(up).find((e) => e.method === "GET");
336
+ assert.ok(got);
337
+ assert.equal(got.headers.authorization, "Bearer jwt-abc",
338
+ "身份必须是本机 device-login 换来的 token;浏览器带的 token 不能替代(否则'谁的身份'就含糊了)");
339
+ });
340
+ } finally {
341
+ up.srv.close();
342
+ }
343
+ });